diff --git a/app/features/tournament-bracket/BracketRepository.server.ts b/app/features/tournament-bracket/BracketRepository.server.ts index 2c49c1454..7c0abd1f9 100644 --- a/app/features/tournament-bracket/BracketRepository.server.ts +++ b/app/features/tournament-bracket/BracketRepository.server.ts @@ -103,9 +103,7 @@ export async function findByTournamentId( "TournamentMatch.number", "TournamentMatch.startedAt", "TournamentMatch.winnerSide", - // totalKos is re-aggregated fresh from the game results; the - // totalKos/totalPoints that old write paths persisted into the - // opponent JSON is stale residue and gets stripped/overwritten + // totalKos is never persisted, it is aggregated fresh from the game results serializedOpponentWithKos("opponentOne").as("opponent1"), serializedOpponentWithKos("opponentTwo").as("opponent2"), ]) @@ -121,15 +119,15 @@ export async function findByTournamentId( } /** - * Builds the opponent JSON with the freshly aggregated KO count: strips the - * legacy `totalPoints` and overwrites `totalKos` with the SQL sum over the - * match's game results. Resolves to `null` for BYEs (the column is `null`). + * Builds the opponent JSON with the freshly aggregated KO count: sets + * `totalKos` to the SQL sum over the match's game results. Resolves to `null` + * for BYEs (the column is `null`). */ function serializedOpponentWithKos( column: "opponentOne" | "opponentTwo", ): RawBuilder { return kyselySql`json_set( - json_remove(${kyselySql.ref(`TournamentMatch.${column}`)}, '$.totalPoints'), + ${kyselySql.ref(`TournamentMatch.${column}`)}, '$.totalKos', sum( case @@ -376,9 +374,6 @@ export function resetBracket(tournamentStageId: number) { function serializeOpponent(opponent: ParticipantResult | null): string | null { if (!opponent) return null; - const { totalKos, totalPoints, ...persisted } = - opponent as ParticipantResult & { - totalPoints?: number; - }; + const { totalKos, ...persisted } = opponent; return JSON.stringify(persisted); } diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index 012d46627..02e420336 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -10,7 +10,6 @@ import { tournamentTeamPage, } from "../../../../utils/urls"; import { useUser } from "../../../auth/core/user"; -import { TOURNAMENT } from "../../../tournament/tournament-constants"; import type { Bracket, Standing } from "../../core/Bracket"; import * as Swiss from "../../core/engine/swiss/team-status"; import * as Progression from "../../core/Progression"; @@ -87,8 +86,7 @@ export function PlacementsTable({ advanceThreshold: bracket.settings.advanceThreshold, losses: stats.setLosses, wins: stats.setWins, - roundCount: - bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, + roundCount: bracket.swissRoundCount, }) === "advanced" ? bracket.tournament.brackets.find((otherBracket) => otherBracket.sources?.some( @@ -288,9 +286,7 @@ function StandingsTable({ advanceThreshold: bracket.settings.advanceThreshold, losses: s.stats.setLosses, wins: s.stats.setWins, - roundCount: - bracket.settings.roundCount ?? - TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, + roundCount: bracket.swissRoundCount, }) === "eliminated"; if (renderQualifiedRow) qualifiedRowRendered = true; diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 1027c2d29..7a72dd25f 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -126,7 +126,7 @@ export abstract class Bracket { return; try { - let data = this.data as Engine.BracketData; + let data = this.data; const teamOrder = this.teamOrderForSimulation(); @@ -141,7 +141,6 @@ export abstract class Bracket { loopCount++; for (const match of data.match) { - if (!match) continue; // we have a result already if (match.winnerSide) { continue; @@ -225,9 +224,7 @@ export abstract class Bracket { simulatedMatch(matchId: number) { if (!this.simulatedData) return; - return this.simulatedData.match - .filter(Boolean) - .find((match) => match.id === matchId); + return this.simulatedData.match.find((match) => match.id === matchId); } /** Whether reporting a game in this bracket also records if the game was a KO win. */ @@ -235,12 +232,17 @@ export abstract class Bracket { return false; } - get type(): Tables["TournamentStage"]["type"] { - throw new Error("not implemented"); - } + abstract get type(): Tables["TournamentStage"]["type"]; - get standings(): Standing[] { - throw new Error("not implemented"); + abstract get standings(): Standing[]; + + /** + * How many rounds a swiss bracket has. Comes from the bracket's own stage + * settings rather than `settings` (the progression's, editable at any time), + * so it can't drift from the bracket that actually exists. + */ + get swissRoundCount() { + return Engine.swissRoundCount(this.data); } get participantTournamentTeamIds() { @@ -397,16 +399,14 @@ export abstract class Bracket { return this.teamsPendingCheckIn.includes(team.id); } - source(_options: { + abstract source(options: { placements: number[]; advanceThreshold?: number; rest?: boolean; }): { relevantMatchesFinished: boolean; teams: number[]; - } { - throw new Error("not implemented"); - } + }; teamsWithNames(teams: { id: number }[]) { return teams.map((team) => { @@ -474,7 +474,5 @@ export abstract class Bracket { return ongoingMatchIds; } - defaultRoundBestOfs(_data: BracketData): BracketMapCounts { - throw new Error("not implemented"); - } + abstract defaultRoundBestOfs(data: BracketData): BracketMapCounts; } diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts index 07b3f51ab..59ecd622f 100644 --- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -14,6 +14,11 @@ export class SingleEliminationBracket extends Bracket { return "single_elimination"; } + /** Unreachable: bracket progression validation rejects single elimination as a source. */ + source(): never { + throw new Error("Single elimination bracket can't be a source"); + } + defaultRoundBestOfs(data: BracketData) { const result: BracketMapCounts = new Map(); diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index 2a1fa5f74..0d2d25083 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -1,7 +1,6 @@ import * as R from "remeda"; import type { Tables } from "~/db/tables"; import * as Standings from "~/features/tournament/core/Standings"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; @@ -59,9 +58,7 @@ export class SwissBracket extends Bracket { advanceThreshold, wins: standing.stats?.setWins ?? 0, losses: standing.stats?.setLosses ?? 0, - roundCount: - this.settings?.roundCount ?? - TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, + roundCount: this.swissRoundCount, }), })) .filter((t) => t.status === "advanced") diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 78d992509..566ab0201 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1088,8 +1088,7 @@ export class Tournament { match.opponent1?.id === team.id || match.opponent2?.id === team.id, ).length; const notAllRoundsGenerated = - bracket.settings?.roundCount && - setsGeneratedCount !== bracket.settings.roundCount; + setsGeneratedCount !== bracket.swissRoundCount; if (isParticipant && notAllRoundsGenerated) { return { type: "WAITING_FOR_ROUND" } as const; diff --git a/app/features/tournament-bracket/core/engine/create/builder.ts b/app/features/tournament-bracket/core/engine/create/builder.ts index d8f9f8160..09ef4aa60 100644 --- a/app/features/tournament-bracket/core/engine/create/builder.ts +++ b/app/features/tournament-bracket/core/engine/create/builder.ts @@ -329,13 +329,6 @@ export class StageCreator { }); } - /** - * Returns the ordering method for the first round of the upper bracket of an elimination stage. - */ - getStandardBracketFirstRoundOrdering(): SeedOrdering { - return "space_between"; - } - /** * The only major ordering for the lower bracket. */ diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.ts index 51dc7adc4..f55b6b752 100644 --- a/app/features/tournament-bracket/core/engine/create/double-elimination.ts +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.ts @@ -1,7 +1,7 @@ import type { Duel, ParticipantSlot } from "../types"; import type { StageCreator } from "./builder"; import * as helpers from "./helpers"; -import { ordering } from "./seeding"; +import { ordering, STANDARD_BRACKET_FIRST_ROUND_ORDERING } from "./seeding"; /** * Creates a double elimination stage. @@ -12,8 +12,7 @@ import { ordering } from "./seeding"; export function createDoubleElimination(creator: StageCreator): void { const slots = creator.getSlots(); const stage = creator.createStage(); - const method = creator.getStandardBracketFirstRoundOrdering(); - const ordered = ordering[method](slots); + const ordered = ordering[STANDARD_BRACKET_FIRST_ROUND_ORDERING](slots); const { losers: losersWb, winner: winnerWb } = creator.createStandardBracket( stage.id, diff --git a/app/features/tournament-bracket/core/engine/create/seeding.ts b/app/features/tournament-bracket/core/engine/create/seeding.ts index 658f5418e..ebc38eb04 100644 --- a/app/features/tournament-bracket/core/engine/create/seeding.ts +++ b/app/features/tournament-bracket/core/engine/create/seeding.ts @@ -65,6 +65,10 @@ export const ordering: OrderingMap = { }, }; +/** The ordering method for the first round of the upper bracket of an elimination stage. */ +export const STANDARD_BRACKET_FIRST_ROUND_ORDERING: SeedOrdering = + "space_between"; + export const defaultMinorOrdering: { [key: number]: SeedOrdering[] } = { // 1 or 2: Not possible. 4: ["natural", "reverse"], diff --git a/app/features/tournament-bracket/core/engine/create/settings.ts b/app/features/tournament-bracket/core/engine/create/settings.ts index 827b8a3b8..cdbf66f00 100644 --- a/app/features/tournament-bracket/core/engine/create/settings.ts +++ b/app/features/tournament-bracket/core/engine/create/settings.ts @@ -1,7 +1,12 @@ import type { TournamentStageSettings } from "~/db/tables"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { assertUnreachable } from "~/utils/types"; -import type { CreateBracketInput, StageSettings, StageType } from "../types"; +import type { + BracketData, + CreateBracketInput, + StageSettings, + StageType, +} from "../types"; /** * Resolves the user-selected settings into the engine's internal stage @@ -44,6 +49,18 @@ export function resolveStageSettings(input: CreateBracketInput): StageSettings { } } +/** + * How many rounds a swiss bracket was created with. Read off the stage's own + * settings (resolved and persisted when the bracket was created) so that later + * edits to the tournament's bracket progression can't change the advance and + * elimination math of a bracket that already exists. + */ +export function swissRoundCount(data: BracketData): number { + return ( + data.stage[0]?.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT + ); +} + /** Whether the bracket will include a third place match. Only possible for single elimination with at least 4 participants. */ export function hasThirdPlaceMatch(args: { type: StageType; diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.ts index 03f171e9b..c002ad054 100644 --- a/app/features/tournament-bracket/core/engine/create/single-elimination.ts +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.ts @@ -1,6 +1,6 @@ import type { Duel, ParticipantSlot } from "../types"; import type { StageCreator } from "./builder"; -import { ordering } from "./seeding"; +import { ordering, STANDARD_BRACKET_FIRST_ROUND_ORDERING } from "./seeding"; /** * Creates a single elimination stage. @@ -10,8 +10,7 @@ import { ordering } from "./seeding"; export function createSingleElimination(creator: StageCreator): void { const slots = creator.getSlots(); const stage = creator.createStage(); - const method = creator.getStandardBracketFirstRoundOrdering(); - const ordered = ordering[method](slots); + const ordered = ordering[STANDARD_BRACKET_FIRST_ROUND_ORDERING](slots); const { losers } = creator.createStandardBracket(stage.id, 1, ordered); createConsolationFinal(creator, stage.id, losers); diff --git a/app/features/tournament-bracket/core/engine/index.ts b/app/features/tournament-bracket/core/engine/index.ts index a36987134..16a3e79c1 100644 --- a/app/features/tournament-bracket/core/engine/index.ts +++ b/app/features/tournament-bracket/core/engine/index.ts @@ -5,7 +5,11 @@ */ export { create } from "./create"; -export { hasThirdPlaceMatch, roundRobinGroupCount } from "./create/settings"; +export { + hasThirdPlaceMatch, + roundRobinGroupCount, + swissRoundCount, +} from "./create/settings"; export { endDroppedTeamMatches } from "./propagation/dropped-teams"; export { reportResult } from "./propagation/report-result"; export { resetMatchResults } from "./propagation/reset-result"; diff --git a/app/features/tournament-bracket/core/engine/swiss/pairing.ts b/app/features/tournament-bracket/core/engine/swiss/pairing.ts index a5814faab..0749b9049 100644 --- a/app/features/tournament-bracket/core/engine/swiss/pairing.ts +++ b/app/features/tournament-bracket/core/engine/swiss/pairing.ts @@ -1,8 +1,8 @@ import blossom from "edmonds-blossom-fixed"; import { err, ok, type Result } from "neverthrow"; import * as R from "remeda"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import invariant from "~/utils/invariant"; +import { swissRoundCount } from "../create/settings"; import type { BracketData, GeneratedRound, @@ -23,7 +23,7 @@ export function generateRound( args: { groupId: number; standings: SwissStanding[]; - settings: { advanceThreshold?: number; roundCount?: number } | null; + settings: { advanceThreshold?: number } | null; }, ): Result { // lets consider only this groups matches @@ -52,8 +52,7 @@ export function generateRound( // filter out teams that have advanced or been eliminated if early advance/elimination is enabled if (typeof args.settings?.advanceThreshold === "number") { - const roundCount = - args.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT; + const roundCount = swissRoundCount(data); const advanceThreshold = args.settings.advanceThreshold; standingsWithoutDropouts = standingsWithoutDropouts.filter((standing) => { diff --git a/migrations/159-tournament-match-nullable-opponents.js b/migrations/159-tournament-match-nullable-opponents.js index 7a0cef59f..204b3e7ba 100644 --- a/migrations/159-tournament-match-nullable-opponents.js +++ b/migrations/159-tournament-match-nullable-opponents.js @@ -32,8 +32,8 @@ export function up(db) { "stageId", "groupId", "number", - case when "opponentOne" = 'null' then null else json_remove("opponentOne", '$.result', '$.forfeit') end, - case when "opponentTwo" = 'null' then null else json_remove("opponentTwo", '$.result', '$.forfeit') end, + case when "opponentOne" = 'null' then null else json_remove("opponentOne", '$.result', '$.forfeit', '$.totalPoints', '$.totalKos') end, + case when "opponentTwo" = 'null' then null else json_remove("opponentTwo", '$.result', '$.forfeit', '$.totalPoints', '$.totalKos') end, case when "opponentOne" ->> '$.result' = 'win' then 'opponent1' when "opponentTwo" ->> '$.result' = 'win' then 'opponent2'