From d2440c4a32e35cf3777bd2b623d6b230644a89c8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:03:53 +0300 Subject: [PATCH] Round robin only tournament support (#2996) --- .../core/Bracket/RoundRobinBracket.ts | 17 +- .../core/Bracket/SwissBracket.ts | 17 +- .../core/Progression.test.ts | 53 +++- .../tournament-bracket/core/Progression.ts | 11 +- .../core/summarizer.server.ts | 36 ++- .../core/summarizer.test.ts | 132 +++++++-- .../tournament/core/Standings.test.ts | 253 ++++++++++++++++++ app/features/tournament/core/Standings.ts | 86 ++++-- 8 files changed, 506 insertions(+), 99 deletions(-) create mode 100644 app/features/tournament/core/Standings.test.ts diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index 5b13a66cb..332517d0f 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -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), ); } diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index f17006a6b..b1a6294eb 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -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), ); } diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 26051fdc1..85b5638bb 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -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)", () => { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 3f6302bdd..746d21ce1 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -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 */ diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index 9d3cb9c98..c1a1b952e 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -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; @@ -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({ diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 2f34b67cf..5687b80b0 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -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; - 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: [ diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts new file mode 100644 index 000000000..4a6193390 --- /dev/null +++ b/app/features/tournament/core/Standings.test.ts @@ -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("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 = { + "1-2": 1, + "1-4": 1, + "2-3": 2, + "3-4": 3, + }; + for (const match of storage.select("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, + }); +} diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index a2fa012ce..d6894df4a 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -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( + 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, + ); }