This commit is contained in:
Kalle
2026-07-25 16:25:08 +03:00
parent 7b3f7ca3a1
commit 2c3c5da9c8
14 changed files with 66 additions and 61 deletions

View File

@@ -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<ParticipantResult | null> {
return kyselySql<ParticipantResult | null>`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);
}

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

@@ -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.
*/

View File

@@ -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,

View File

@@ -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"],

View File

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

View File

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

View File

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

View File

@@ -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<GeneratedRound, string> {
// 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) => {

View File

@@ -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'