mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Drop teams in any bracket format (#2684)
This commit is contained in:
parent
393955f5eb
commit
0393575c87
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,9 +408,11 @@ function EditableDestination({
|
|||
|
||||
return (
|
||||
<>
|
||||
{allMatchesFinished &&
|
||||
overridenDestination &&
|
||||
overridenDestination.idx !== destination?.idx ? (
|
||||
{droppedOut ? (
|
||||
<td />
|
||||
) : allMatchesFinished &&
|
||||
overridenDestination &&
|
||||
overridenDestination.idx !== destination?.idx ? (
|
||||
<td className="text-theme font-bold">
|
||||
<span>→ {overridenDestination.name}</span>
|
||||
</td>
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user