mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-20 10:04:57 -05:00
Fix in-progress SE/DE standings sometimes incorrect
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
31
app/features/tournament-bracket/core/Bracket/utils.ts
Normal file
31
app/features/tournament-bracket/core/Bracket/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user