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")
|
.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({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user