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,9 +276,11 @@ export async function seasonResultsByUserId({
.orderBy("Skill.id", "desc")
.execute();
return rows.map((row) => {
return rows
.map((row) => {
if (row.groupMatch) {
const skillDiff = row.groupMatch?.memento?.users[userId]?.skillDifference;
const skillDiff =
row.groupMatch?.memento?.users[userId]?.skillDifference;
const chooseMostPopularWeapon = (userId: number) => {
const weaponSplIds = row
@ -331,8 +333,10 @@ export async function seasonResultsByUserId({
};
}
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({

View File

@ -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;

View File

@ -304,6 +304,7 @@ export function PlacementsTable({
allMatchesFinished={allMatchesFinished}
canEditDestination={canEditDestination}
tournamentTeamId={s.team.id}
droppedOut={Boolean(s.team.droppedOut)}
/>
</tr>
{!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<any>();
const [editingDestination, setEditingDestination] = React.useState(false);
@ -405,7 +408,9 @@ function EditableDestination({
return (
<>
{allMatchesFinished &&
{droppedOut ? (
<td />
) : allMatchesFinished &&
overridenDestination &&
overridenDestination.idx !== destination?.idx ? (
<td className="text-theme font-bold">
@ -422,7 +427,7 @@ function EditableDestination({
) : (
<td />
)}
{canEditDestination ? (
{canEditDestination && !droppedOut ? (
<td>
<SendouButton
variant="minimal"
@ -431,6 +436,8 @@ function EditableDestination({
onPress={() => setEditingDestination(true)}
/>
</td>
) : canEditDestination ? (
<td />
) : 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(
...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;

View File

@ -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,
});
}

View File

@ -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,
};
}

View File

@ -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;
// Handle dropped team sets without game results - use active roster or member list
let winnerTeamIdentifier: string;
let loserTeamIdentifier: string;
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 ?? [];
// Skip if no roster info available (defensive check)
if (winnerRoster.length === 0 || loserRoster.length === 0) continue;
winnerTeamIdentifier = userIdsToIdentifier(winnerRoster);
loserTeamIdentifier = userIdsToIdentifier(loserRoster);
} else {
const winnerTeamIdentifiers = match.maps.flatMap((m) => {
const winnerUserIds = m.participants
.filter((p) => p.tournamentTeamId === winnerTeamId)
.filter((p) => p.tournamentTeamId === winner.id)
.map((p) => p.userId);
return userIdsToIdentifier(winnerUserIds);
});
const winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers);
winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers);
const loserTeamIdentifiers = match.maps.flatMap((m) => {
const loserUserIds = m.participants
.filter((p) => p.tournamentTeamId !== winnerTeamId)
.filter((p) => p.tournamentTeamId !== winner.id)
.map((p) => p.userId);
return userIdsToIdentifier(loserUserIds);
});
const loserTeamIdentifier = selectMostPopular(loserTeamIdentifiers);
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[] = [];

View File

@ -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);
});
});

View File

@ -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,6 +63,10 @@ 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 "opponentTwoId" is not null
@ -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,22 +107,52 @@ 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) => {
maps: hasGameResults
? rawMaps.map((map: any) => {
const participants = parseDBArray(map.participants);
invariant(participants.length > 0, "No participants found");
invariant(
@ -117,7 +174,8 @@ export function allMatchResultsByTournamentId(
...map,
participants,
};
}),
})
: [],
};
});
}

View File

@ -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 (
<div className="tournament-bracket__during-match-actions">
<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>
{winnerTeam ? (
<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}
</div>
) : null}

View File

@ -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) =>

View File

@ -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;

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 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<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.
*
* @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.");
const stored = this.storage.select("match", match.id);
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
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();
});
});