Skip to content

Commit e5cf666

Browse files
MineKing9534WolverinDEV
authored andcommitted
fix: tournaments getting stuck in an infinite reload loop
1 parent dc70c1f commit e5cf666

2 files changed

Lines changed: 112 additions & 12 deletions

File tree

packages/backend/src/tournament/tournamentService.test.ts

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,13 +325,32 @@ class FakeSessionManager {
325325
}
326326
}
327327

328+
type FakeFinishedGame = {
329+
id: string;
330+
players: Array<{ playerId: string; profileId: string | null }>;
331+
gameResult: { winningPlayerId: string | null } | null;
332+
sessionId?: string;
333+
};
334+
328335
class FakeGameHistoryRepository {
329-
async getFinishedGame(): Promise<null> {
336+
finishedGames = new Map<string, FakeFinishedGame>();
337+
338+
async getFinishedGame(): Promise<FakeFinishedGame | null> {
330339
return null;
331340
}
332341

333-
async getFinishedGameBySessionId(): Promise<null> {
334-
return null;
342+
async getFinishedGameBySessionId(sessionId: string): Promise<FakeFinishedGame | null> {
343+
return [...this.finishedGames.values()].find((game) => game.sessionId === sessionId) ?? null;
344+
}
345+
}
346+
347+
class FinishedGameByIdRepository extends FakeGameHistoryRepository {
348+
constructor(private readonly game: FakeFinishedGame) {
349+
super();
350+
}
351+
352+
override async getFinishedGame(): Promise<FakeFinishedGame | null> {
353+
return this.game;
335354
}
336355
}
337356

@@ -416,6 +435,7 @@ function createServiceWithOverrides(options: {
416435
sessionManager?: FakeSessionManager;
417436
eventSink?: FakeTournamentEventSink;
418437
authRepository?: FakeAuthRepository;
438+
gameHistoryRepository?: FakeGameHistoryRepository;
419439
}) {
420440
const repository = options.repository;
421441
const sessionManager = options.sessionManager ?? new FakeSessionManager();
@@ -425,7 +445,7 @@ function createServiceWithOverrides(options: {
425445
repository as never,
426446
authRepository as never,
427447
sessionManager as never,
428-
new FakeGameHistoryRepository() as never,
448+
(options.gameHistoryRepository ?? new FakeGameHistoryRepository()) as never,
429449
);
430450
service.setEventHandlers(eventSink);
431451

@@ -1334,6 +1354,75 @@ test(`getTournamentDetail refreshes double-elimination participant statuses when
13341354
assert.equal(runnerUp?.status, `eliminated`);
13351355
});
13361356

1357+
test(`getTournamentDetail repairs stale best-of session recovery without repeated updatedAt changes`, async () => {
1358+
const tournament = createTournament({
1359+
status: `live`,
1360+
format: `double-elimination`,
1361+
updatedAt: 123,
1362+
seriesSettings: {
1363+
earlyRoundsBestOf: 3,
1364+
finalsBestOf: 3,
1365+
grandFinalBestOf: 5,
1366+
grandFinalResetEnabled: true,
1367+
},
1368+
participants: [
1369+
createParticipant({ profileId: `player-1`, displayName: `Player 1`, registeredAt: 1, checkedInAt: 11, status: `checked-in`, checkInState: `checked-in`, seed: 1 }),
1370+
createParticipant({ profileId: `player-2`, displayName: `Player 2`, registeredAt: 2, checkedInAt: 12, status: `checked-in`, checkInState: `checked-in`, seed: 2 }),
1371+
],
1372+
matches: [
1373+
createMatch({
1374+
id: `match-winners-1-1`,
1375+
bracket: `winners`,
1376+
round: 1,
1377+
order: 1,
1378+
state: `in-progress`,
1379+
bestOf: 3,
1380+
sessionId: `missing-game-two-session`,
1381+
startedAt: 456,
1382+
gameIds: [`finished-game-one`],
1383+
currentGameNumber: 2,
1384+
leftWins: 1,
1385+
rightWins: 0,
1386+
slots: [
1387+
createSlot({ profileId: `player-1`, displayName: `Player 1`, seed: 1 }),
1388+
createSlot({ profileId: `player-2`, displayName: `Player 2`, seed: 2 }),
1389+
],
1390+
}),
1391+
],
1392+
});
1393+
const repository = new FakeTournamentRepository(tournament);
1394+
const gameHistoryRepository = new FinishedGameByIdRepository({
1395+
id: `finished-game-one`,
1396+
players: [
1397+
{ playerId: `left-player`, profileId: `player-1` },
1398+
{ playerId: `right-player`, profileId: `player-2` },
1399+
],
1400+
gameResult: { winningPlayerId: `left-player` },
1401+
});
1402+
const { service } = createServiceWithOverrides({
1403+
repository,
1404+
gameHistoryRepository,
1405+
});
1406+
1407+
await service.getTournamentDetail(tournament.id, null);
1408+
1409+
const repaired = repository.getSync(tournament.id).matches[0]!;
1410+
assert.equal(repaired.state, `ready`);
1411+
assert.equal(repaired.sessionId, null);
1412+
assert.deepEqual(repaired.gameIds, [`finished-game-one`]);
1413+
assert.equal(repaired.leftWins, 1);
1414+
assert.equal(repaired.currentGameNumber, 2);
1415+
1416+
await service.getTournamentDetail(tournament.id, null);
1417+
const recreated = repository.getSync(tournament.id);
1418+
const recreatedMatch = recreated.matches[0]!;
1419+
assert.equal(recreatedMatch.state, `in-progress`);
1420+
assert.notEqual(recreatedMatch.sessionId, null);
1421+
1422+
await service.getTournamentDetail(tournament.id, null);
1423+
assert.equal(repository.getSync(tournament.id).updatedAt, recreated.updatedAt);
1424+
});
1425+
13371426
test(`roundDelayMinutes keeps the grand final pending until its cooldown expires`, async () => {
13381427
const now = Date.now();
13391428
const tournament = createTournament({

packages/backend/src/tournament/tournamentService.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2012,7 +2012,14 @@ export class TournamentService {
20122012
throw new SessionError(`Failed to resolve the winner for a tournament match.`);
20132013
}
20142014

2015-
this.applyFinishedGameToMatch(tournament, match, session.state.gameId, winnerProfileId);
2015+
const appliedGame = this.applyFinishedGameToMatch(tournament, match, session.state.gameId, winnerProfileId);
2016+
if (appliedGame) {
2017+
return true;
2018+
}
2019+
2020+
match.sessionId = null;
2021+
match.state = `ready`;
2022+
match.startedAt = null;
20162023
return true;
20172024
}
20182025

@@ -2028,14 +2035,17 @@ export class TournamentService {
20282035
}
20292036

20302037
/* Session is gone — try to recover the result from game history */
2031-
const recoveredGame = match.gameIds.length > 0
2032-
? await this.gameHistoryRepository.getFinishedGame(match.gameIds[match.gameIds.length - 1])
2033-
: await this.gameHistoryRepository.getFinishedGameBySessionId(match.sessionId);
2038+
const recoveredGame = await this.gameHistoryRepository.getFinishedGameBySessionId(match.sessionId)
2039+
?? (match.gameIds.length > 0
2040+
? await this.gameHistoryRepository.getFinishedGame(match.gameIds[match.gameIds.length - 1])
2041+
: null);
20342042
if (recoveredGame?.gameResult?.winningPlayerId) {
20352043
const winnerProfileId = recoveredGame.players.find((player) => player.playerId === recoveredGame.gameResult?.winningPlayerId)?.profileId ?? null;
20362044
if (winnerProfileId) {
2037-
this.applyFinishedGameToMatch(tournament, match, recoveredGame.id, winnerProfileId);
2038-
return true;
2045+
const appliedGame = this.applyFinishedGameToMatch(tournament, match, recoveredGame.id, winnerProfileId);
2046+
if (appliedGame) {
2047+
return true;
2048+
}
20392049
}
20402050
}
20412051

@@ -2094,9 +2104,9 @@ export class TournamentService {
20942104
match: TournamentMatch,
20952105
gameId: string,
20962106
winnerProfileId: string,
2097-
) {
2107+
): boolean {
20982108
if (match.gameIds.includes(gameId)) {
2099-
return;
2109+
return false;
21002110
}
21012111
if (match.sessionId) {
21022112
this.cancelClaimWin(match.id, match.sessionId);
@@ -2122,6 +2132,7 @@ export class TournamentService {
21222132
match.state = `ready`;
21232133
match.startedAt = null;
21242134
}
2135+
return true;
21252136
}
21262137

21272138
/**

0 commit comments

Comments
 (0)