Swiss TB consider dropped out teams who finished the bracket & reuse check-in

This commit is contained in:
Kalle
2026-09-29 17:44:11 +03:00
parent 966261de0a
commit 6b2809f32d
11 changed files with 463 additions and 85 deletions

View File

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

View File

@@ -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") {

View File

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

View File

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

View File

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

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

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

View File

@@ -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[] = [
{

View File

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

View 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

View 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