diff --git a/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts b/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts index b2f32106a..dc4cf8762 100644 --- a/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts +++ b/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts @@ -36,7 +36,7 @@ export function endDroppedTeamMatches( return Math.random() < 0.5 ? match.opponent1.id : match.opponent2.id; })(); - const stored = store.select("match", match.id); + const stored = store.matchById(match.id); if (!stored) throw Error("Match not found."); propagator.updateMatch( diff --git a/app/features/tournament-bracket/core/engine/propagation/report-result.ts b/app/features/tournament-bracket/core/engine/propagation/report-result.ts index 709197fae..e729b6b7c 100644 --- a/app/features/tournament-bracket/core/engine/propagation/report-result.ts +++ b/app/features/tournament-bracket/core/engine/propagation/report-result.ts @@ -16,7 +16,7 @@ export function reportResult( const store = new Store(data); const propagator = new Propagator(store); - const stored = store.select("match", input.matchId); + const stored = store.matchById(input.matchId); invariant(stored, "Match not found"); propagator.updateMatch( diff --git a/app/features/tournament-bracket/core/engine/propagation/reset-result.ts b/app/features/tournament-bracket/core/engine/propagation/reset-result.ts index 27909830d..bcb163273 100644 --- a/app/features/tournament-bracket/core/engine/propagation/reset-result.ts +++ b/app/features/tournament-bracket/core/engine/propagation/reset-result.ts @@ -20,13 +20,13 @@ export function resetMatchResults( const store = new Store(data); const propagator = new Propagator(store); - const stored = store.select("match", matchId); + const stored = store.matchById(matchId); if (!stored) throw Error("Match not found."); - const stage = store.select("stage", stored.stageId); + const stage = store.stageById(stored.stageId); if (!stage) throw Error("Stage not found."); - const group = store.select("group", stored.groupId); + const group = store.groupById(stored.groupId); if (!group) throw Error("Group not found."); const { roundNumber, roundCount } = propagator.getRoundPositionalInfo( @@ -55,7 +55,7 @@ export function resetMatchResults( throw Error("The match is locked."); helpers.clearWinner(stored); - propagator.applyMatchUpdate(stored); + store.markMatchChanged(stored); if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage)) propagator.updateRelatedMatches(stored); diff --git a/app/features/tournament-bracket/core/engine/propagation/store.ts b/app/features/tournament-bracket/core/engine/propagation/store.ts index ece6f410c..dd6daaf8a 100644 --- a/app/features/tournament-bracket/core/engine/propagation/store.ts +++ b/app/features/tournament-bracket/core/engine/propagation/store.ts @@ -6,78 +6,115 @@ import type { StageData, } from "../types"; -interface TableTypes { - stage: StageData; - group: GroupData; - round: RoundData; - match: MatchData; -} - -type Table = keyof TableTypes; - /** - * A working copy of BracketData with the same select/update semantics the old - * storage had: selects return clones (in table order), updates replace the row - * by id. Tracks which match rows were written so callers can emit a delta. + * A working copy of BracketData. The input is cloned once on construction and + * every row handed out afterwards is a live reference into that copy, so + * mutating a row is the write. Callers report the match rows they mutated so + * that a delta can be emitted. */ export class Store { readonly data: BracketData; - private readonly touchedMatchIds = new Set(); + private readonly stagesById: Map; + private readonly groupsById: Map; + private readonly roundsById: Map; + private readonly matchesById: Map; + private readonly groupsByStageId: Map; + private readonly roundsByGroupId: Map; + private readonly matchesByRoundId: Map; + private readonly changedMatchIds = new Set(); constructor(data: BracketData) { this.data = structuredClone(data); + + this.stagesById = indexById(this.data.stage); + this.groupsById = indexById(this.data.group); + this.roundsById = indexById(this.data.round); + this.matchesById = indexById(this.data.match); + + this.groupsByStageId = groupByKey( + this.data.group, + (group) => group.stageId, + ); + this.roundsByGroupId = groupByKey( + this.data.round, + (round) => round.groupId, + ); + this.matchesByRoundId = groupByKey( + this.data.match, + (match) => match.roundId, + ); } - select(table: T, id: number): TableTypes[T] | null { - const row = (this.data[table] as TableTypes[T][]).find((r) => r.id === id); - return row ? structuredClone(row) : null; + stageById(id: number): StageData | null { + return this.stagesById.get(id) ?? null; } - selectAll( - table: T, - filter: Partial, - ): TableTypes[T][] { - return (this.data[table] as TableTypes[T][]) - .filter(makeFilter(filter)) - .map((row) => structuredClone(row)); + groupById(id: number): GroupData | null { + return this.groupsById.get(id) ?? null; } - selectFirst( - table: T, - filter: Partial, - ): TableTypes[T] | null { - const results = this.selectAll(table, filter); - return results.length > 0 ? results[0] : null; + roundById(id: number): RoundData | null { + return this.roundsById.get(id) ?? null; } - selectLast( - table: T, - filter: Partial, - ): TableTypes[T] | null { - const results = this.selectAll(table, filter); - return results.length > 0 ? results[results.length - 1] : null; + matchById(id: number): MatchData | null { + return this.matchesById.get(id) ?? null; } - updateMatch(match: MatchData): void { - const index = this.data.match.findIndex((m) => m.id === match.id); - if (index === -1) throw Error("Could not update the match."); + groupByNumber(stageId: number, groupNumber: number): GroupData | null { + const groups = this.groupsByStageId.get(stageId); + return groups?.find((group) => group.number === groupNumber) ?? null; + } - this.data.match[index] = structuredClone(match); - this.touchedMatchIds.add(match.id); + roundByNumber(groupId: number, roundNumber: number): RoundData | null { + const rounds = this.roundsByGroupId.get(groupId); + return rounds?.find((round) => round.number === roundNumber) ?? null; + } + + matchByNumber(roundId: number, matchNumber: number): MatchData | null { + const matches = this.matchesByRoundId.get(roundId); + return matches?.find((match) => match.number === matchNumber) ?? null; + } + + roundCountInGroup(groupId: number): number { + return this.roundsByGroupId.get(groupId)?.length ?? 0; + } + + matchCountInRound(roundId: number): number { + return this.matchesByRoundId.get(roundId)?.length ?? 0; + } + + /** Records that a match row of this store was mutated. */ + markMatchChanged(match: MatchData): void { + if (this.matchesById.get(match.id) !== match) + throw Error("Match is not a row of this store."); + + this.changedMatchIds.add(match.id); } /** Returns the final version of every match row that was written during this operation. */ changedMatches(): MatchData[] { return this.data.match.filter((match) => - this.touchedMatchIds.has(match.id), + this.changedMatchIds.has(match.id), ); } } -function makeFilter( - partial: Partial, -): (row: T) => boolean { - const entries = Object.entries(partial); - return (row) => - entries.every(([key, value]) => row[key as keyof T] === value); +function indexById(rows: T[]): Map { + return new Map(rows.map((row) => [row.id, row])); +} + +function groupByKey(rows: T[], key: (row: T) => number): Map { + const result = new Map(); + + for (const row of rows) { + const existing = result.get(key(row)); + if (existing) { + existing.push(row); + } else { + result.set(key(row), [row]); + } + } + + return result; } diff --git a/app/features/tournament-bracket/core/engine/propagation/traversal.ts b/app/features/tournament-bracket/core/engine/propagation/traversal.ts index 31f1b3bf2..fbe762927 100644 --- a/app/features/tournament-bracket/core/engine/propagation/traversal.ts +++ b/app/features/tournament-bracket/core/engine/propagation/traversal.ts @@ -19,8 +19,8 @@ interface RoundPositionalInfo { /** * Resolves the matches following another match and applies result propagation - * to them. Port of the old base/getter.ts + base/updater.ts, reading and - * writing a Store instead of storage. + * to them. Port of the old base/getter.ts + base/updater.ts, mutating the rows + * of a Store instead of writing to storage. */ export class Propagator { readonly store: Store; @@ -43,10 +43,10 @@ export class Propagator { match.roundId, ); - const stage = this.store.select("stage", match.stageId); + const stage = this.store.stageById(match.stageId); if (!stage) throw Error("Stage not found."); - const group = this.store.select("group", match.groupId); + const group = this.store.groupById(match.groupId); if (!group) throw Error("Group not found."); const matchLocation = helpers.getMatchLocation(stage.type, group.number); @@ -57,7 +57,7 @@ export class Propagator { /** * Updates a match based on a reported result. * - * @param stored A reference to what will be updated in the storage. + * @param stored The match row of the store that is mutated. * @param input Input of the update. * @param force Whether to force update matches that can't be played yet. */ @@ -69,11 +69,11 @@ export class Propagator { if (!force && matchStatus(this.store.data, stored.id) === "PENDING") throw Error("The match is locked."); - const stage = this.store.select("stage", stored.stageId); + const stage = this.store.stageById(stored.stageId); if (!stage) throw Error("Stage not found."); const resultChanged = helpers.setMatchResults(stored, input); - this.applyMatchUpdate(stored); + this.store.markMatchChanged(stored); // Don't propagate if it's a simple score update. if (!resultChanged) return; @@ -86,15 +86,6 @@ export class Propagator { } } - /** - * Updates the opponents of a match. - * - * @param match A match. - */ - applyMatchUpdate(match: MatchData): void { - this.store.updateMatch(match); - } - /** * Updates the match(es) following the current match based on this match results. * @@ -169,7 +160,7 @@ export class Propagator { if (!nextMatches[0]) throw Error("First next match is null."); setNextOpponent(nextMatches[0], "opponent1", match, "opponent1"); setNextOpponent(nextMatches[0], "opponent2", match, "opponent2"); - this.applyMatchUpdate(nextMatches[0]); + this.store.markMatchChanged(nextMatches[0]); return; } @@ -197,7 +188,7 @@ export class Propagator { match, winnerSide && helpers.getOtherSide(winnerSide), ); - this.applyMatchUpdate(nextMatches[1]); + this.store.markMatchChanged(nextMatches[1]); } else { const nextSideLB = helpers.getNextSideLoserBracket( match.number, @@ -221,7 +212,7 @@ export class Propagator { */ propagateByeWinners(match: MatchData): void { helpers.resolveByeWinner(match); // BYE propagation is only in non round-robin stages. - this.applyMatchUpdate(match); + this.store.markMatchChanged(match); if (helpers.hasBye(match)) this.updateRelatedMatches(match); } @@ -236,16 +227,12 @@ export class Propagator { * @param roundId ID of the round. */ getRoundPositionalInfo(roundId: number): RoundPositionalInfo { - const round = this.store.select("round", roundId); + const round = this.store.roundById(roundId); if (!round) throw Error("Round not found."); - const rounds = this.store.selectAll("round", { - groupId: round.groupId, - }); - return { roundNumber: round.number, - roundCount: rounds.length, + roundCount: this.store.roundCountInGroup(round.groupId), }; } @@ -489,10 +476,7 @@ export class Propagator { stageType === "single_elimination" ? 2 /* Consolation final */ : 3; /* Grand final */ - const finalGroup = this.store.selectFirst("group", { - stageId: stageId, - number: groupNumber, - }); + const finalGroup = this.store.groupByNumber(stageId, groupNumber); if (!finalGroup) return null; return finalGroup.id; } @@ -503,10 +487,7 @@ export class Propagator { * @param stageId ID of the stage. */ private getUpperBracket(stageId: number): GroupData { - const winnerBracket = this.store.selectFirst("group", { - stageId: stageId, - number: 1, - }); + const winnerBracket = this.store.groupByNumber(stageId, 1); if (!winnerBracket) throw Error("Winner bracket not found."); return winnerBracket; } @@ -519,16 +500,10 @@ export class Propagator { */ private participantCount(stageId: number): number { const upperBracket = this.getUpperBracket(stageId); - const firstRound = this.store.selectFirst("round", { - groupId: upperBracket.id, - number: 1, - }); + const firstRound = this.store.roundByNumber(upperBracket.id, 1); if (!firstRound) throw Error("First round not found."); - const firstRoundMatches = this.store.selectAll("match", { - roundId: firstRound.id, - }); - return firstRoundMatches.length * 2; + return this.store.matchCountInRound(firstRound.id) * 2; } /** @@ -537,7 +512,7 @@ export class Propagator { * @param stageId ID of the stage. */ private getLoserBracket(stageId: number): GroupData | null { - return this.store.selectFirst("group", { stageId: stageId, number: 2 }); + return this.store.groupByNumber(stageId, 2); } /** @@ -590,17 +565,11 @@ export class Propagator { roundNumber: number, matchNumber: number, ): MatchData { - const round = this.store.selectFirst("round", { - groupId: groupId, - number: roundNumber, - }); + const round = this.store.roundByNumber(groupId, roundNumber); if (!round) throw Error("Round not found."); - const match = this.store.selectFirst("match", { - roundId: round.id, - number: matchNumber, - }); + const match = this.store.matchByNumber(round.id, matchNumber); if (!match) throw Error("Match not found.");