Drop teams in any bracket format (#2684)

This commit is contained in:
Kalle 2025-12-30 19:31:24 +02:00 committed by GitHub
parent 393955f5eb
commit 0393575c87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 541 additions and 218 deletions

View File

@ -276,63 +276,67 @@ export async function seasonResultsByUserId({
.orderBy("Skill.id", "desc") .orderBy("Skill.id", "desc")
.execute(); .execute();
return rows.map((row) => { return rows
if (row.groupMatch) { .map((row) => {
const skillDiff = row.groupMatch?.memento?.users[userId]?.skillDifference; if (row.groupMatch) {
const skillDiff =
row.groupMatch?.memento?.users[userId]?.skillDifference;
const chooseMostPopularWeapon = (userId: number) => { const chooseMostPopularWeapon = (userId: number) => {
const weaponSplIds = row const weaponSplIds = row
.groupMatch!.maps.flatMap((map) => map.weapons) .groupMatch!.maps.flatMap((map) => map.weapons)
.filter((w) => w.userId === userId) .filter((w) => w.userId === userId)
.map((w) => w.weaponSplId); .map((w) => w.weaponSplId);
return mostPopularArrayElement(weaponSplIds); return mostPopularArrayElement(weaponSplIds);
}; };
return { return {
type: "GROUP_MATCH" as const, type: "GROUP_MATCH" as const,
...R.omit(row, ["groupMatch", "tournamentResult"]), ...R.omit(row, ["groupMatch", "tournamentResult"]),
// older skills don't have createdAt, so we use groupMatch's createdAt as fallback // older skills don't have createdAt, so we use groupMatch's createdAt as fallback
createdAt: row.createdAt ?? row.groupMatch.createdAt, createdAt: row.createdAt ?? row.groupMatch.createdAt,
groupMatch: { groupMatch: {
...R.omit(row.groupMatch, ["createdAt", "memento", "maps"]), ...R.omit(row.groupMatch, ["createdAt", "memento", "maps"]),
// note there is no corresponding "censoring logic" for tournament result // note there is no corresponding "censoring logic" for tournament result
// because for those the sp diff is not inserted in the first place // because for those the sp diff is not inserted in the first place
// if it should not be shown to the user // if it should not be shown to the user
spDiff: skillDiff?.calculated ? skillDiff.spDiff : null, spDiff: skillDiff?.calculated ? skillDiff.spDiff : null,
groupAlphaMembers: row.groupMatch.groupAlphaMembers.map((m) => ({ groupAlphaMembers: row.groupMatch.groupAlphaMembers.map((m) => ({
...m, ...m,
weaponSplId: chooseMostPopularWeapon(m.id), weaponSplId: chooseMostPopularWeapon(m.id),
})), })),
groupBravoMembers: row.groupMatch.groupBravoMembers.map((m) => ({ groupBravoMembers: row.groupMatch.groupBravoMembers.map((m) => ({
...m, ...m,
weaponSplId: chooseMostPopularWeapon(m.id), weaponSplId: chooseMostPopularWeapon(m.id),
})), })),
score: row.groupMatch.maps.reduce( score: row.groupMatch.maps.reduce(
(acc, cur) => [ (acc, cur) => [
acc[0] + acc[0] +
(cur.winnerGroupId === row.groupMatch!.alphaGroupId ? 1 : 0), (cur.winnerGroupId === row.groupMatch!.alphaGroupId ? 1 : 0),
acc[1] + acc[1] +
(cur.winnerGroupId === row.groupMatch!.bravoGroupId ? 1 : 0), (cur.winnerGroupId === row.groupMatch!.bravoGroupId ? 1 : 0),
], ],
[0, 0], [0, 0],
), ),
}, },
}; };
} }
if (row.tournamentResult) { if (row.tournamentResult) {
return { return {
type: "TOURNAMENT_RESULT" as const, type: "TOURNAMENT_RESULT" as const,
...R.omit(row, ["groupMatch", "tournamentResult"]), ...R.omit(row, ["groupMatch", "tournamentResult"]),
// older skills don't have createdAt, so we use tournament's start time as a fallback // older skills don't have createdAt, so we use tournament's start time as a fallback
createdAt: row.createdAt ?? row.tournamentResult.tournamentStartTime, createdAt: row.createdAt ?? row.tournamentResult.tournamentStartTime,
tournamentResult: row.tournamentResult, 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({ export async function seasonCanceledMatchesByUserId({

View File

@ -5,6 +5,7 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.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 * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
import invariant from "~/utils/invariant"; import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger"; import { logger } from "~/utils/logger";
@ -221,6 +222,10 @@ export const action: ActionFunction = async ({ params, request }) => {
tournamentTeamId: match.opponentTwo!.id!, tournamentTeamId: match.opponentTwo!.id!,
}); });
} }
if (setOver) {
endDroppedTeamMatches({ tournament, manager });
}
})(); })();
emitMatchUpdate = true; emitMatchUpdate = true;

View File

@ -304,6 +304,7 @@ export function PlacementsTable({
allMatchesFinished={allMatchesFinished} allMatchesFinished={allMatchesFinished}
canEditDestination={canEditDestination} canEditDestination={canEditDestination}
tournamentTeamId={s.team.id} tournamentTeamId={s.team.id}
droppedOut={Boolean(s.team.droppedOut)}
/> />
</tr> </tr>
{!eliminatedRowRendered && {!eliminatedRowRendered &&
@ -333,6 +334,7 @@ function EditableDestination({
allMatchesFinished, allMatchesFinished,
canEditDestination, canEditDestination,
tournamentTeamId, tournamentTeamId,
droppedOut,
}: { }: {
source: Bracket; source: Bracket;
destination?: Bracket; destination?: Bracket;
@ -341,6 +343,7 @@ function EditableDestination({
allMatchesFinished: boolean; allMatchesFinished: boolean;
canEditDestination: boolean; canEditDestination: boolean;
tournamentTeamId: number; tournamentTeamId: number;
droppedOut: boolean;
}) { }) {
const fetcher = useFetcher<any>(); const fetcher = useFetcher<any>();
const [editingDestination, setEditingDestination] = React.useState(false); const [editingDestination, setEditingDestination] = React.useState(false);
@ -405,9 +408,11 @@ function EditableDestination({
return ( return (
<> <>
{allMatchesFinished && {droppedOut ? (
overridenDestination && <td />
overridenDestination.idx !== destination?.idx ? ( ) : allMatchesFinished &&
overridenDestination &&
overridenDestination.idx !== destination?.idx ? (
<td className="text-theme font-bold"> <td className="text-theme font-bold">
<span> {overridenDestination.name}</span> <span> {overridenDestination.name}</span>
</td> </td>
@ -422,7 +427,7 @@ function EditableDestination({
) : ( ) : (
<td /> <td />
)} )}
{canEditDestination ? ( {canEditDestination && !droppedOut ? (
<td> <td>
<SendouButton <SendouButton
variant="minimal" variant="minimal"
@ -431,6 +436,8 @@ function EditableDestination({
onPress={() => setEditingDestination(true)} onPress={() => setEditingDestination(true)}
/> />
</td> </td>
) : canEditDestination ? (
<td />
) : null} ) : null}
</> </>
); );

View File

@ -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( placements.push(
...teams ...teams
.sort((a, b) => { .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;
if (a.setWins < b.setWins) return 1; if (a.setWins < b.setWins) return 1;

View File

@ -204,26 +204,24 @@ export class SwissBracket extends Bracket {
if (!winner || !loser) continue; if (!winner || !loser) continue;
invariant( invariant(
typeof winner.id === "number" && typeof winner.id === "number" && typeof loser.id === "number",
typeof loser.id === "number" && "SwissBracket.standings: winner or loser id not found",
typeof winner.score === "number" &&
typeof loser.score === "number",
"RoundRobinBracket.standings: winner or loser id not found",
); );
// note: score might be missing in the case the set was ended early
updateTeam({ updateTeam({
teamId: winner.id, teamId: winner.id,
setWins: 1, setWins: 1,
setLosses: 0, setLosses: 0,
mapWins: winner.score, mapWins: winner.score ?? 0,
mapLosses: loser.score, mapLosses: loser.score ?? 0,
}); });
updateTeam({ updateTeam({
teamId: loser.id, teamId: loser.id,
setWins: 0, setWins: 0,
setLosses: 1, setLosses: 1,
mapWins: loser.score, mapWins: loser.score ?? 0,
mapLosses: winner.score, mapLosses: winner.score ?? 0,
}); });
} }

View File

@ -337,8 +337,15 @@ export class Tournament {
}) })
.map(({ id }) => id); .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 { return {
teams: teams.concat(overridesWithoutRepeats), teams: activeTeams,
relevantMatchesFinished: allRelevantMatchesFinished, relevantMatchesFinished: allRelevantMatchesFinished,
}; };
} }

View File

@ -73,12 +73,17 @@ export function tournamentSummary({
progression: ParsedBracket[]; progression: ParsedBracket[];
}): TournamentSummary { }): TournamentSummary {
const resultsWithoutEarlyEndedSets = results.filter((match) => { const resultsWithoutEarlyEndedSets = results.filter((match) => {
return !matchEndedEarly({ const endedEarly = matchEndedEarly({
opponentOne: match.opponentOne, opponentOne: match.opponentOne,
opponentTwo: match.opponentTwo, opponentTwo: match.opponentTwo,
count: match.roundMaps.count, count: match.roundMaps.count,
countType: match.roundMaps.type, 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 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, * 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.). * 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. * 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) { 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 resolveMostPopularUserIds = (userIds: number[]) => {
const counts = userIds.reduce((acc, userId) => { const counts = userIds.reduce((acc, userId) => {
acc.set(userId, (acc.get(userId) ?? 0) + 1); acc.set(userId, (acc.get(userId) ?? 0) + 1);
@ -227,16 +251,12 @@ function matchToSetMostPlayedUsers(match: AllMatchResult) {
return result; return result;
}; };
const winnerTeamId =
match.opponentOne.result === "win"
? match.opponentOne.id
: match.opponentTwo.id;
const participants = match.maps.flatMap((m) => m.participants); const participants = match.maps.flatMap((m) => m.participants);
const winnerUserIds = participants const winnerUserIds = participants
.filter((p) => p.tournamentTeamId === winnerTeamId) .filter((p) => p.tournamentTeamId === winner.id)
.map((p) => p.userId); .map((p) => p.userId);
const loserUserIds = participants const loserUserIds = participants
.filter((p) => p.tournamentTeamId !== winnerTeamId) .filter((p) => p.tournamentTeamId !== winner.id)
.map((p) => p.userId); .map((p) => p.userId);
return { return {
@ -264,28 +284,51 @@ function calculateTeamSkills({
}; };
for (const match of results) { for (const match of results) {
const winnerTeamId = const winner =
match.opponentOne.result === "win" match.opponentOne.result === "win"
? match.opponentOne.id ? match.opponentOne
: match.opponentTwo.id; : match.opponentTwo;
const loser =
match.opponentOne.result === "win"
? match.opponentTwo
: match.opponentOne;
const winnerTeamIdentifiers = match.maps.flatMap((m) => { // Handle dropped team sets without game results - use active roster or member list
const winnerUserIds = m.participants let winnerTeamIdentifier: string;
.filter((p) => p.tournamentTeamId === winnerTeamId) let loserTeamIdentifier: string;
.map((p) => p.userId);
return userIdsToIdentifier(winnerUserIds); if (match.maps.length === 0) {
}); // Use activeRosterUserIds if set, otherwise fall back to memberUserIds
const winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers); // (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) => { // Skip if no roster info available (defensive check)
const loserUserIds = m.participants if (winnerRoster.length === 0 || loserRoster.length === 0) continue;
.filter((p) => p.tournamentTeamId !== winnerTeamId)
.map((p) => p.userId);
return userIdsToIdentifier(loserUserIds); winnerTeamIdentifier = userIdsToIdentifier(winnerRoster);
}); loserTeamIdentifier = userIdsToIdentifier(loserRoster);
const loserTeamIdentifier = selectMostPopular(loserTeamIdentifiers); } 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( 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 mostPopularParticipants = (() => {
const alphaIdentifiers: string[] = []; const alphaIdentifiers: string[] = [];
const bravoIdentifiers: string[] = []; const bravoIdentifiers: string[] = [];

View File

@ -6,6 +6,22 @@ import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.se
import { tournamentSummary } from "./summarizer.server"; import { tournamentSummary } from "./summarizer.server";
import type { TournamentDataTeam } from "./Tournament.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()", () => { describe("tournamentSummary()", () => {
const createTeam = ( const createTeam = (
teamId: number, teamId: number,
@ -170,16 +186,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 2),
id: 1, opponentTwo: createOpponent(2, "loss", 0),
result: "win",
score: 2,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -287,16 +295,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 2),
id: 1, opponentTwo: createOpponent(2, "loss", 0),
result: "win",
score: 2,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -335,16 +335,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 2),
id: 1, opponentTwo: createOpponent(2, "loss", 0),
result: "win",
score: 2,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -429,16 +421,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 2),
id: 1, opponentTwo: createOpponent(2, "loss", 1),
result: "win",
score: 2,
},
opponentTwo: {
id: 2,
result: "loss",
score: 1,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -602,16 +586,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 3),
id: 1, opponentTwo: createOpponent(2, "loss", 0),
result: "win",
score: 3,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -660,16 +636,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 2),
id: 1, opponentTwo: createOpponent(2, "loss", 0),
result: "win",
score: 2,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -828,16 +796,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 0),
id: 1, opponentTwo: createOpponent(2, "loss", 0),
result: "win",
score: 0,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -872,16 +832,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1, winnerTeamId: 1,
}, },
], ],
opponentOne: { opponentOne: createOpponent(1, "win", 1),
id: 1, opponentTwo: createOpponent(2, "loss", 0),
result: "win",
score: 1,
},
opponentTwo: {
id: 2,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -905,16 +857,8 @@ describe("tournamentSummary()", () => {
winnerTeamId: 3, winnerTeamId: 3,
}, },
], ],
opponentOne: { opponentOne: createOpponent(3, "win", 0),
id: 3, opponentTwo: createOpponent(4, "loss", 0),
result: "win",
score: 0,
},
opponentTwo: {
id: 4,
result: "loss",
score: 0,
},
roundMaps: { roundMaps: {
count: 3, count: 3,
type: "BEST_OF", type: "BEST_OF",
@ -941,4 +885,99 @@ describe("tournamentSummary()", () => {
expect(skillsFromTeam3.length).toBe(0); expect(skillsFromTeam3.length).toBe(0);
expect(skillsFromTeam4.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);
});
}); });

View File

@ -17,6 +17,20 @@ const stm = sql.prepare(/* sql */ `
from "TournamentMatchGameResult" from "TournamentMatchGameResult"
left join "TournamentMatchGameResultParticipant" on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id" left join "TournamentMatchGameResultParticipant" on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id"
group by "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 select
"m"."opponentOne" ->> '$.id' as "opponentOneId", "m"."opponentOne" ->> '$.id' as "opponentOneId",
@ -26,6 +40,12 @@ const stm = sql.prepare(/* sql */ `
"m"."opponentOne" ->> '$.result' as "opponentOneResult", "m"."opponentOne" ->> '$.result' as "opponentOneResult",
"m"."opponentTwo" ->> '$.result' as "opponentTwoResult", "m"."opponentTwo" ->> '$.result' as "opponentTwoResult",
"TournamentRound"."maps" as "roundMaps", "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_group_array(
json_object( json_object(
'stageId', 'stageId',
@ -43,8 +63,12 @@ const stm = sql.prepare(/* sql */ `
inner join "TournamentRound" on "TournamentRound"."id" = "m"."roundId" inner join "TournamentRound" on "TournamentRound"."id" = "m"."roundId"
left join "TournamentStage" on "TournamentStage"."id" = "m"."stageId" left join "TournamentStage" on "TournamentStage"."id" = "m"."stageId"
left join "q1" on "q1"."matchId" = "m"."id" 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 where "TournamentStage"."tournamentId" = @tournamentId
and "opponentOneId" is not null and "opponentOneId" is not null
and "opponentTwoId" is not null and "opponentTwoId" is not null
and "opponentOneResult" is not null and "opponentOneResult" is not null
group by "m"."id" group by "m"."id"
@ -55,6 +79,9 @@ interface Opponent {
id: number; id: number;
score: number; score: number;
result: "win" | "loss"; result: "win" | "loss";
droppedOut: boolean;
activeRosterUserIds: number[] | null;
memberUserIds: number[];
} }
export interface AllMatchResult { export interface AllMatchResult {
opponentOne: Opponent; opponentOne: Opponent;
@ -80,44 +107,75 @@ export function allMatchResultsByTournamentId(
return rows.map((row) => { return rows.map((row) => {
const roundMaps = JSON.parse(row.roundMaps) as TournamentRoundMaps; 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, id: row.opponentOneId,
score: row.opponentOneScore, score: row.opponentOneScore,
result: row.opponentOneResult, result: row.opponentOneResult,
droppedOut: row.opponentOneDroppedOut === 1,
activeRosterUserIds: parseActiveRoster(row.opponentOneActiveRoster),
memberUserIds: parseMemberUserIds(row.opponentOneMemberUserIds),
}; };
const opponentTwo = { const opponentTwo: Opponent = {
id: row.opponentTwoId, id: row.opponentTwoId,
score: row.opponentTwoScore, score: row.opponentTwoScore,
result: row.opponentTwoResult, 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 { return {
opponentOne, opponentOne,
opponentTwo, opponentTwo,
roundMaps, roundMaps,
maps: parseDBJsonArray(row.maps).map((map: any) => { maps: hasGameResults
const participants = parseDBArray(map.participants); ? rawMaps.map((map: any) => {
invariant(participants.length > 0, "No participants found"); const participants = parseDBArray(map.participants);
invariant( invariant(participants.length > 0, "No participants found");
participants.every( invariant(
(p: any) => typeof p.tournamentTeamId === "number", participants.every(
), (p: any) => typeof p.tournamentTeamId === "number",
"Some participants have no team id", ),
); "Some participants have no team id",
invariant( );
participants.every( invariant(
(p: any) => participants.every(
p.tournamentTeamId === row.opponentOneId || (p: any) =>
p.tournamentTeamId === row.opponentTwoId, p.tournamentTeamId === row.opponentOneId ||
), p.tournamentTeamId === row.opponentTwoId,
"Some participants have an invalid team id", ),
); "Some participants have an invalid team id",
);
return { return {
...map, ...map,
participants, participants,
}; };
}), })
: [],
}; };
}); });
} }

View File

@ -385,6 +385,18 @@ function EndedEarlyMessage() {
const winnerTeam = winnerTeamId ? tournament.teamById(winnerTeamId) : null; 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 ( return (
<div className="tournament-bracket__during-match-actions"> <div className="tournament-bracket__during-match-actions">
<div className="tournament-bracket__locked-banner tournament-bracket__locked-banner__lonely"> <div className="tournament-bracket__locked-banner tournament-bracket__locked-banner__lonely">
@ -392,7 +404,9 @@ function EndedEarlyMessage() {
<div className="text-lg text-center font-bold">Match ended early</div> <div className="text-lg text-center font-bold">Match ended early</div>
{winnerTeam ? ( {winnerTeam ? (
<div className="text-xs text-lighter text-center"> <div className="text-xs text-lighter text-center">
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} Winner: {winnerTeam.name}
</div> </div>
) : null} ) : null}

View File

@ -1,9 +1,11 @@
import type { ActionFunction } from "react-router"; import type { ActionFunction } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server"; import { requireUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server"; import { userIsBanned } from "~/features/ban/core/banned.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { notify } from "~/features/notifications/core/notify.server"; import { notify } from "~/features/notifications/core/notify.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.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 * as Progression from "~/features/tournament-bracket/core/Progression";
import { import {
clearTournamentDataCache, clearTournamentDataCache,
@ -26,7 +28,10 @@ import { deleteTeam } from "../queries/deleteTeam.server";
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server"; import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
import * as TournamentRepository from "../TournamentRepository.server"; import * as TournamentRepository from "../TournamentRepository.server";
import { adminActionSchema } from "../tournament-schemas.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 }) => { export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request); const user = await requireUser(request);
@ -355,6 +360,30 @@ export const action: ActionFunction = async ({ request, params }) => {
} }
case "DROP_TEAM_OUT": { case "DROP_TEAM_OUT": {
validateIsTournamentOrganizer(); 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({ await TournamentRepository.dropTeamOut({
tournamentTeamId: data.teamId, tournamentTeamId: data.teamId,
previewBracketIdxs: tournament.brackets.flatMap((b, idx) => previewBracketIdxs: tournament.brackets.flatMap((b, idx) =>

View File

@ -173,12 +173,12 @@ const actions = [
{ {
type: "DROP_TEAM_OUT", type: "DROP_TEAM_OUT",
inputs: ["REGISTERED_TEAM"] as InputType[], inputs: ["REGISTERED_TEAM"] as InputType[],
when: ["TOURNAMENT_AFTER_START", "IS_SWISS"], when: ["TOURNAMENT_AFTER_START"],
}, },
{ {
type: "UNDO_DROP_TEAM_OUT", type: "UNDO_DROP_TEAM_OUT",
inputs: ["REGISTERED_TEAM"] as InputType[], inputs: ["REGISTERED_TEAM"] as InputType[],
when: ["TOURNAMENT_AFTER_START", "IS_SWISS"], when: ["TOURNAMENT_AFTER_START"],
}, },
{ {
type: "UPDATE_IN_GAME_NAME", type: "UPDATE_IN_GAME_NAME",
@ -234,13 +234,6 @@ function TeamActions() {
break; break;
} }
case "IS_SWISS": {
if (!tournament.brackets.some((b) => b.type === "swiss")) {
return false;
}
break;
}
case "IN_GAME_NAME_REQUIRED": { case "IN_GAME_NAME_REQUIRED": {
if (!tournament.ctx.settings.requireInGameNames) { if (!tournament.ctx.settings.requireInGameNames) {
return false; return false;

View File

@ -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 TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server"; import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
import type { Tournament } from "../tournament-bracket/core/Tournament"; import type { Tournament } from "../tournament-bracket/core/Tournament";
@ -40,3 +42,63 @@ export async function requireNotBannedByOrganization({
errorToast(message); 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<typeof getServerTournamentManager>;
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,
);
}
}

View File

@ -9,13 +9,17 @@ export class Update extends BaseUpdater {
* This will update related matches accordingly. * This will update related matches accordingly.
* *
* @param match Values to change in a match. * @param match Values to change in a match.
* @param force If true, bypasses the locked match check.
*/ */
public match<M extends Match = Match>(match: DeepPartial<M>): void { public match<M extends Match = Match>(
match: DeepPartial<M>,
force?: boolean,
): void {
if (match.id === undefined) throw Error("No match id given."); if (match.id === undefined) throw Error("No match id given.");
const stored = this.storage.select("match", match.id); const stored = this.storage.select("match", match.id);
if (!stored) throw Error("Match not found."); if (!stored) throw Error("Match not found.");
this.updateMatch(stored, match); this.updateMatch(stored, match, force);
} }
} }

View File

@ -1127,4 +1127,48 @@ test.describe("Tournament bracket", () => {
// Verify match ended early // Verify match ended early
await expect(page.getByText("Match ended early")).toBeVisible(); 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();
});
}); });