Fix in-progress SE/DE standings sometimes incorrect

This commit is contained in:
Kalle
2026-06-29 20:31:59 +03:00
parent e063f1a7fd
commit f9739d9551
4 changed files with 247 additions and 14 deletions

View File

@@ -626,3 +626,196 @@ describe("single elimination standings - third place match", () => {
).toBe(4);
});
});
const reportLowerIdWinner = (
storage: InMemoryDatabase,
manager: BracketsManager,
matchId: number,
) => {
const match = storage.select<any>("match", matchId);
invariant(match, `match ${matchId} not found`);
const opponent1Lower = match.opponent1.id < match.opponent2.id;
manager.update.match({
id: matchId,
opponent1: opponent1Lower ? { score: 2, result: "win" } : { score: 0 },
opponent2: opponent1Lower ? { score: 0 } : { score: 2, result: "win" },
});
};
const readyMatches = (
storage: InMemoryDatabase,
predicate: (match: any) => boolean,
) =>
storage
.select<any>("match")!
.filter(
(match) =>
predicate(match) &&
match.opponent1?.id != null &&
match.opponent2?.id != null &&
match.opponent1.result == null &&
match.opponent2.result == null,
);
describe("single elimination standings - projected ties", () => {
// Two semifinal losers tie for 3rd (no consolation final). Reports only one
// semifinal so the other is still in progress, mirroring the projected
// standings bug where the finished team is shown one placement too low.
const partialSingleEliminationTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "SE",
tournamentId: 1,
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
const semifinals = storage
.select<any>("match")!
.filter((match) => match.opponent1?.id && match.opponent2?.id);
invariant(semifinals.length === 2, "Expected two semifinal matches");
const decided = semifinals[0];
const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id);
reportLowerIdWinner(storage, manager, decided.id);
const tournament = testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "single_elimination",
name: "SE",
requiresCheckIn: false,
settings: {},
sources: [],
},
],
},
},
data: manager.get.tournamentData(1),
});
return { tournament, decidedLoserId };
};
it("projects a finished semifinal loser as tied 3rd before the other semifinal finishes", () => {
const { tournament, decidedLoserId } = partialSingleEliminationTournament();
const standings = tournament.bracketByIdx(0)!.standings;
expect(standings.find((s) => s.team.id === decidedLoserId)?.placement).toBe(
3,
);
});
});
describe("double elimination standings - projected ties", () => {
// 8-team DE: losers round 2 produces the 5th/6th tie. Plays out the whole
// winners bracket and losers round 1, then reports only one of the two
// losers round 2 matches so its loser should already project to tied 5th
// while the sibling match is still unfinished.
const partialDoubleEliminationTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "DE",
tournamentId: 1,
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: { grandFinal: "double", seedOrdering: ["natural"] },
});
const groupId = (number: number) =>
storage.select<any>("group")!.find((g) => g.number === number)!.id;
const winnersGroupId = groupId(1);
const losersGroupId = groupId(2);
const losersRoundId = (number: number) =>
storage
.select<any>("round")!
.find((r) => r.group_id === losersGroupId && r.number === number)!.id;
// play out the entire winners bracket so all losers feed in
let winnersReady = readyMatches(
storage,
(m) => m.group_id === winnersGroupId,
);
while (winnersReady.length) {
for (const match of winnersReady) {
reportLowerIdWinner(storage, manager, match.id);
}
winnersReady = readyMatches(
storage,
(m) => m.group_id === winnersGroupId,
);
}
// losers round 1: both matches -> two teams eliminated, tied 7th/8th
for (const match of readyMatches(
storage,
(m) => m.round_id === losersRoundId(1),
)) {
reportLowerIdWinner(storage, manager, match.id);
}
// losers round 2: report only one of the two matches
const losersRound2 = readyMatches(
storage,
(m) => m.round_id === losersRoundId(2),
);
invariant(losersRound2.length === 2, "Expected two losers round 2 matches");
const decided = losersRound2[0];
const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id);
const stillPlayingTeamIds = [
losersRound2[1].opponent1.id,
losersRound2[1].opponent2.id,
];
reportLowerIdWinner(storage, manager, decided.id);
const tournament = testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "double_elimination",
name: "DE",
requiresCheckIn: false,
settings: {},
sources: [],
},
],
},
},
data: manager.get.tournamentData(1),
});
return { tournament, decidedLoserId, stillPlayingTeamIds };
};
it("projects a finished losers-round-2 loser as tied 5th before the sibling match finishes", () => {
const { tournament, decidedLoserId } = partialDoubleEliminationTournament();
const standings = tournament.bracketByIdx(0)!.standings;
expect(standings.find((s) => s.team.id === decidedLoserId)?.placement).toBe(
5,
);
});
it("does not yet place teams still playing their losers round 2 match", () => {
const { tournament, stillPlayingTeamIds } =
partialDoubleEliminationTournament();
const standings = tournament.bracketByIdx(0)!.standings;
for (const teamId of stillPlayingTeamIds) {
expect(standings.find((s) => s.team.id === teamId)).toBe(undefined);
}
});
});

