diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 5484f72ec..0ea0fa39d 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -276,63 +276,67 @@ export async function seasonResultsByUserId({ .orderBy("Skill.id", "desc") .execute(); - return rows.map((row) => { - if (row.groupMatch) { - const skillDiff = row.groupMatch?.memento?.users[userId]?.skillDifference; + return rows + .map((row) => { + if (row.groupMatch) { + const skillDiff = + row.groupMatch?.memento?.users[userId]?.skillDifference; - const chooseMostPopularWeapon = (userId: number) => { - const weaponSplIds = row - .groupMatch!.maps.flatMap((map) => map.weapons) - .filter((w) => w.userId === userId) - .map((w) => w.weaponSplId); + const chooseMostPopularWeapon = (userId: number) => { + const weaponSplIds = row + .groupMatch!.maps.flatMap((map) => map.weapons) + .filter((w) => w.userId === userId) + .map((w) => w.weaponSplId); - return mostPopularArrayElement(weaponSplIds); - }; + return mostPopularArrayElement(weaponSplIds); + }; - return { - type: "GROUP_MATCH" as const, - ...R.omit(row, ["groupMatch", "tournamentResult"]), - // older skills don't have createdAt, so we use groupMatch's createdAt as fallback - createdAt: row.createdAt ?? row.groupMatch.createdAt, - groupMatch: { - ...R.omit(row.groupMatch, ["createdAt", "memento", "maps"]), - // note there is no corresponding "censoring logic" for tournament result - // because for those the sp diff is not inserted in the first place - // if it should not be shown to the user - spDiff: skillDiff?.calculated ? skillDiff.spDiff : null, - groupAlphaMembers: row.groupMatch.groupAlphaMembers.map((m) => ({ - ...m, - weaponSplId: chooseMostPopularWeapon(m.id), - })), - groupBravoMembers: row.groupMatch.groupBravoMembers.map((m) => ({ - ...m, - weaponSplId: chooseMostPopularWeapon(m.id), - })), - score: row.groupMatch.maps.reduce( - (acc, cur) => [ - acc[0] + - (cur.winnerGroupId === row.groupMatch!.alphaGroupId ? 1 : 0), - acc[1] + - (cur.winnerGroupId === row.groupMatch!.bravoGroupId ? 1 : 0), - ], - [0, 0], - ), - }, - }; - } + return { + type: "GROUP_MATCH" as const, + ...R.omit(row, ["groupMatch", "tournamentResult"]), + // older skills don't have createdAt, so we use groupMatch's createdAt as fallback + createdAt: row.createdAt ?? row.groupMatch.createdAt, + groupMatch: { + ...R.omit(row.groupMatch, ["createdAt", "memento", "maps"]), + // note there is no corresponding "censoring logic" for tournament result + // because for those the sp diff is not inserted in the first place + // if it should not be shown to the user + spDiff: skillDiff?.calculated ? skillDiff.spDiff : null, + groupAlphaMembers: row.groupMatch.groupAlphaMembers.map((m) => ({ + ...m, + weaponSplId: chooseMostPopularWeapon(m.id), + })), + groupBravoMembers: row.groupMatch.groupBravoMembers.map((m) => ({ + ...m, + weaponSplId: chooseMostPopularWeapon(m.id), + })), + score: row.groupMatch.maps.reduce( + (acc, cur) => [ + acc[0] + + (cur.winnerGroupId === row.groupMatch!.alphaGroupId ? 1 : 0), + acc[1] + + (cur.winnerGroupId === row.groupMatch!.bravoGroupId ? 1 : 0), + ], + [0, 0], + ), + }, + }; + } - if (row.tournamentResult) { - return { - type: "TOURNAMENT_RESULT" as const, - ...R.omit(row, ["groupMatch", "tournamentResult"]), - // older skills don't have createdAt, so we use tournament's start time as a fallback - createdAt: row.createdAt ?? row.tournamentResult.tournamentStartTime, - tournamentResult: row.tournamentResult, - }; - } + if (row.tournamentResult) { + return { + type: "TOURNAMENT_RESULT" as const, + ...R.omit(row, ["groupMatch", "tournamentResult"]), + // older skills don't have createdAt, so we use tournament's start time as a fallback + createdAt: row.createdAt ?? row.tournamentResult.tournamentStartTime, + tournamentResult: row.tournamentResult, + }; + } - throw new Error("Row does not contain groupMatch or tournamentResult"); - }); + // Skills from dropped teams without tournament results - skip these + return null; + }) + .filter((result) => result !== null); } export async function seasonCanceledMatchesByUserId({ diff --git a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts index fa9ecb67c..d03172e0a 100644 --- a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts @@ -5,6 +5,7 @@ import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server"; import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; @@ -221,6 +222,10 @@ export const action: ActionFunction = async ({ params, request }) => { tournamentTeamId: match.opponentTwo!.id!, }); } + + if (setOver) { + endDroppedTeamMatches({ tournament, manager }); + } })(); emitMatchUpdate = true; diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index f699ceaed..b526dd3d2 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -304,6 +304,7 @@ export function PlacementsTable({ allMatchesFinished={allMatchesFinished} canEditDestination={canEditDestination} tournamentTeamId={s.team.id} + droppedOut={Boolean(s.team.droppedOut)} /> {!eliminatedRowRendered && @@ -333,6 +334,7 @@ function EditableDestination({ allMatchesFinished, canEditDestination, tournamentTeamId, + droppedOut, }: { source: Bracket; destination?: Bracket; @@ -341,6 +343,7 @@ function EditableDestination({ allMatchesFinished: boolean; canEditDestination: boolean; tournamentTeamId: number; + droppedOut: boolean; }) { const fetcher = useFetcher(); const [editingDestination, setEditingDestination] = React.useState(false); @@ -405,9 +408,11 @@ function EditableDestination({ return ( <> - {allMatchesFinished && - overridenDestination && - overridenDestination.idx !== destination?.idx ? ( + {droppedOut ? ( + + ) : allMatchesFinished && + overridenDestination && + overridenDestination.idx !== destination?.idx ? ( → {overridenDestination.name} @@ -422,7 +427,7 @@ function EditableDestination({ ) : ( )} - {canEditDestination ? ( + {canEditDestination && !droppedOut ? ( setEditingDestination(true)} /> + ) : canEditDestination ? ( + ) : null} ); diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index c0ec90f4a..d3d612bcc 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -182,9 +182,20 @@ export class RoundRobinBracket extends Bracket { } } + const droppedOutTeams = this.tournament.ctx.teams + .filter((t) => t.droppedOut) + .map((t) => t.id); + placements.push( ...teams .sort((a, b) => { + // TIEBREAKER 0) dropped out teams are always last + const aDroppedOut = droppedOutTeams.includes(a.id); + const bDroppedOut = droppedOutTeams.includes(b.id); + + if (aDroppedOut && !bDroppedOut) return 1; + if (!aDroppedOut && bDroppedOut) return -1; + if (a.setWins > b.setWins) return -1; if (a.setWins < b.setWins) return 1; diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index d213912d5..f17006a6b 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -204,26 +204,24 @@ export class SwissBracket extends Bracket { if (!winner || !loser) continue; invariant( - typeof winner.id === "number" && - typeof loser.id === "number" && - typeof winner.score === "number" && - typeof loser.score === "number", - "RoundRobinBracket.standings: winner or loser id not found", + typeof winner.id === "number" && typeof loser.id === "number", + "SwissBracket.standings: winner or loser id not found", ); + // note: score might be missing in the case the set was ended early updateTeam({ teamId: winner.id, setWins: 1, setLosses: 0, - mapWins: winner.score, - mapLosses: loser.score, + mapWins: winner.score ?? 0, + mapLosses: loser.score ?? 0, }); updateTeam({ teamId: loser.id, setWins: 0, setLosses: 1, - mapWins: loser.score, - mapLosses: winner.score, + mapWins: loser.score ?? 0, + mapLosses: winner.score ?? 0, }); } diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index ad2aadbe2..2876c3475 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -337,8 +337,15 @@ export class Tournament { }) .map(({ id }) => id); + // Filter out dropped teams from advancing to follow-up brackets + const allTeams = teams.concat(overridesWithoutRepeats); + const activeTeams = allTeams.filter((teamId) => { + const team = this.teamById(teamId); + return team && !team.droppedOut; + }); + return { - teams: teams.concat(overridesWithoutRepeats), + teams: activeTeams, relevantMatchesFinished: allRelevantMatchesFinished, }; } diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index ba2f457e8..9d3cb9c98 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -73,12 +73,17 @@ export function tournamentSummary({ progression: ParsedBracket[]; }): TournamentSummary { const resultsWithoutEarlyEndedSets = results.filter((match) => { - return !matchEndedEarly({ + const endedEarly = matchEndedEarly({ opponentOne: match.opponentOne, opponentTwo: match.opponentTwo, count: match.roundMaps.count, countType: match.roundMaps.type, }); + + if (!endedEarly) return true; + + // Include early-ended sets where a team dropped out (they still affect skills) + return match.opponentOne.droppedOut || match.opponentTwo.droppedOut; }); const skills = calculateSeasonalStats @@ -199,8 +204,27 @@ export function calculateIndividualPlayerSkills({ * For each team (winner and loser), this function collects all user IDs from the match's map participants, * counts their occurrences, and returns the most popular user IDs up to a full team's worth depending on the tournament format (4v4, 3v3 etc.). * If there are ties at the cutoff, all tied user IDs are included. + * + * For dropped team sets without game results, uses the activeRosterUserIds from the team records. */ function matchToSetMostPlayedUsers(match: AllMatchResult) { + const winner = + match.opponentOne.result === "win" ? match.opponentOne : match.opponentTwo; + const loser = + match.opponentOne.result === "win" ? match.opponentTwo : match.opponentOne; + + // Handle dropped team sets without game results - use active roster or member list + if (match.maps.length === 0) { + const winnerRoster = + winner.activeRosterUserIds ?? winner.memberUserIds ?? []; + const loserRoster = loser.activeRosterUserIds ?? loser.memberUserIds ?? []; + + return { + winnerUserIds: winnerRoster, + loserUserIds: loserRoster, + }; + } + const resolveMostPopularUserIds = (userIds: number[]) => { const counts = userIds.reduce((acc, userId) => { acc.set(userId, (acc.get(userId) ?? 0) + 1); @@ -227,16 +251,12 @@ function matchToSetMostPlayedUsers(match: AllMatchResult) { return result; }; - const winnerTeamId = - match.opponentOne.result === "win" - ? match.opponentOne.id - : match.opponentTwo.id; const participants = match.maps.flatMap((m) => m.participants); const winnerUserIds = participants - .filter((p) => p.tournamentTeamId === winnerTeamId) + .filter((p) => p.tournamentTeamId === winner.id) .map((p) => p.userId); const loserUserIds = participants - .filter((p) => p.tournamentTeamId !== winnerTeamId) + .filter((p) => p.tournamentTeamId !== winner.id) .map((p) => p.userId); return { @@ -264,28 +284,51 @@ function calculateTeamSkills({ }; for (const match of results) { - const winnerTeamId = + const winner = match.opponentOne.result === "win" - ? match.opponentOne.id - : match.opponentTwo.id; + ? match.opponentOne + : match.opponentTwo; + const loser = + match.opponentOne.result === "win" + ? match.opponentTwo + : match.opponentOne; - const winnerTeamIdentifiers = match.maps.flatMap((m) => { - const winnerUserIds = m.participants - .filter((p) => p.tournamentTeamId === winnerTeamId) - .map((p) => p.userId); + // Handle dropped team sets without game results - use active roster or member list + let winnerTeamIdentifier: string; + let loserTeamIdentifier: string; - return userIdsToIdentifier(winnerUserIds); - }); - const winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers); + if (match.maps.length === 0) { + // Use activeRosterUserIds if set, otherwise fall back to memberUserIds + // (teams without subs have their roster trivially inferred from members) + const winnerRoster = + winner.activeRosterUserIds ?? winner.memberUserIds ?? []; + const loserRoster = + loser.activeRosterUserIds ?? loser.memberUserIds ?? []; - const loserTeamIdentifiers = match.maps.flatMap((m) => { - const loserUserIds = m.participants - .filter((p) => p.tournamentTeamId !== winnerTeamId) - .map((p) => p.userId); + // Skip if no roster info available (defensive check) + if (winnerRoster.length === 0 || loserRoster.length === 0) continue; - return userIdsToIdentifier(loserUserIds); - }); - const loserTeamIdentifier = selectMostPopular(loserTeamIdentifiers); + winnerTeamIdentifier = userIdsToIdentifier(winnerRoster); + loserTeamIdentifier = userIdsToIdentifier(loserRoster); + } else { + const winnerTeamIdentifiers = match.maps.flatMap((m) => { + const winnerUserIds = m.participants + .filter((p) => p.tournamentTeamId === winner.id) + .map((p) => p.userId); + + return userIdsToIdentifier(winnerUserIds); + }); + winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers); + + const loserTeamIdentifiers = match.maps.flatMap((m) => { + const loserUserIds = m.participants + .filter((p) => p.tournamentTeamId !== winner.id) + .map((p) => p.userId); + + return userIdsToIdentifier(loserUserIds); + }); + loserTeamIdentifier = selectMostPopular(loserTeamIdentifiers); + } const [[ratedWinner], [ratedLoser]] = rate( [ @@ -446,6 +489,11 @@ function playerResultDeltas( } } + // Skip sets with no maps (ended early) + if (match.maps.length === 0) { + continue; + } + const mostPopularParticipants = (() => { const alphaIdentifiers: string[] = []; const bravoIdentifiers: string[] = []; diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 979aaafa5..04c0af2a5 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -6,6 +6,22 @@ import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.se import { tournamentSummary } from "./summarizer.server"; import type { TournamentDataTeam } from "./Tournament.server"; +const createOpponent = ( + id: number, + result: "win" | "loss", + score: number, + droppedOut = false, + activeRosterUserIds: number[] | null = null, + memberUserIds: number[] = [], +): AllMatchResult["opponentOne"] => ({ + id, + result, + score, + droppedOut, + activeRosterUserIds, + memberUserIds, +}); + describe("tournamentSummary()", () => { const createTeam = ( teamId: number, @@ -170,16 +186,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(1, "win", 2), + opponentTwo: createOpponent(2, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -287,16 +295,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(1, "win", 2), + opponentTwo: createOpponent(2, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -335,16 +335,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(1, "win", 2), + opponentTwo: createOpponent(2, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -429,16 +421,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 1, - }, + opponentOne: createOpponent(1, "win", 2), + opponentTwo: createOpponent(2, "loss", 1), roundMaps: { count: 3, type: "BEST_OF", @@ -602,16 +586,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 3, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(1, "win", 3), + opponentTwo: createOpponent(2, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -660,16 +636,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 2, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(1, "win", 2), + opponentTwo: createOpponent(2, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -828,16 +796,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 0, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(1, "win", 0), + opponentTwo: createOpponent(2, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -872,16 +832,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 1, }, ], - opponentOne: { - id: 1, - result: "win", - score: 1, - }, - opponentTwo: { - id: 2, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(1, "win", 1), + opponentTwo: createOpponent(2, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -905,16 +857,8 @@ describe("tournamentSummary()", () => { winnerTeamId: 3, }, ], - opponentOne: { - id: 3, - result: "win", - score: 0, - }, - opponentTwo: { - id: 4, - result: "loss", - score: 0, - }, + opponentOne: createOpponent(3, "win", 0), + opponentTwo: createOpponent(4, "loss", 0), roundMaps: { count: 3, type: "BEST_OF", @@ -941,4 +885,99 @@ describe("tournamentSummary()", () => { expect(skillsFromTeam3.length).toBe(0); expect(skillsFromTeam4.length).toBe(0); }); + + test("includes early-ended matches from dropped teams in skill calculations", () => { + const summary = summarize({ + results: [ + { + maps: [ + { + mode: "SZ", + stageId: 1, + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], + winnerTeamId: 1, + }, + ], + opponentOne: createOpponent(1, "win", 1, false), + opponentTwo: createOpponent(2, "loss", 0, true), + roundMaps: { + count: 3, + type: "BEST_OF", + }, + }, + ], + }); + + const skillsFromTeam1 = summary.skills.filter((s) => + [1, 2, 3, 4].includes(s.userId ?? 0), + ); + const skillsFromTeam2 = summary.skills.filter((s) => + [5, 6, 7, 8].includes(s.userId ?? 0), + ); + + expect(skillsFromTeam1.length).toBe(4); + expect(skillsFromTeam2.length).toBe(4); + }); + + test("includes dropped team sets without maps using active roster", () => { + const summary = summarize({ + results: [ + { + maps: [], + opponentOne: createOpponent(1, "win", 0, false, [1, 2, 3, 4]), + opponentTwo: createOpponent(2, "loss", 0, true, [5, 6, 7, 8]), + roundMaps: { + count: 3, + type: "BEST_OF", + }, + }, + ], + }); + + const skillsFromTeam1 = summary.skills.filter((s) => + [1, 2, 3, 4].includes(s.userId ?? 0), + ); + const skillsFromTeam2 = summary.skills.filter((s) => + [5, 6, 7, 8].includes(s.userId ?? 0), + ); + + expect(skillsFromTeam1.length).toBe(4); + expect(skillsFromTeam2.length).toBe(4); + }); + + test("includes dropped team sets without maps using memberUserIds as fallback", () => { + const summary = summarize({ + results: [ + { + maps: [], + // No activeRosterUserIds, but memberUserIds is set + opponentOne: createOpponent(1, "win", 0, false, null, [1, 2, 3, 4]), + opponentTwo: createOpponent(2, "loss", 0, true, null, [5, 6, 7, 8]), + roundMaps: { + count: 3, + type: "BEST_OF", + }, + }, + ], + }); + + const skillsFromTeam1 = summary.skills.filter((s) => + [1, 2, 3, 4].includes(s.userId ?? 0), + ); + const skillsFromTeam2 = summary.skills.filter((s) => + [5, 6, 7, 8].includes(s.userId ?? 0), + ); + + expect(skillsFromTeam1.length).toBe(4); + expect(skillsFromTeam2.length).toBe(4); + }); }); diff --git a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts index 28a267198..8b51238ec 100644 --- a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts +++ b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts @@ -17,6 +17,20 @@ const stm = sql.prepare(/* sql */ ` from "TournamentMatchGameResult" left join "TournamentMatchGameResultParticipant" on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id" group by "TournamentMatchGameResult"."id" + ), + "TeamMembers1" as ( + select + "tournamentTeamId", + json_group_array("userId") as "memberUserIds" + from "TournamentTeamMember" + group by "tournamentTeamId" + ), + "TeamMembers2" as ( + select + "tournamentTeamId", + json_group_array("userId") as "memberUserIds" + from "TournamentTeamMember" + group by "tournamentTeamId" ) select "m"."opponentOne" ->> '$.id' as "opponentOneId", @@ -26,6 +40,12 @@ const stm = sql.prepare(/* sql */ ` "m"."opponentOne" ->> '$.result' as "opponentOneResult", "m"."opponentTwo" ->> '$.result' as "opponentTwoResult", "TournamentRound"."maps" as "roundMaps", + "Team1"."droppedOut" as "opponentOneDroppedOut", + "Team2"."droppedOut" as "opponentTwoDroppedOut", + "Team1"."activeRosterUserIds" as "opponentOneActiveRoster", + "Team2"."activeRosterUserIds" as "opponentTwoActiveRoster", + "TeamMembers1"."memberUserIds" as "opponentOneMemberUserIds", + "TeamMembers2"."memberUserIds" as "opponentTwoMemberUserIds", json_group_array( json_object( 'stageId', @@ -43,8 +63,12 @@ const stm = sql.prepare(/* sql */ ` inner join "TournamentRound" on "TournamentRound"."id" = "m"."roundId" left join "TournamentStage" on "TournamentStage"."id" = "m"."stageId" left join "q1" on "q1"."matchId" = "m"."id" + left join "TournamentTeam" as "Team1" on "Team1"."id" = "m"."opponentOne" ->> '$.id' + left join "TournamentTeam" as "Team2" on "Team2"."id" = "m"."opponentTwo" ->> '$.id' + left join "TeamMembers1" on "TeamMembers1"."tournamentTeamId" = "Team1"."id" + left join "TeamMembers2" on "TeamMembers2"."tournamentTeamId" = "Team2"."id" where "TournamentStage"."tournamentId" = @tournamentId - and "opponentOneId" is not null + and "opponentOneId" is not null and "opponentTwoId" is not null and "opponentOneResult" is not null group by "m"."id" @@ -55,6 +79,9 @@ interface Opponent { id: number; score: number; result: "win" | "loss"; + droppedOut: boolean; + activeRosterUserIds: number[] | null; + memberUserIds: number[]; } export interface AllMatchResult { opponentOne: Opponent; @@ -80,44 +107,75 @@ export function allMatchResultsByTournamentId( return rows.map((row) => { const roundMaps = JSON.parse(row.roundMaps) as TournamentRoundMaps; - const opponentOne = { + const parseActiveRoster = (roster: string | null): number[] | null => { + if (!roster) return null; + try { + const parsed = JSON.parse(roster); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + }; + + const parseMemberUserIds = (members: string | null): number[] => { + if (!members) return []; + try { + const parsed = JSON.parse(members); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }; + + const opponentOne: Opponent = { id: row.opponentOneId, score: row.opponentOneScore, result: row.opponentOneResult, + droppedOut: row.opponentOneDroppedOut === 1, + activeRosterUserIds: parseActiveRoster(row.opponentOneActiveRoster), + memberUserIds: parseMemberUserIds(row.opponentOneMemberUserIds), }; - const opponentTwo = { + const opponentTwo: Opponent = { id: row.opponentTwoId, score: row.opponentTwoScore, result: row.opponentTwoResult, + droppedOut: row.opponentTwoDroppedOut === 1, + activeRosterUserIds: parseActiveRoster(row.opponentTwoActiveRoster), + memberUserIds: parseMemberUserIds(row.opponentTwoMemberUserIds), }; + const rawMaps = parseDBJsonArray(row.maps); + const hasGameResults = rawMaps.length > 0 && rawMaps[0].stageId !== null; + return { opponentOne, opponentTwo, roundMaps, - maps: parseDBJsonArray(row.maps).map((map: any) => { - const participants = parseDBArray(map.participants); - invariant(participants.length > 0, "No participants found"); - invariant( - participants.every( - (p: any) => typeof p.tournamentTeamId === "number", - ), - "Some participants have no team id", - ); - invariant( - participants.every( - (p: any) => - p.tournamentTeamId === row.opponentOneId || - p.tournamentTeamId === row.opponentTwoId, - ), - "Some participants have an invalid team id", - ); + maps: hasGameResults + ? rawMaps.map((map: any) => { + const participants = parseDBArray(map.participants); + invariant(participants.length > 0, "No participants found"); + invariant( + participants.every( + (p: any) => typeof p.tournamentTeamId === "number", + ), + "Some participants have no team id", + ); + invariant( + participants.every( + (p: any) => + p.tournamentTeamId === row.opponentOneId || + p.tournamentTeamId === row.opponentTwoId, + ), + "Some participants have an invalid team id", + ); - return { - ...map, - participants, - }; - }), + return { + ...map, + participants, + }; + }) + : [], }; }); } diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index c55e1852a..00e0a1bdd 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -385,6 +385,18 @@ function EndedEarlyMessage() { const winnerTeam = winnerTeamId ? tournament.teamById(winnerTeamId) : null; + const opponentOneTeam = data.match.opponentOne?.id + ? tournament.teamById(data.match.opponentOne.id) + : null; + const opponentTwoTeam = data.match.opponentTwo?.id + ? tournament.teamById(data.match.opponentTwo.id) + : null; + const droppedTeam = opponentOneTeam?.droppedOut + ? opponentOneTeam + : opponentTwoTeam?.droppedOut + ? opponentTwoTeam + : null; + return (
@@ -392,7 +404,9 @@ function EndedEarlyMessage() {
Match ended early
{winnerTeam ? (
- The organizer ended this match as it exceeded the time limit. + {droppedTeam + ? `${droppedTeam.name} dropped out of the tournament.` + : "The organizer ended this match as it exceeded the time limit."}{" "} Winner: {winnerTeam.name}
) : null} diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts index 6bfa896b4..302c44e83 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -1,9 +1,11 @@ import type { ActionFunction } from "react-router"; +import * as R from "remeda"; import { requireUser } from "~/features/auth/core/user.server"; import { userIsBanned } from "~/features/ban/core/banned.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { notify } from "~/features/notifications/core/notify.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import { clearTournamentDataCache, @@ -26,7 +28,10 @@ import { deleteTeam } from "../queries/deleteTeam.server"; import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server"; import * as TournamentRepository from "../TournamentRepository.server"; import { adminActionSchema } from "../tournament-schemas.server"; -import { inGameNameIfNeeded } from "../tournament-utils.server"; +import { + endDroppedTeamMatches, + inGameNameIfNeeded, +} from "../tournament-utils.server"; export const action: ActionFunction = async ({ request, params }) => { const user = await requireUser(request); @@ -355,6 +360,30 @@ export const action: ActionFunction = async ({ request, params }) => { } case "DROP_TEAM_OUT": { validateIsTournamentOrganizer(); + const droppingTeam = tournament.teamById(data.teamId); + errorToastIfFalsy(droppingTeam, "Invalid team id"); + + // Set active roster only for teams with subs (can't infer which players played) + // Teams without subs have their roster trivially inferred in summarizer + const hasSubs = + droppingTeam.members.length > tournament.minMembersPerTeam; + if (hasSubs && !droppingTeam.activeRosterUserIds) { + const randomRoster = R.sample( + droppingTeam.members.map((m) => m.userId), + tournament.minMembersPerTeam, + ); + await TournamentTeamRepository.setActiveRoster({ + teamId: data.teamId, + activeRosterUserIds: randomRoster, + }); + } + + endDroppedTeamMatches({ + tournament, + manager: getServerTournamentManager(), + droppedTeamId: data.teamId, + }); + await TournamentRepository.dropTeamOut({ tournamentTeamId: data.teamId, previewBracketIdxs: tournament.brackets.flatMap((b, idx) => diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index ccac81737..fd50fc77e 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -173,12 +173,12 @@ const actions = [ { type: "DROP_TEAM_OUT", inputs: ["REGISTERED_TEAM"] as InputType[], - when: ["TOURNAMENT_AFTER_START", "IS_SWISS"], + when: ["TOURNAMENT_AFTER_START"], }, { type: "UNDO_DROP_TEAM_OUT", inputs: ["REGISTERED_TEAM"] as InputType[], - when: ["TOURNAMENT_AFTER_START", "IS_SWISS"], + when: ["TOURNAMENT_AFTER_START"], }, { type: "UPDATE_IN_GAME_NAME", @@ -234,13 +234,6 @@ function TeamActions() { break; } - case "IS_SWISS": { - if (!tournament.brackets.some((b) => b.type === "swiss")) { - return false; - } - - break; - } case "IN_GAME_NAME_REQUIRED": { if (!tournament.ctx.settings.requireInGameNames) { return false; diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 0a7095a40..5ee29b5f8 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -1,5 +1,7 @@ +import type { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { logger } from "~/utils/logger"; import { errorToast, errorToastIfFalsy } from "~/utils/remix.server"; import type { Tournament } from "../tournament-bracket/core/Tournament"; @@ -40,3 +42,63 @@ export async function requireNotBannedByOrganization({ errorToast(message); } } + +/** + * Ends all unfinished matches involving dropped teams by awarding wins to their opponents. + * If both teams in a match have dropped, a random winner is selected. + * + * @param tournament - The tournament instance + * @param manager - The bracket manager instance used to update matches + * @param droppedTeamId - Optional team ID to filter matches for a specific dropped team. + * If omitted, processes all matches with any dropped team. + */ +export function endDroppedTeamMatches({ + tournament, + manager, + droppedTeamId, +}: { + tournament: Tournament; + manager: ReturnType; + droppedTeamId?: number; +}) { + const stageData = manager.get.tournamentData(tournament.ctx.id); + + for (const match of stageData.match) { + if (!match.opponent1?.id || !match.opponent2?.id) continue; + if (match.opponent1.result === "win" || match.opponent2.result === "win") + continue; + + const team1 = tournament.teamById(match.opponent1.id); + const team2 = tournament.teamById(match.opponent2.id); + + const team1Dropped = + team1?.droppedOut || match.opponent1.id === droppedTeamId; + const team2Dropped = + team2?.droppedOut || match.opponent2.id === droppedTeamId; + + if (!team1Dropped && !team2Dropped) continue; + + const winnerTeamId = (() => { + if (team1Dropped && !team2Dropped) return match.opponent2.id; + if (!team1Dropped && team2Dropped) return match.opponent1.id; + return Math.random() < 0.5 ? match.opponent1.id : match.opponent2.id; + })(); + + logger.info( + `Ending match with dropped team: Match ID: ${match.id}; Team1 dropped: ${team1Dropped}; Team2 dropped: ${team2Dropped}; Winner: ${winnerTeamId}`, + ); + + manager.update.match( + { + id: match.id, + opponent1: { + result: winnerTeamId === match.opponent1.id ? "win" : "loss", + }, + opponent2: { + result: winnerTeamId === match.opponent2.id ? "win" : "loss", + }, + }, + true, + ); + } +} diff --git a/app/modules/brackets-manager/update.ts b/app/modules/brackets-manager/update.ts index bea3a3cfa..20b661715 100644 --- a/app/modules/brackets-manager/update.ts +++ b/app/modules/brackets-manager/update.ts @@ -9,13 +9,17 @@ export class Update extends BaseUpdater { * This will update related matches accordingly. * * @param match Values to change in a match. + * @param force If true, bypasses the locked match check. */ - public match(match: DeepPartial): void { + public match( + match: DeepPartial, + force?: boolean, + ): void { if (match.id === undefined) throw Error("No match id given."); const stored = this.storage.select("match", match.id); if (!stored) throw Error("Match not found."); - this.updateMatch(stored, match); + this.updateMatch(stored, match, force); } } diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index efd6ec12a..581fa7f7d 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -1127,4 +1127,48 @@ test.describe("Tournament bracket", () => { // Verify match ended early await expect(page.getByText("Match ended early")).toBeVisible(); }); + + test("dropping team out ends ongoing match early and auto-forfeits losers bracket match", async ({ + page, + }) => { + const tournamentId = 2; + + await startBracket(page, tournamentId); + + // 1) Report partial score on match 5 (winners bracket) + await navigateToMatch(page, 5); + await reportResult({ page, amountOfMapsToReport: 1, winner: 1 }); + await backToBracket(page); + + // 2) Drop team 102 (one of the teams in match 5) via admin + await navigate({ + page, + url: tournamentAdminPage(tournamentId), + }); + await page.getByLabel("Action").selectOption("DROP_TEAM_OUT"); + await page.getByLabel("Team", { exact: true }).selectOption("102"); + await submit(page); + + // 3) Verify the ongoing match ended early + await navigate({ + page, + url: tournamentMatchPage({ tournamentId, matchId: 5 }), + }); + await expect(page.getByText("Match ended early")).toBeVisible(); + await expect(page.getByText("dropped out of the tournament")).toBeVisible(); + await backToBracket(page); + + // 4) Complete the adjacent match (match 6) so its loser goes to losers bracket + await navigateToMatch(page, 6); + await reportResult({ page, amountOfMapsToReport: 2 }); + await backToBracket(page); + + // 5) The losers bracket match (match 18) should now have teams: + // - Loser of match 5 (team 102, dropped) + // - Loser of match 6 + // It should have ended early since team 102 is dropped + await navigateToMatch(page, 18); + await expect(page.getByText("Match ended early")).toBeVisible(); + await expect(page.getByText("dropped out of the tournament")).toBeVisible(); + }); });