mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Swiss TB consider dropped out teams who finished the bracket & reuse check-in
This commit is contained in:
@@ -41,8 +41,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => {
|
||||
teams: [checkedInAtEventLevel],
|
||||
status: "checkedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 0,
|
||||
bracketRequiresOwnCheckIn: false,
|
||||
checkInBracketIdxs: null,
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
@@ -54,8 +53,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => {
|
||||
teams: [checkedInAtEventLevel],
|
||||
status: "notCheckedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 0,
|
||||
bracketRequiresOwnCheckIn: false,
|
||||
checkInBracketIdxs: null,
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
@@ -80,8 +78,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => {
|
||||
teams: [checkedIntoBracket, onlyEventLevel],
|
||||
status: "checkedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 2,
|
||||
bracketRequiresOwnCheckIn: true,
|
||||
checkInBracketIdxs: [2],
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
@@ -93,8 +90,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => {
|
||||
teams: [checkedIntoBracket, onlyEventLevel],
|
||||
status: "notCheckedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 2,
|
||||
bracketRequiresOwnCheckIn: true,
|
||||
checkInBracketIdxs: [2],
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
@@ -108,8 +104,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => {
|
||||
teams: [checkedIntoBracket, onlyEventLevel, notInBracket],
|
||||
status: "notCheckedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 2,
|
||||
bracketRequiresOwnCheckIn: true,
|
||||
checkInBracketIdxs: [2],
|
||||
// notInBracket is intentionally absent from the bracket's pool
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
import * as CheckIn from "~/features/tournament-bracket/core/CheckIn";
|
||||
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as CSV from "~/modules/csv";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
@@ -109,14 +110,18 @@ export function ExportDialog({
|
||||
});
|
||||
|
||||
const selectedBracket = tournament.bracketsMeta[bracketIdx];
|
||||
const bracketRequiresOwnCheckIn =
|
||||
selectedBracket.requiresCheckIn && !selectedBracket.isStartingBracket;
|
||||
const checkInBracketIdxs =
|
||||
selectedBracket.requiresCheckIn && !selectedBracket.isStartingBracket
|
||||
? CheckIn.sharedBracketIdxs(
|
||||
bracketIdx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
)
|
||||
: null;
|
||||
const teams = scopedAndSortedTeams({
|
||||
teams: allTeams,
|
||||
status,
|
||||
sort,
|
||||
bracketIdx,
|
||||
bracketRequiresOwnCheckIn,
|
||||
checkInBracketIdxs,
|
||||
bracketParticipantIds: new Set(
|
||||
tournament.eligibleTeamIdsOfBracket(bracketIdx),
|
||||
),
|
||||
@@ -126,8 +131,7 @@ export function ExportDialog({
|
||||
format,
|
||||
fields,
|
||||
captainsOnly: roster === "captains",
|
||||
bracketIdx,
|
||||
bracketRequiresOwnCheckIn,
|
||||
checkInBracketIdxs,
|
||||
checkedInLabel: "Checked in",
|
||||
notCheckedInLabel: "Not checked in",
|
||||
});
|
||||
@@ -267,15 +271,13 @@ function RadioRow<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/** `checkInBracketIdxs` null = the bracket uses the event-level check-in */
|
||||
function hasActiveCheckIn(
|
||||
team: TournamentTeamFull,
|
||||
bracketIdx: number,
|
||||
bracketRequiresOwnCheckIn: boolean,
|
||||
checkInBracketIdxs: number[] | null,
|
||||
) {
|
||||
if (bracketRequiresOwnCheckIn) {
|
||||
return team.checkIns.some(
|
||||
(checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut,
|
||||
);
|
||||
if (checkInBracketIdxs) {
|
||||
return CheckIn.isCheckedInToBrackets(team.checkIns, checkInBracketIdxs);
|
||||
}
|
||||
|
||||
const eventLevel = team.checkIns.filter(
|
||||
@@ -291,15 +293,13 @@ export function scopedAndSortedTeams({
|
||||
teams,
|
||||
status,
|
||||
sort,
|
||||
bracketIdx,
|
||||
bracketRequiresOwnCheckIn,
|
||||
checkInBracketIdxs,
|
||||
bracketParticipantIds,
|
||||
}: {
|
||||
teams: TournamentTeamFull[];
|
||||
status: ExportStatus;
|
||||
sort: ExportSort;
|
||||
bracketIdx: number;
|
||||
bracketRequiresOwnCheckIn: boolean;
|
||||
checkInBracketIdxs: number[] | null;
|
||||
bracketParticipantIds: Set<number>;
|
||||
}) {
|
||||
const filtered = teams.filter((team) => {
|
||||
@@ -308,9 +308,9 @@ export function scopedAndSortedTeams({
|
||||
}
|
||||
switch (status) {
|
||||
case "checkedIn":
|
||||
return hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn);
|
||||
return hasActiveCheckIn(team, checkInBracketIdxs);
|
||||
case "notCheckedIn":
|
||||
return !hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn);
|
||||
return !hasActiveCheckIn(team, checkInBracketIdxs);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -338,8 +338,7 @@ function teamFieldValue(
|
||||
opts: {
|
||||
checkedInLabel: string;
|
||||
notCheckedInLabel: string;
|
||||
bracketIdx: number;
|
||||
bracketRequiresOwnCheckIn: boolean;
|
||||
checkInBracketIdxs: number[] | null;
|
||||
},
|
||||
) {
|
||||
switch (field) {
|
||||
@@ -350,11 +349,7 @@ function teamFieldValue(
|
||||
case "registeredAt":
|
||||
return databaseTimestampToDate(team.createdAt).toISOString();
|
||||
case "checkInStatus":
|
||||
return hasActiveCheckIn(
|
||||
team,
|
||||
opts.bracketIdx,
|
||||
opts.bracketRequiresOwnCheckIn,
|
||||
)
|
||||
return hasActiveCheckIn(team, opts.checkInBracketIdxs)
|
||||
? opts.checkedInLabel
|
||||
: opts.notCheckedInLabel;
|
||||
case "teamPageUrl":
|
||||
@@ -385,8 +380,7 @@ function buildContent({
|
||||
format,
|
||||
fields,
|
||||
captainsOnly,
|
||||
bracketIdx,
|
||||
bracketRequiresOwnCheckIn,
|
||||
checkInBracketIdxs,
|
||||
checkedInLabel,
|
||||
notCheckedInLabel,
|
||||
}: {
|
||||
@@ -394,8 +388,7 @@ function buildContent({
|
||||
format: ExportFormat;
|
||||
fields: Set<ExportField>;
|
||||
captainsOnly: boolean;
|
||||
bracketIdx: number;
|
||||
bracketRequiresOwnCheckIn: boolean;
|
||||
checkInBracketIdxs: number[] | null;
|
||||
checkedInLabel: string;
|
||||
notCheckedInLabel: string;
|
||||
}) {
|
||||
@@ -408,8 +401,7 @@ function buildContent({
|
||||
const labelOpts = {
|
||||
checkedInLabel,
|
||||
notCheckedInLabel,
|
||||
bracketIdx,
|
||||
bracketRequiresOwnCheckIn,
|
||||
checkInBracketIdxs,
|
||||
};
|
||||
|
||||
if (format === "csv") {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { Table } from "~/components/Table";
|
||||
import { DroppedOutPopover } from "~/features/tournament/components/DroppedOutPopover";
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
import * as CheckIn from "~/features/tournament-bracket/core/CheckIn";
|
||||
import type {
|
||||
BracketMeta,
|
||||
Tournament,
|
||||
@@ -375,7 +376,13 @@ function TeamRowMenu({
|
||||
? bracketsRequiringCheckIn.map((bracket) => {
|
||||
if (!bracket.preview) return null;
|
||||
|
||||
const bracketCheckedIn = isBracketCheckedIn(team, bracket.idx);
|
||||
const bracketCheckedIn = CheckIn.isCheckedInToBrackets(
|
||||
team.checkIns,
|
||||
CheckIn.sharedBracketIdxs(
|
||||
bracket.idx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
),
|
||||
);
|
||||
|
||||
return bracketCheckedIn ? (
|
||||
<SendouMenuItem
|
||||
@@ -475,12 +482,6 @@ function isTournamentCheckedIn(team: TournamentTeamFull) {
|
||||
);
|
||||
}
|
||||
|
||||
function isBracketCheckedIn(team: TournamentTeamFull, bracketIdx: number) {
|
||||
return team.checkIns.some(
|
||||
(checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut,
|
||||
);
|
||||
}
|
||||
|
||||
/** Does this bracket have its own opt-in check-in (besides the event check-in)? */
|
||||
function isCheckInBracket(bracket: BracketMeta) {
|
||||
return bracket.requiresCheckIn;
|
||||
@@ -510,7 +511,13 @@ function checkInScopes(tournament: Tournament, team: TournamentTeamFull) {
|
||||
{ label: "Event", checkedIn: isTournamentCheckedIn(team) },
|
||||
...checkInBracketsForTeam(tournament, team).map((bracket) => ({
|
||||
label: bracket.name,
|
||||
checkedIn: isBracketCheckedIn(team, bracket.idx),
|
||||
checkedIn: CheckIn.isCheckedInToBrackets(
|
||||
team.checkIns,
|
||||
CheckIn.sharedBracketIdxs(
|
||||
bracket.idx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
),
|
||||
),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as R from "remeda";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { invariant } from "../../../utils/invariant";
|
||||
import type { Standing } from "./Bracket";
|
||||
import * as Engine from "./engine";
|
||||
import { createResolved } from "./engine/create";
|
||||
import type { BracketData, MatchData } from "./engine/types";
|
||||
@@ -93,43 +94,32 @@ describe("swiss standings - losses against tied", () => {
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round
|
||||
});
|
||||
|
||||
test("ignores a dropped out team with an identical record (losses against tied)", () => {
|
||||
test("counts a dropped out team that finished its run (losses against tied)", () => {
|
||||
const data = Engine.create({
|
||||
type: "swiss",
|
||||
seeding: [1, 2, 3, 4, 5, 6],
|
||||
settings: { groupCount: 1, roundCount: 3 },
|
||||
});
|
||||
|
||||
const playedMatch = (
|
||||
id: number,
|
||||
roundIdx: number,
|
||||
number: number,
|
||||
winnerId: number,
|
||||
loserId: number,
|
||||
): MatchData => ({
|
||||
id,
|
||||
stageId: data.stage[0].id,
|
||||
groupId: data.group[0].id,
|
||||
roundId: data.round[roundIdx].id,
|
||||
number,
|
||||
opponent1: { id: winnerId },
|
||||
opponent2: { id: loserId },
|
||||
winnerSide: "opponent1",
|
||||
});
|
||||
|
||||
// teams 1 and 6 both finish 2-1; team 1's only loss is to team 6,
|
||||
// who dropped out after the swiss ended
|
||||
data.match = [
|
||||
playedMatch(0, 0, 1, 1, 2),
|
||||
playedMatch(1, 0, 2, 3, 4),
|
||||
playedMatch(2, 0, 3, 5, 6),
|
||||
playedMatch(3, 1, 1, 1, 3),
|
||||
playedMatch(4, 1, 2, 2, 5),
|
||||
playedMatch(5, 1, 3, 6, 4),
|
||||
playedMatch(6, 2, 1, 6, 1),
|
||||
playedMatch(7, 2, 2, 3, 5),
|
||||
playedMatch(8, 2, 3, 2, 4),
|
||||
];
|
||||
data.match = playedSwissMatches(data, [
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
],
|
||||
[
|
||||
[1, 3],
|
||||
[2, 5],
|
||||
[6, 4],
|
||||
],
|
||||
[
|
||||
[6, 1],
|
||||
[3, 5],
|
||||
[2, 4],
|
||||
],
|
||||
]);
|
||||
|
||||
const tournament = testTournament({
|
||||
data,
|
||||
@@ -155,9 +145,122 @@ describe("swiss standings - losses against tied", () => {
|
||||
?.standings.find((candidate) => candidate.team.id === 1);
|
||||
invariant(standing, "Standing not found");
|
||||
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(0);
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(1);
|
||||
});
|
||||
|
||||
test("dropping out a team that finished its run keeps the order of the other teams", () => {
|
||||
const teamOrderByGroup = (standings: Standing[], excludedTeamId: number) =>
|
||||
R.mapValues(
|
||||
R.groupBy(
|
||||
standings.filter((standing) => standing.team.id !== excludedTeamId),
|
||||
(standing) => String(standing.groupId),
|
||||
),
|
||||
(group) => group.map((standing) => standing.team.id),
|
||||
);
|
||||
|
||||
const standingsBefore = new Tournament(
|
||||
LOW_INK_DECEMBER_2024(),
|
||||
).bracketByIdx(0)!.standings;
|
||||
|
||||
for (const { team } of standingsBefore) {
|
||||
if (team.droppedOut) continue;
|
||||
|
||||
const tournamentData = LOW_INK_DECEMBER_2024();
|
||||
const standingsAfter = new Tournament({
|
||||
...tournamentData,
|
||||
ctx: {
|
||||
...tournamentData.ctx,
|
||||
teams: tournamentData.ctx.teams.map((ctxTeam) =>
|
||||
ctxTeam.id === team.id ? { ...ctxTeam, droppedOut: 1 } : ctxTeam,
|
||||
),
|
||||
},
|
||||
}).bracketByIdx(0)!.standings;
|
||||
|
||||
expect(teamOrderByGroup(standingsAfter, team.id)).toEqual(
|
||||
teamOrderByGroup(standingsBefore, team.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
why: "finished early via advance threshold",
|
||||
advanceThreshold: 3,
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
why: "mid-run without advance threshold",
|
||||
advanceThreshold: undefined,
|
||||
expected: 0,
|
||||
},
|
||||
])(
|
||||
"dropped out team whose run $why (losses against tied)",
|
||||
({ advanceThreshold, expected }) => {
|
||||
const data = Engine.create({
|
||||
type: "swiss",
|
||||
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
settings: { groupCount: 1, roundCount: 5 },
|
||||
});
|
||||
|
||||
// teams 3 and 7 are both eliminated at 1-3 after round 4; team 7's only
|
||||
// loss to a tied team is to team 3, who then dropped out
|
||||
data.match = playedSwissMatches(data, [
|
||||
[
|
||||
[1, 5],
|
||||
[2, 6],
|
||||
[3, 7],
|
||||
[4, 8],
|
||||
],
|
||||
[
|
||||
[1, 2],
|
||||
[4, 3],
|
||||
[7, 5],
|
||||
[6, 8],
|
||||
],
|
||||
[
|
||||
[1, 4],
|
||||
[2, 3],
|
||||
[6, 7],
|
||||
[5, 8],
|
||||
],
|
||||
[
|
||||
[4, 2],
|
||||
[6, 3],
|
||||
[5, 7],
|
||||
],
|
||||
[[2, 5]],
|
||||
]);
|
||||
|
||||
const tournament = testTournament({
|
||||
data,
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "swiss",
|
||||
name: "Main Bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: { advanceThreshold },
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: [1, 2, 3, 4, 5, 6, 7, 8].map((teamId) =>
|
||||
tournamentCtxTeam(teamId, { droppedOut: teamId === 3 ? 1 : 0 }),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const standing = tournament
|
||||
.bracketByIdx(0)
|
||||
?.standings.find((candidate) => candidate.team.id === 7);
|
||||
invariant(standing, "Standing not found");
|
||||
|
||||
expect(standing.stats?.setWins).toBe(1);
|
||||
expect(standing.stats?.setLosses).toBe(3);
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
const inProgressSwissTestTournament = () => {
|
||||
const data = Engine.create({
|
||||
type: "swiss",
|
||||
@@ -1575,6 +1678,27 @@ describe("single elimination sourcing - placements are tiers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Matches of a single group swiss where each round lists its sets as [winnerId, loserId] */
|
||||
function playedSwissMatches(
|
||||
data: BracketData,
|
||||
rounds: Array<Array<[winnerId: number, loserId: number]>>,
|
||||
): MatchData[] {
|
||||
let id = 0;
|
||||
|
||||
return rounds.flatMap((sets, roundIdx) =>
|
||||
sets.map(([winnerId, loserId], setIdx) => ({
|
||||
id: id++,
|
||||
stageId: data.stage[0].id,
|
||||
groupId: data.group[0].id,
|
||||
roundId: data.round[roundIdx].id,
|
||||
number: setIdx + 1,
|
||||
opponent1: { id: winnerId },
|
||||
opponent2: { id: loserId },
|
||||
winnerSide: "opponent1",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function reportLowerIdWinner(data: BracketData, matchId: number): BracketData {
|
||||
const match = matchById(data, matchId);
|
||||
const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!;
|
||||
|
||||
@@ -319,13 +319,23 @@ export class SwissBracket extends Bracket {
|
||||
.filter((t) => t.droppedOut)
|
||||
.map((t) => t.id);
|
||||
|
||||
// wins against tied, results against dropped out teams don't count
|
||||
// teams that finished their run keep counting so dropping them only moves the teams below up
|
||||
const droppedOutMidRunTeamIds = new Set(
|
||||
teams
|
||||
.filter(
|
||||
(team) =>
|
||||
droppedOutTeams.includes(team.id) && !this.runIsComplete(team),
|
||||
)
|
||||
.map((team) => team.id),
|
||||
);
|
||||
|
||||
// wins against tied, results against teams that dropped out mid-run don't count
|
||||
for (const team of teams) {
|
||||
if (droppedOutTeams.includes(team.id)) continue;
|
||||
if (droppedOutMidRunTeamIds.has(team.id)) continue;
|
||||
|
||||
for (const team2 of teams) {
|
||||
if (team.id === team2.id) continue;
|
||||
if (droppedOutTeams.includes(team2.id)) continue;
|
||||
if (droppedOutMidRunTeamIds.has(team2.id)) continue;
|
||||
if (
|
||||
team.setWins !== team2.setWins ||
|
||||
// check also set losses to account for dropped teams
|
||||
@@ -476,6 +486,30 @@ export class SwissBracket extends Bracket {
|
||||
);
|
||||
}
|
||||
|
||||
/** Has the team played every round or, with an advance threshold, already advanced or been eliminated? */
|
||||
private runIsComplete({
|
||||
setWins,
|
||||
setLosses,
|
||||
}: {
|
||||
setWins: number;
|
||||
setLosses: number;
|
||||
}) {
|
||||
const roundCount = this.swissRoundCount;
|
||||
if (setWins + setLosses >= roundCount) return true;
|
||||
|
||||
const advanceThreshold = this.settings?.advanceThreshold;
|
||||
if (!advanceThreshold) return false;
|
||||
|
||||
return (
|
||||
calculateTeamStatus({
|
||||
wins: setWins,
|
||||
losses: setLosses,
|
||||
advanceThreshold,
|
||||
roundCount,
|
||||
}) !== "active"
|
||||
);
|
||||
}
|
||||
|
||||
private trackRecordToWinPercentage(trackRecord: TeamTrackRecord) {
|
||||
const onlyByes = trackRecord.wins === 0 && trackRecord.losses === 0;
|
||||
if (onlyByes) {
|
||||
|
||||
74
app/features/tournament-bracket/core/CheckIn.test.ts
Normal file
74
app/features/tournament-bracket/core/CheckIn.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as CheckIn from "./CheckIn";
|
||||
import type { ParsedBracket } from "./Progression";
|
||||
|
||||
const DAY_2_START = 1_790_528_400;
|
||||
|
||||
const bracket = (partial: Partial<ParsedBracket>): ParsedBracket => ({
|
||||
name: "Bracket",
|
||||
type: "double_elimination",
|
||||
requiresCheckIn: true,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [1] }],
|
||||
...partial,
|
||||
});
|
||||
|
||||
const progression: ParsedBracket[] = [
|
||||
bracket({ type: "swiss", requiresCheckIn: false, sources: undefined }),
|
||||
bracket({ startTime: DAY_2_START }),
|
||||
bracket({ startTime: DAY_2_START }),
|
||||
bracket({ startTime: DAY_2_START + 3600 }),
|
||||
bracket({ startTime: DAY_2_START, requiresCheckIn: false }),
|
||||
bracket({}),
|
||||
];
|
||||
|
||||
describe("CheckIn.sharedBracketIdxs", () => {
|
||||
test.each([
|
||||
{ why: "same start time", bracketIdx: 1, expected: [1, 2] },
|
||||
{ why: "own start time", bracketIdx: 3, expected: [3] },
|
||||
{ why: "no start time", bracketIdx: 5, expected: [5] },
|
||||
])("$why", ({ bracketIdx, expected }) => {
|
||||
expect(CheckIn.sharedBracketIdxs(bracketIdx, progression)).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CheckIn.isCheckedInToBrackets", () => {
|
||||
test.each([
|
||||
{ why: "no check-ins", checkIns: [], expected: false },
|
||||
{
|
||||
why: "event check-in only",
|
||||
checkIns: [{ bracketIdx: null, checkedInAt: 1, isCheckOut: 0 }],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
why: "checked in to a shared bracket",
|
||||
checkIns: [{ bracketIdx: 2, checkedInAt: 1, isCheckOut: 0 }],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
why: "checked in to a bracket not shared",
|
||||
checkIns: [{ bracketIdx: 3, checkedInAt: 1, isCheckOut: 0 }],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
why: "checked out after checking in to a shared bracket",
|
||||
checkIns: [
|
||||
{ bracketIdx: 2, checkedInAt: 1, isCheckOut: 0 },
|
||||
{ bracketIdx: 1, checkedInAt: 2, isCheckOut: 1 },
|
||||
],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
why: "checked in again after checking out",
|
||||
checkIns: [
|
||||
{ bracketIdx: 1, checkedInAt: 1, isCheckOut: 1 },
|
||||
{ bracketIdx: 2, checkedInAt: 2, isCheckOut: 0 },
|
||||
],
|
||||
expected: true,
|
||||
},
|
||||
])("$why", ({ checkIns, expected }) => {
|
||||
expect(CheckIn.isCheckedInToBrackets(checkIns, [1, 2])).toBe(expected);
|
||||
});
|
||||
});
|
||||
48
app/features/tournament-bracket/core/CheckIn.ts
Normal file
48
app/features/tournament-bracket/core/CheckIn.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as R from "remeda";
|
||||
import type { ParsedBracket } from "./Progression";
|
||||
|
||||
interface CheckInRow {
|
||||
bracketIdx: number | null;
|
||||
checkedInAt: number;
|
||||
isCheckOut: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brackets that share their check-in with the given bracket: every bracket requiring check-in that starts at the same time.
|
||||
* A team checked in to one of them is checked in to all, so a team moving between them keeps its check-in.
|
||||
*
|
||||
* @example
|
||||
* // Alpha (1) and Beta (2) both start at 18:00, Gamma (3) at 20:00
|
||||
* CheckIn.sharedBracketIdxs(1, progression) // [1, 2]
|
||||
*/
|
||||
export function sharedBracketIdxs(
|
||||
bracketIdx: number,
|
||||
progression: ParsedBracket[],
|
||||
): number[] {
|
||||
const bracket = progression[bracketIdx];
|
||||
if (!bracket.startTime) return [bracketIdx];
|
||||
|
||||
return progression.flatMap((candidate, candidateIdx) =>
|
||||
candidate.requiresCheckIn && candidate.startTime === bracket.startTime
|
||||
? [candidateIdx]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the team checked in to the brackets (from {@link sharedBracketIdxs})? The latest check-in or check-out among them decides.
|
||||
*/
|
||||
export function isCheckedInToBrackets(
|
||||
checkIns: CheckInRow[],
|
||||
bracketIdxs: number[],
|
||||
): boolean {
|
||||
const latest = R.firstBy(
|
||||
checkIns.filter(
|
||||
(checkIn) =>
|
||||
checkIn.bracketIdx !== null && bracketIdxs.includes(checkIn.bracketIdx),
|
||||
),
|
||||
[(checkIn) => checkIn.checkedInAt, "desc"],
|
||||
);
|
||||
|
||||
return Boolean(latest && !latest.isCheckOut);
|
||||
}
|
||||
@@ -682,6 +682,94 @@ describe("teamMemberOfProgressStatus with a follow-up bracket check-in", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("follow-up bracket check-in shared between brackets", () => {
|
||||
const DAY_2_START = 1_790_528_400;
|
||||
|
||||
const progressionWithBetaStartingAt = (
|
||||
betaStartTime: number,
|
||||
): Progression.ParsedBracket[] => [
|
||||
{
|
||||
name: "Groups",
|
||||
type: "round_robin",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
name: "Alpha",
|
||||
type: "single_elimination",
|
||||
requiresCheckIn: true,
|
||||
startTime: DAY_2_START,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [1, 2] }],
|
||||
},
|
||||
{
|
||||
name: "Beta",
|
||||
type: "single_elimination",
|
||||
requiresCheckIn: true,
|
||||
startTime: betaStartTime,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [3, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
const alphaAfterMovingBetaTeamThere = (betaStartTime: number) => {
|
||||
const data = Engine.create({
|
||||
type: "round_robin",
|
||||
seeding: [1, 2, 3, 4],
|
||||
settings: { groupCount: 1 },
|
||||
});
|
||||
finishPendingMatches(data);
|
||||
const bracketProgression = progressionWithBetaStartingAt(betaStartTime);
|
||||
|
||||
const betaTeamId = testTournament({
|
||||
data,
|
||||
ctx: { settings: { bracketProgression } },
|
||||
}).bracketByIdx(2)!.teamsPendingCheckIn![0];
|
||||
|
||||
const alpha = testTournament({
|
||||
data,
|
||||
ctx: {
|
||||
settings: { bracketProgression },
|
||||
teams: [1, 2, 3, 4].map((teamId) =>
|
||||
tournamentCtxTeam(teamId, {
|
||||
checkIns: [
|
||||
{ checkedInAt: 1, bracketIdx: null, isCheckOut: 0 },
|
||||
...(teamId === betaTeamId
|
||||
? [{ checkedInAt: 2, bracketIdx: 2, isCheckOut: 0 as const }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
),
|
||||
bracketProgressionOverrides: [
|
||||
{
|
||||
sourceBracketIdx: 0,
|
||||
destinationBracketIdx: 1,
|
||||
tournamentTeamId: betaTeamId,
|
||||
},
|
||||
],
|
||||
},
|
||||
}).bracketByIdx(1)!;
|
||||
|
||||
return { alpha, betaTeamId };
|
||||
};
|
||||
|
||||
test("keeps the check-in of a team moved to a bracket starting at the same time", () => {
|
||||
const { alpha, betaTeamId } = alphaAfterMovingBetaTeamThere(DAY_2_START);
|
||||
|
||||
expect(alpha.seeding).toContain(betaTeamId);
|
||||
expect(alpha.teamsPendingCheckIn).not.toContain(betaTeamId);
|
||||
});
|
||||
|
||||
test("requires a new check-in from a team moved to a bracket starting at a different time", () => {
|
||||
const { alpha, betaTeamId } = alphaAfterMovingBetaTeamThere(
|
||||
DAY_2_START + 3600,
|
||||
);
|
||||
|
||||
expect(alpha.seeding).not.toContain(betaTeamId);
|
||||
expect(alpha.teamsPendingCheckIn).toContain(betaTeamId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Swiss early advance bracket sourcing", () => {
|
||||
const progressionWithConsolation: Progression.ParsedBracket[] = [
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ import { logger } from "~/utils/logger";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { groupNumberToLetters } from "../tournament-bracket-utils";
|
||||
import { type Bracket, createBracket } from "./Bracket";
|
||||
import * as CheckIn from "./CheckIn";
|
||||
import { calculateTeamStatus } from "./engine/swiss/team-status";
|
||||
import { getRounds } from "./rounds";
|
||||
import * as Seeding from "./Seeding";
|
||||
@@ -617,6 +618,11 @@ export class Tournament {
|
||||
usesRegularCheckIn: boolean;
|
||||
requiresCheckIn: boolean;
|
||||
}) {
|
||||
const sharedCheckInBracketIdxs = CheckIn.sharedBracketIdxs(
|
||||
bracketIdx,
|
||||
this.ctx.settings.bracketProgression,
|
||||
);
|
||||
|
||||
return teams.reduce<{
|
||||
checkedInTeams: number[];
|
||||
notCheckedInTeams: number[];
|
||||
@@ -632,9 +638,9 @@ export class Tournament {
|
||||
acc.notCheckedInTeams.push(cur);
|
||||
}
|
||||
} else if (requiresCheckIn) {
|
||||
const isCheckedIn = team.checkIns.some(
|
||||
(checkIn) =>
|
||||
checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut,
|
||||
const isCheckedIn = CheckIn.isCheckedInToBrackets(
|
||||
team.checkIns,
|
||||
sharedCheckInBracketIdxs,
|
||||
);
|
||||
|
||||
if (isCheckedIn) {
|
||||
|
||||
5
changelog/2026-09-28-shared-bracket-check-in.md
Normal file
5
changelog/2026-09-28-shared-bracket-check-in.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: medal
|
||||
type: feature
|
||||
---
|
||||
Checking in to a bracket also counts for other brackets starting at the same time, so a team moved from e.g. Beta to Alpha stays checked in
|
||||
5
changelog/2026-09-28-swiss-drop-keeps-standings-order.md
Normal file
5
changelog/2026-09-28-swiss-drop-keeps-standings-order.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: medal
|
||||
type: feature
|
||||
---
|
||||
Dropping out a team that finished its Swiss rounds only moves the teams below it up, the rest of the standings stay in the same order
|
||||
Reference in New Issue
Block a user