View File

@@ -5,6 +5,7 @@ import type { Round } from "~/modules/brackets-model";
import invariant from "~/utils/invariant";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
import { cumulativeEliminationsByRound } from "./utils";
export class DoubleEliminationBracket extends Bracket {
get type(): Tables["TournamentStage"]["type"] {
@@ -73,13 +74,13 @@ export class DoubleEliminationBracket extends Bracket {
const losersGroupId = this.data.group.find((g) => g.number === 2)?.id;
const losersMatches = this.data.match
.filter((match) => match.group_id === losersGroupId)
.sort((a, b) => a.round_id - b.round_id);
const teams: { id: number; lostAt: number }[] = [];
for (const match of this.data.match
.slice()
.sort((a, b) => a.round_id - b.round_id)) {
if (match.group_id !== losersGroupId) continue;
for (const match of losersMatches) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
@@ -97,8 +98,8 @@ export class DoubleEliminationBracket extends Bracket {
teams.push({ id: loser.id, lostAt: match.round_id });
}
const teamCountWhoDidntLoseInLosersYet =
this.participantTournamentTeamIds.length - teams.length;
const eliminationsThroughLosersRound =
cumulativeEliminationsByRound(losersMatches);
const result: Standing[] = [];
for (const roundId of R.unique(teams.map((team) => team.lostAt))) {
@@ -107,16 +108,18 @@ export class DoubleEliminationBracket extends Bracket {
teamsLostThisRound.push(teams.shift()!);
}
const placement =
this.participantTournamentTeamIds.length -
eliminationsThroughLosersRound.get(roundId)! +
1;
for (const { id: teamId } of teamsLostThisRound) {
const team = this.tournament.teamById(teamId);
invariant(team, `Team not found for id: ${teamId}`);
const teamsPlacedAbove =
teamCountWhoDidntLoseInLosersYet + teams.length;
result.push({
team,
placement: teamsPlacedAbove + 1,
placement,
});
}
}

View File

@@ -5,6 +5,7 @@ import type { Round } from "~/modules/brackets-model";
import invariant from "~/utils/invariant";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
import { cumulativeEliminationsByRound } from "./utils";
export class SingleEliminationBracket extends Bracket {
get type(): Tables["TournamentStage"]["type"] {
@@ -96,6 +97,8 @@ export class SingleEliminationBracket extends Bracket {
const teamCountWhoDidntLoseYet =
this.participantTournamentTeamIds.length - teams.length;
const eliminationsThroughRound = cumulativeEliminationsByRound(matches);
const result: Standing[] = [];
for (const roundId of R.unique(teams.map((team) => team.lostAt))) {
const teamsLostThisRound: { id: number }[] = [];
@@ -103,15 +106,18 @@ export class SingleEliminationBracket extends Bracket {
teamsLostThisRound.push(teams.shift()!);
}
const placement =
this.participantTournamentTeamIds.length -
eliminationsThroughRound.get(roundId)! +
1;
for (const { id: teamId } of teamsLostThisRound) {
const team = this.tournament.teamById(teamId);
invariant(team, `Team not found for id: ${teamId}`);
const teamsPlacedAbove = teamCountWhoDidntLoseYet + teams.length;
result.push({
team,
placement: teamsPlacedAbove + 1,
placement,
});
}
}

View File

@@ -0,0 +1,31 @@
import * as R from "remeda";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
/**
* Maps each round_id to the cumulative number of teams eliminated by the end of
* that round, counting one elimination per non-bye match. This is a structural
* property of the bracket that does not depend on which matches have already
* been reported, so teams tied at the same placement resolve to the same
* placement even while some of their round's matches are still in progress.
*/
export function cumulativeEliminationsByRound(
matches: TournamentManagerDataSet["match"],
): Map<number, number> {
const result = new Map<number, number>();
const roundIds = R.unique(matches.map((match) => match.round_id)).sort(
(a, b) => a - b,
);
let cumulativeEliminations = 0;
for (const roundId of roundIds) {
const eliminationsThisRound = matches.filter(
(match) =>
match.round_id === roundId && match.opponent1 && match.opponent2,
).length;
cumulativeEliminations += eliminationsThisRound;
result.set(roundId, cumulativeEliminations);
}
return result;
}