mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-26 21:26:01 -05:00
Round robin only tournament support (#2996)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import * as R from "remeda";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import * as Standings from "~/features/tournament/core/Standings";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
@@ -278,22 +279,8 @@ export class RoundRobinBracket extends Bracket {
|
||||
return 0;
|
||||
});
|
||||
|
||||
let lastPlacement = 0;
|
||||
let currentPlacement = 1;
|
||||
let teamsEncountered = 0;
|
||||
return this.standingsWithoutNonParticipants(
|
||||
sorted.map((team) => {
|
||||
if (team.placement !== lastPlacement) {
|
||||
lastPlacement = team.placement;
|
||||
currentPlacement = teamsEncountered + 1;
|
||||
}
|
||||
teamsEncountered++;
|
||||
return {
|
||||
...team,
|
||||
placement: currentPlacement,
|
||||
stats: team.stats,
|
||||
};
|
||||
}),
|
||||
Standings.reNumberPlacements(sorted),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +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 { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
@@ -443,22 +444,8 @@ export class SwissBracket extends Bracket {
|
||||
return 0;
|
||||
});
|
||||
|
||||
let lastPlacement = 0;
|
||||
let currentPlacement = 1;
|
||||
let teamsEncountered = 0;
|
||||
return this.standingsWithoutNonParticipants(
|
||||
sorted.map((team) => {
|
||||
if (team.placement !== lastPlacement) {
|
||||
lastPlacement = team.placement;
|
||||
currentPlacement = teamsEncountered + 1;
|
||||
}
|
||||
teamsEncountered++;
|
||||
return {
|
||||
...team,
|
||||
placement: currentPlacement,
|
||||
stats: team.stats,
|
||||
};
|
||||
}),
|
||||
Standings.reNumberPlacements(sorted),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -274,22 +274,36 @@ const getValidatedBrackets = (
|
||||
);
|
||||
|
||||
describe("validatedSources - other rules", () => {
|
||||
it("handles NOT_RESOLVING_WINNER (only round robin)", () => {
|
||||
const error = getValidatedBrackets([
|
||||
it("accepts a single round robin with no follow-ups", () => {
|
||||
const result = getValidatedBrackets([
|
||||
{
|
||||
settings: {},
|
||||
type: "round_robin",
|
||||
},
|
||||
]) as Progression.ValidationError;
|
||||
]);
|
||||
|
||||
expect(error.type).toBe("NOT_RESOLVING_WINNER");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles NOT_RESOLVING_WINNER (ends in round robin)", () => {
|
||||
const error = getValidatedBrackets([
|
||||
it("accepts a single A/B round robin with no follow-ups", () => {
|
||||
const result = getValidatedBrackets([
|
||||
{
|
||||
settings: {
|
||||
hasAbDivisions: true,
|
||||
teamsPerGroup: 6,
|
||||
},
|
||||
type: "round_robin",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a swiss to round robin progression", () => {
|
||||
const result = getValidatedBrackets([
|
||||
{
|
||||
settings: {},
|
||||
type: "single_elimination",
|
||||
type: "swiss",
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
@@ -301,9 +315,30 @@ describe("validatedSources - other rules", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
]) as Progression.ValidationError;
|
||||
]);
|
||||
|
||||
expect(error.type).toBe("NOT_RESOLVING_WINNER");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a round robin to round robin progression", () => {
|
||||
const result = getValidatedBrackets([
|
||||
{
|
||||
settings: {},
|
||||
type: "round_robin",
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
type: "round_robin",
|
||||
sources: [
|
||||
{
|
||||
bracketId: "0",
|
||||
placements: "1,2",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles NOT_RESOLVING_WINNER (swiss with many groups)", () => {
|
||||
|
||||
@@ -405,7 +405,6 @@ function resolvesWinner(brackets: ParsedBracket[]) {
|
||||
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
|
||||
|
||||
if (!finals) return false;
|
||||
if (finals?.type === "round_robin") return false;
|
||||
if (
|
||||
finals.type === "swiss" &&
|
||||
(finals.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT) > 1
|
||||
@@ -668,6 +667,16 @@ export function isFinals(idx: number, brackets: ParsedBracket[]) {
|
||||
return resolveMainBracketProgression(brackets).at(-1) === idx;
|
||||
}
|
||||
|
||||
/** Returns true if the finals bracket of the tournament is an A/B divisions round robin. */
|
||||
export function hasAbDivisionsFinals(brackets: ParsedBracket[]): boolean {
|
||||
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
|
||||
if (!finals) return false;
|
||||
|
||||
return (
|
||||
finals.type === "round_robin" && finals.settings?.hasAbDivisions === true
|
||||
);
|
||||
}
|
||||
|
||||
/** Given bracketIdx and bracketProgression will resolve if this an "underground bracket".
|
||||
* Underground bracket is defined as a bracket that is not part of the main tournament progression e.g. optional bracket for early losers
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "../tournament-bracket-utils";
|
||||
import type { Standing } from "./Bracket";
|
||||
import type { ParsedBracket } from "./Progression";
|
||||
import * as Progression from "./Progression";
|
||||
|
||||
export interface TournamentSummary {
|
||||
skills: Omit<
|
||||
@@ -41,6 +42,7 @@ type TeamsArg = Array<{
|
||||
id: number;
|
||||
members: Array<{ userId: number }>;
|
||||
startingBracketIdx?: number | null;
|
||||
abDivision?: number | null;
|
||||
}>;
|
||||
|
||||
type Rating = Pick<Tables["Skill"], "mu" | "sigma">;
|
||||
@@ -574,25 +576,31 @@ function tournamentResults({
|
||||
}) {
|
||||
const result: TournamentSummary["tournamentResults"] = [];
|
||||
|
||||
const firstPlaceFinishesCount = finalStandings.filter(
|
||||
(s) => s.placement === 1,
|
||||
).length;
|
||||
const isMultiStartingBracket = firstPlaceFinishesCount > 1;
|
||||
const isMultiStartingBracket =
|
||||
Progression.startingBrackets(progression).length > 1;
|
||||
const isAbDivisionsFinals = Progression.hasAbDivisionsFinals(progression);
|
||||
|
||||
for (const standing of finalStandings) {
|
||||
const team = teams.find((t) => t.id === standing.team.id);
|
||||
invariant(team);
|
||||
const div =
|
||||
// second check should be redundant, but just here in case
|
||||
typeof team.startingBracketIdx === "number" && isMultiStartingBracket
|
||||
? getBracketProgressionLabel(team.startingBracketIdx, progression)
|
||||
: null;
|
||||
|
||||
const divisionParticipantCount =
|
||||
div !== null
|
||||
? teams.filter((t) => t.startingBracketIdx === team.startingBracketIdx)
|
||||
.length
|
||||
: participantCount;
|
||||
let div: string | null = null;
|
||||
let divisionParticipantCount = participantCount;
|
||||
|
||||
if (isAbDivisionsFinals && typeof team.abDivision === "number") {
|
||||
div = team.abDivision === 0 ? "A" : "B";
|
||||
divisionParticipantCount = teams.filter(
|
||||
(t) => t.abDivision === team.abDivision,
|
||||
).length;
|
||||
} else if (
|
||||
isMultiStartingBracket &&
|
||||
typeof team.startingBracketIdx === "number"
|
||||
) {
|
||||
div = getBracketProgressionLabel(team.startingBracketIdx, progression);
|
||||
divisionParticipantCount = teams.filter(
|
||||
(t) => t.startingBracketIdx === team.startingBracketIdx,
|
||||
).length;
|
||||
}
|
||||
|
||||
for (const player of standing.team.members) {
|
||||
result.push({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { Tables } from "../../../db/tables";
|
||||
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
|
||||
import type { ParsedBracket } from "./Progression";
|
||||
import { tournamentSummary } from "./summarizer.server";
|
||||
import type { TournamentDataTeam } from "./Tournament.server";
|
||||
|
||||
@@ -65,6 +66,7 @@ describe("tournamentSummary()", () => {
|
||||
seedingSkillCountsFor,
|
||||
withMemberInTwoTeams = false,
|
||||
teamsWithStartingBrackets,
|
||||
teamsWithAbDivisions,
|
||||
progression,
|
||||
finalStandings,
|
||||
}: {
|
||||
@@ -75,13 +77,11 @@ describe("tournamentSummary()", () => {
|
||||
id: number;
|
||||
startingBracketIdx: number | null;
|
||||
}>;
|
||||
progression?: Array<{
|
||||
name: string;
|
||||
type: "single_elimination";
|
||||
settings: Record<string, never>;
|
||||
requiresCheckIn: boolean;
|
||||
sources?: Array<{ bracketIdx: number; placements: number[] }>;
|
||||
teamsWithAbDivisions?: Array<{
|
||||
id: number;
|
||||
abDivision: 0 | 1;
|
||||
}>;
|
||||
progression?: ParsedBracket[];
|
||||
finalStandings?: Array<{
|
||||
placement: number;
|
||||
team: TournamentDataTeam;
|
||||
@@ -122,17 +122,19 @@ describe("tournamentSummary()", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const teams = teamsWithStartingBrackets
|
||||
? defaultTeams.map((team) => {
|
||||
const startingBracket = teamsWithStartingBrackets.find(
|
||||
(t) => t.id === team.id,
|
||||
);
|
||||
return {
|
||||
...team,
|
||||
startingBracketIdx: startingBracket?.startingBracketIdx ?? null,
|
||||
};
|
||||
})
|
||||
: defaultTeams;
|
||||
const teams = defaultTeams.map((team) => {
|
||||
const startingBracket = teamsWithStartingBrackets?.find(
|
||||
(t) => t.id === team.id,
|
||||
);
|
||||
const abDivisionEntry = teamsWithAbDivisions?.find(
|
||||
(t) => t.id === team.id,
|
||||
);
|
||||
return {
|
||||
...team,
|
||||
startingBracketIdx: startingBracket?.startingBracketIdx ?? null,
|
||||
abDivision: abDivisionEntry?.abDivision ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return tournamentSummary({
|
||||
finalStandings: finalStandings ?? [
|
||||
@@ -779,6 +781,102 @@ describe("tournamentSummary()", () => {
|
||||
expect(team4Results.every((r) => r.participantCount === 2)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("div is set from abDivision when finals is an A/B divisions round robin", () => {
|
||||
const summary = summarize({
|
||||
teamsWithAbDivisions: [
|
||||
{ id: 1, abDivision: 0 },
|
||||
{ id: 2, abDivision: 1 },
|
||||
{ id: 3, abDivision: 0 },
|
||||
{ id: 4, abDivision: 1 },
|
||||
],
|
||||
progression: [
|
||||
{
|
||||
name: "Groups stage",
|
||||
type: "round_robin",
|
||||
settings: { hasAbDivisions: true, teamsPerGroup: 4 },
|
||||
requiresCheckIn: false,
|
||||
},
|
||||
],
|
||||
finalStandings: [
|
||||
{
|
||||
placement: 1,
|
||||
team: createTeam(1, [1, 2, 3, 4]),
|
||||
},
|
||||
{
|
||||
placement: 1,
|
||||
team: createTeam(2, [5, 6, 7, 8]),
|
||||
},
|
||||
{
|
||||
placement: 2,
|
||||
team: createTeam(3, [9, 10, 11, 12]),
|
||||
},
|
||||
{
|
||||
placement: 2,
|
||||
team: createTeam(4, [13, 14, 15, 16]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const team1Results = summary.tournamentResults.filter(
|
||||
(r) => r.tournamentTeamId === 1,
|
||||
);
|
||||
const team2Results = summary.tournamentResults.filter(
|
||||
(r) => r.tournamentTeamId === 2,
|
||||
);
|
||||
const team3Results = summary.tournamentResults.filter(
|
||||
(r) => r.tournamentTeamId === 3,
|
||||
);
|
||||
const team4Results = summary.tournamentResults.filter(
|
||||
(r) => r.tournamentTeamId === 4,
|
||||
);
|
||||
|
||||
expect(team1Results.every((r) => r.div === "A")).toBeTruthy();
|
||||
expect(team2Results.every((r) => r.div === "B")).toBeTruthy();
|
||||
expect(team3Results.every((r) => r.div === "A")).toBeTruthy();
|
||||
expect(team4Results.every((r) => r.div === "B")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("participantCount counts teams per abDivision for A/B finals", () => {
|
||||
const summary = summarize({
|
||||
teamsWithAbDivisions: [
|
||||
{ id: 1, abDivision: 0 },
|
||||
{ id: 2, abDivision: 1 },
|
||||
{ id: 3, abDivision: 0 },
|
||||
{ id: 4, abDivision: 1 },
|
||||
],
|
||||
progression: [
|
||||
{
|
||||
name: "Groups stage",
|
||||
type: "round_robin",
|
||||
settings: { hasAbDivisions: true, teamsPerGroup: 4 },
|
||||
requiresCheckIn: false,
|
||||
},
|
||||
],
|
||||
finalStandings: [
|
||||
{
|
||||
placement: 1,
|
||||
team: createTeam(1, [1, 2, 3, 4]),
|
||||
},
|
||||
{
|
||||
placement: 1,
|
||||
team: createTeam(2, [5, 6, 7, 8]),
|
||||
},
|
||||
{
|
||||
placement: 2,
|
||||
team: createTeam(3, [9, 10, 11, 12]),
|
||||
},
|
||||
{
|
||||
placement: 2,
|
||||
team: createTeam(4, [13, 14, 15, 16]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const result of summary.tournamentResults) {
|
||||
expect(result.participantCount).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("excludes matches ended early by organizer from calculations", () => {
|
||||
const summary = summarize({
|
||||
results: [
|
||||
|
||||
253
app/features/tournament/core/Standings.test.ts
Normal file
253
app/features/tournament/core/Standings.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
progressions,
|
||||
testTournament,
|
||||
tournamentCtxTeam,
|
||||
} from "~/features/tournament-bracket/core/tests/test-utils";
|
||||
import { BracketsManager } from "~/modules/brackets-manager";
|
||||
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { reNumberPlacements, tournamentStandings } from "./Standings";
|
||||
|
||||
describe("tournamentStandings", () => {
|
||||
it("returns single-division standings for a tournament with one starting bracket", () => {
|
||||
const tournament = singleEliminationTournament();
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
expect(result.type).toBe("single");
|
||||
invariant(result.type === "single");
|
||||
expect(result.standings.length).toBeGreaterThan(0);
|
||||
expect(result.standings[0].placement).toBe(1);
|
||||
expect(result.standings[0].team.id).toBe(1);
|
||||
});
|
||||
|
||||
it("returns one div per starting bracket for a tournament with multiple starting brackets", () => {
|
||||
const tournament = testTournament({
|
||||
ctx: {
|
||||
settings: { bracketProgression: progressions.manyStartBrackets },
|
||||
teams: [
|
||||
tournamentCtxTeam(1, { startingBracketIdx: 0, seed: 1 }),
|
||||
tournamentCtxTeam(2, { startingBracketIdx: 0, seed: 2 }),
|
||||
tournamentCtxTeam(3, { startingBracketIdx: 1, seed: 3 }),
|
||||
tournamentCtxTeam(4, { startingBracketIdx: 1, seed: 4 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
expect(result.type).toBe("multi");
|
||||
invariant(result.type === "multi");
|
||||
expect(result.standings).toHaveLength(2);
|
||||
for (const { div } of result.standings) {
|
||||
expect(typeof div).toBe("string");
|
||||
expect(div.length).toBeGreaterThan(0);
|
||||
}
|
||||
const divs = result.standings.map((s) => s.div);
|
||||
expect(new Set(divs).size).toBe(2);
|
||||
});
|
||||
|
||||
it("splits A/B divisions finals into 'A' and 'B' divs with teams partitioned by abDivision", () => {
|
||||
const tournament = abDivisionsTournament();
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
expect(result.type).toBe("multi");
|
||||
invariant(result.type === "multi");
|
||||
expect(result.standings.map((s) => s.div)).toEqual(["A", "B"]);
|
||||
|
||||
const [a, b] = result.standings;
|
||||
expect(a.standings.map((s) => s.team.id)).toEqual([1, 3]);
|
||||
expect(b.standings.map((s) => s.team.id)).toEqual([2, 4]);
|
||||
expect(a.standings.every((s) => s.team.abDivision === 0)).toBe(true);
|
||||
expect(b.standings.every((s) => s.team.abDivision === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-numbers placements within each A/B division starting from 1", () => {
|
||||
const tournament = abDivisionsTournament();
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "multi");
|
||||
const [a, b] = result.standings;
|
||||
expect(a.standings.map((s) => s.placement)).toEqual([1, 2]);
|
||||
expect(b.standings.map((s) => s.placement)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reNumberPlacements", () => {
|
||||
it("keeps already contiguous placements unchanged", () => {
|
||||
const result = reNumberPlacements([
|
||||
{ placement: 1 },
|
||||
{ placement: 2 },
|
||||
{ placement: 3 },
|
||||
]);
|
||||
|
||||
expect(result.map((s) => s.placement)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("groups tied placements and skips numbers to match team count", () => {
|
||||
const result = reNumberPlacements([
|
||||
{ placement: 1 },
|
||||
{ placement: 1 },
|
||||
{ placement: 3 },
|
||||
{ placement: 3 },
|
||||
{ placement: 5 },
|
||||
]);
|
||||
|
||||
expect(result.map((s) => s.placement)).toEqual([1, 1, 3, 3, 5]);
|
||||
});
|
||||
|
||||
it("re-numbers from 1 when the input has been filtered (e.g. top finishers removed)", () => {
|
||||
const result = reNumberPlacements([
|
||||
{ placement: 3 },
|
||||
{ placement: 3 },
|
||||
{ placement: 5 },
|
||||
{ placement: 7 },
|
||||
]);
|
||||
|
||||
expect(result.map((s) => s.placement)).toEqual([1, 1, 3, 4]);
|
||||
});
|
||||
|
||||
it("adds the offset to every placement", () => {
|
||||
const result = reNumberPlacements(
|
||||
[{ placement: 1 }, { placement: 1 }, { placement: 3 }],
|
||||
10,
|
||||
);
|
||||
|
||||
expect(result.map((s) => s.placement)).toEqual([11, 11, 13]);
|
||||
});
|
||||
|
||||
it("preserves non-placement fields on each standing", () => {
|
||||
const result = reNumberPlacements([
|
||||
{ placement: 1, team: { id: 7 }, note: "a" },
|
||||
{ placement: 2, team: { id: 8 }, note: "b" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ placement: 1, team: { id: 7 }, note: "a" },
|
||||
{ placement: 2, team: { id: 8 }, note: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when given an empty array", () => {
|
||||
expect(reNumberPlacements([])).toEqual([]);
|
||||
expect(reNumberPlacements([], 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function singleEliminationTournament() {
|
||||
const storage = new InMemoryDatabase();
|
||||
const manager = new BracketsManager(storage);
|
||||
|
||||
manager.create({
|
||||
name: "Main Bracket",
|
||||
tournamentId: 1,
|
||||
type: "single_elimination",
|
||||
seeding: [1, 2, 3, 4],
|
||||
settings: { seedOrdering: ["natural"] },
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const pending = storage
|
||||
.select<any>("match")!
|
||||
.find(
|
||||
(m) =>
|
||||
typeof m.opponent1?.id === "number" &&
|
||||
typeof m.opponent2?.id === "number" &&
|
||||
m.opponent1.result !== "win" &&
|
||||
m.opponent2.result !== "win",
|
||||
);
|
||||
if (!pending) break;
|
||||
|
||||
const winnerIsOpp1 = pending.opponent1.id < pending.opponent2.id;
|
||||
manager.update.match({
|
||||
id: pending.id,
|
||||
opponent1: winnerIsOpp1 ? { score: 2, result: "win" } : { score: 0 },
|
||||
opponent2: winnerIsOpp1 ? { score: 0 } : { score: 2, result: "win" },
|
||||
});
|
||||
}
|
||||
|
||||
return testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: progressions.singleElimination,
|
||||
},
|
||||
teams: [
|
||||
tournamentCtxTeam(1, { seed: 1 }),
|
||||
tournamentCtxTeam(2, { seed: 2 }),
|
||||
tournamentCtxTeam(3, { seed: 3 }),
|
||||
tournamentCtxTeam(4, { seed: 4 }),
|
||||
],
|
||||
},
|
||||
data: manager.get.tournamentData(1),
|
||||
});
|
||||
}
|
||||
|
||||
function abDivisionsTournament() {
|
||||
const storage = new InMemoryDatabase();
|
||||
const manager = new BracketsManager(storage);
|
||||
|
||||
manager.create({
|
||||
name: "AB RR",
|
||||
tournamentId: 1,
|
||||
type: "round_robin",
|
||||
seeding: [1, 2, 3, 4],
|
||||
abDivisions: [0, 1, 0, 1],
|
||||
settings: {
|
||||
groupCount: 1,
|
||||
hasAbDivisions: true,
|
||||
seedOrdering: ["groups.seed_optimized"],
|
||||
},
|
||||
});
|
||||
|
||||
const winnerByMatchup: Record<string, number> = {
|
||||
"1-2": 1,
|
||||
"1-4": 1,
|
||||
"2-3": 2,
|
||||
"3-4": 3,
|
||||
};
|
||||
for (const match of storage.select<any>("match")!) {
|
||||
const a = match.opponent1.id as number;
|
||||
const b = match.opponent2.id as number;
|
||||
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
|
||||
const winnerId = winnerByMatchup[key];
|
||||
invariant(winnerId, `unexpected matchup ${key}`);
|
||||
const loserScore = key === "2-3" || key === "3-4" ? 1 : 0;
|
||||
const winnerIsOpp1 = match.opponent1.id === winnerId;
|
||||
manager.update.match({
|
||||
id: match.id,
|
||||
opponent1: winnerIsOpp1
|
||||
? { score: 2, result: "win" }
|
||||
: { score: loserScore },
|
||||
opponent2: winnerIsOpp1
|
||||
? { score: loserScore }
|
||||
: { score: 2, result: "win" },
|
||||
});
|
||||
}
|
||||
|
||||
const data = manager.get.tournamentData(1);
|
||||
|
||||
return testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "AB RR",
|
||||
requiresCheckIn: false,
|
||||
settings: { hasAbDivisions: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
tournamentCtxTeam(1, { abDivision: 0, seed: 1 }),
|
||||
tournamentCtxTeam(2, { abDivision: 1, seed: 2 }),
|
||||
tournamentCtxTeam(3, { abDivision: 0, seed: 3 }),
|
||||
tournamentCtxTeam(4, { abDivision: 1, seed: 4 }),
|
||||
],
|
||||
},
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,34 @@ export function flattenStandings(
|
||||
: standingsResult.standings.flatMap((div) => div.standings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-numbers placements in a sorted standings array so that tied placements stay
|
||||
* grouped (e.g. `[1, 1, 3, 3, 5]`) while non-tied positions reflect the true
|
||||
* number of teams above them. Useful after filtering or merging standings where
|
||||
* the original placement numbers no longer match the team count.
|
||||
*
|
||||
* Pass `offset` to shift every placement downwards — used when the returned
|
||||
* standings will be appended below standings from another bracket.
|
||||
*/
|
||||
export function reNumberPlacements<T extends { placement: number }>(
|
||||
standings: T[],
|
||||
offset = 0,
|
||||
): T[] {
|
||||
let lastOriginalPlacement = 0;
|
||||
let currentPlacement = 0;
|
||||
|
||||
return standings.map((standing, index) => {
|
||||
if (standing.placement !== lastOriginalPlacement) {
|
||||
lastOriginalPlacement = standing.placement;
|
||||
currentPlacement = index + 1;
|
||||
}
|
||||
return {
|
||||
...standing,
|
||||
placement: currentPlacement + offset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Calculates SPR (Seed Performance Rating) - see https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf */
|
||||
export function calculateSPR({
|
||||
standings,
|
||||
@@ -143,24 +171,42 @@ export function matchesPlayed({
|
||||
export function tournamentStandings(
|
||||
tournament: Tournament,
|
||||
): TournamentStandingsResult {
|
||||
const startingBracketIdxs = Progression.startingBrackets(
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
);
|
||||
const progression = tournament.ctx.settings.bracketProgression;
|
||||
const startingBracketIdxs = Progression.startingBrackets(progression);
|
||||
|
||||
if (startingBracketIdxs.length <= 1) {
|
||||
const standings = tournamentStandingsForBracket(tournament, undefined);
|
||||
|
||||
if (Progression.hasAbDivisionsFinals(progression)) {
|
||||
return {
|
||||
type: "multi",
|
||||
standings: [
|
||||
{
|
||||
div: "A",
|
||||
standings: reNumberPlacements(
|
||||
standings.filter((s) => s.team.abDivision === 0),
|
||||
),
|
||||
},
|
||||
{
|
||||
div: "B",
|
||||
standings: reNumberPlacements(
|
||||
standings.filter((s) => s.team.abDivision === 1),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
standings: tournamentStandingsForBracket(tournament, undefined),
|
||||
standings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "multi",
|
||||
standings: startingBracketIdxs.map((bracketIdx) => ({
|
||||
div: getBracketProgressionLabel(
|
||||
bracketIdx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
),
|
||||
div: getBracketProgressionLabel(bracketIdx, progression),
|
||||
standings: tournamentStandingsForBracket(tournament, bracketIdx),
|
||||
})),
|
||||
};
|
||||
@@ -241,8 +287,6 @@ function standingsToMergeable<
|
||||
standings: T[];
|
||||
teamsAboveFromAnotherBracketsCount: number;
|
||||
}) {
|
||||
const result: T[] = [];
|
||||
|
||||
const filtered = standings.filter(
|
||||
(standing) => !alreadyIncludedTeamIds.has(standing.team.id),
|
||||
);
|
||||
@@ -250,22 +294,8 @@ function standingsToMergeable<
|
||||
// e.g. if standings start at 3rd place, this must mean there is 2 teams left to finish _this_ bracket
|
||||
const unfinishedTeamsCount = (standings.at(0)?.placement ?? 1) - 1;
|
||||
|
||||
let placement = 1;
|
||||
|
||||
for (const [i, standing] of filtered.entries()) {
|
||||
const placementChanged =
|
||||
i !== 0 && standing.placement !== filtered[i - 1].placement;
|
||||
|
||||
if (placementChanged) {
|
||||
placement = i + 1;
|
||||
}
|
||||
|
||||
result.push({
|
||||
...standing,
|
||||
placement:
|
||||
placement + teamsAboveFromAnotherBracketsCount + unfinishedTeamsCount,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return reNumberPlacements(
|
||||
filtered,
|
||||
teamsAboveFromAnotherBracketsCount + unfinishedTeamsCount,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user