This commit is contained in:
Kalle
2026-07-25 16:03:42 +03:00
parent 035390c4dd
commit 7b3f7ca3a1
5 changed files with 109 additions and 103 deletions

View File

@@ -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(

View File

@@ -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(

View File

@@ -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);

View File

@@ -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<number>();
private readonly stagesById: Map<number, StageData>;
private readonly groupsById: Map<number, GroupData>;
private readonly roundsById: Map<number, RoundData>;
private readonly matchesById: Map<number, MatchData>;
private readonly groupsByStageId: Map<number, GroupData[]>;
private readonly roundsByGroupId: Map<number, RoundData[]>;
private readonly matchesByRoundId: Map<number, MatchData[]>;
private readonly changedMatchIds = new Set<number>();
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<T extends Table>(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<T extends Table>(
table: T,
filter: Partial<TableTypes[T]>,
): 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<T extends Table>(
table: T,
filter: Partial<TableTypes[T]>,
): 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<T extends Table>(
table: T,
filter: Partial<TableTypes[T]>,
): 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<T extends object>(
partial: Partial<T>,
): (row: T) => boolean {
const entries = Object.entries(partial);
return (row) =>
entries.every(([key, value]) => row[key as keyof T] === value);
function indexById<T extends { id: number }>(rows: T[]): Map<number, T> {
return new Map(rows.map((row) => [row.id, row]));
}
function groupByKey<T>(rows: T[], key: (row: T) => number): Map<number, T[]> {
const result = new Map<number, T[]>();
for (const row of rows) {
const existing = result.get(key(row));
if (existing) {
existing.push(row);
} else {
result.set(key(row), [row]);
}
}
return result;
}

View File

@@ -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.");