diff --git a/hasura/functions/tournaments/preview_tournament_match_reset.sql b/hasura/functions/tournaments/preview_tournament_match_reset.sql index 958997c6..b975b226 100644 --- a/hasura/functions/tournaments/preview_tournament_match_reset.sql +++ b/hasura/functions/tournaments/preview_tournament_match_reset.sql @@ -15,9 +15,11 @@ LANGUAGE plpgsql AS $$ DECLARE source_bracket_id uuid; + source_stage_type text; BEGIN - SELECT tb.id INTO source_bracket_id + SELECT tb.id, ts.type INTO source_bracket_id, source_stage_type FROM tournament_brackets tb + JOIN tournament_stages ts ON ts.id = tb.tournament_stage_id WHERE tb.match_id = _match_id LIMIT 1; @@ -25,6 +27,10 @@ BEGIN RETURN; END IF; + IF source_stage_type NOT IN ('SingleElimination', 'DoubleElimination') THEN + RAISE EXCEPTION 'only elimination stage matches can be reset' USING ERRCODE = '22000'; + END IF; + RETURN QUERY WITH RECURSIVE chain AS ( SELECT source_bracket_id AS id, 0 AS depth diff --git a/hasura/functions/tournaments/reset_tournament_match.sql b/hasura/functions/tournaments/reset_tournament_match.sql index 4f5f6080..1d15c358 100644 --- a/hasura/functions/tournaments/reset_tournament_match.sql +++ b/hasura/functions/tournaments/reset_tournament_match.sql @@ -6,7 +6,12 @@ LANGUAGE plpgsql AS $$ DECLARE slot_position int; + target_bracket tournament_brackets%ROWTYPE; BEGIN + SELECT * INTO target_bracket + FROM tournament_brackets + WHERE id = _target_bracket_id; + -- Same feeder ordering as assign_team_to_bracket_slot, including the -- WB-before-LB key that disambiguates the grand final's two feeders. SELECT ranked.pos INTO slot_position @@ -25,6 +30,19 @@ BEGIN ) ranked WHERE ranked.id = _source_bracket_id; + -- Mirror assign_team_to_bracket_slot's bye promotion: when a pruned + -- round-1 bye pushed its seed into the target, the surviving feeder owns + -- the opposite slot — never clear the seed-placed bye team. + IF slot_position IS NOT NULL THEN + IF target_bracket.team_1_seed IS NOT NULL + AND target_bracket.team_2_seed IS NULL THEN + slot_position := 2; + ELSIF target_bracket.team_2_seed IS NOT NULL + AND target_bracket.team_1_seed IS NULL THEN + slot_position := 1; + END IF; + END IF; + IF slot_position = 1 THEN UPDATE tournament_brackets SET tournament_team_id_1 = NULL @@ -52,6 +70,7 @@ DECLARE source_match matches%ROWTYPE; affected_match_id uuid; source_tournament_id uuid; + source_stage_type text; feeder record; BEGIN IF _reset_status NOT IN ('Scheduled', 'WaitingForCheckIn') THEN @@ -68,12 +87,18 @@ BEGIN RAISE EXCEPTION 'match is not linked to a tournament bracket' USING ERRCODE = '22000'; END IF; - SELECT ts.tournament_id - INTO source_tournament_id + SELECT ts.tournament_id, ts.type + INTO source_tournament_id, source_stage_type FROM tournament_stages ts WHERE ts.id = source_bracket.tournament_stage_id LIMIT 1; + -- Swiss/RoundRobin results drive pool assignment and standings-based + -- advancement that a parent-chain unwind cannot restore. + IF source_stage_type NOT IN ('SingleElimination', 'DoubleElimination') THEN + RAISE EXCEPTION 'only elimination stage matches can be reset' USING ERRCODE = '22000'; + END IF; + SELECT * INTO source_match FROM matches diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index d9c3a6e0..06485d08 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -129,6 +129,10 @@ export class ChatService { } break; case ChatLobbyType.Draft: { + if (isRoleAbove(user.role, "match_organizer")) { + break; + } + const { draft_games } = await this.hasuraService.query({ draft_games: { __args: { @@ -267,8 +271,14 @@ export class ChatService { private async canSendDraftMessage( id: string, - steamId: string, + player: User, ): Promise { + if (isRoleAbove(player.role, "match_organizer")) { + return true; + } + + const steamId = player.steam_id; + const { draft_games_by_pk } = await this.hasuraService.query({ draft_games_by_pk: { __args: { id }, @@ -278,6 +288,7 @@ export class ChatService { players: { steam_id: true, status: true, + lineup: true, }, }, }); @@ -298,10 +309,12 @@ export class ChatService { return true; } + // A waitlisted backup moved into a lineup plays in the match but keeps + // their Waitlist status, so lineup membership counts too. return (draft_games_by_pk.players || []).some( (draftPlayer) => String(draftPlayer.steam_id) === String(steamId) && - draftPlayer.status === "Accepted", + (draftPlayer.status === "Accepted" || draftPlayer.lineup != null), ); } @@ -321,7 +334,7 @@ export class ChatService { if ( type === ChatLobbyType.Draft && - !(await this.canSendDraftMessage(id, player.steam_id)) + !(await this.canSendDraftMessage(id, player)) ) { return; } @@ -635,4 +648,46 @@ export class ChatService { const sessionKeys = await this.redis.keys(`${lobbyKey}:sessions:*`); await this.redis.del(lobbyKey, ...sessionKeys); } + + public async migrateLobbyMessages( + fromType: ChatLobbyType, + fromId: string, + toType: ChatLobbyType, + toId: string, + ) { + const fromKey = `chat_${fromType}_${fromId}`; + const toKey = `chat_${toType}_${toId}`; + + const messagesObject = await this.redis.hgetall(fromKey); + + for (const [field, message] of Object.entries(messagesObject)) { + await this.redis.hset(toKey, field, message); + await this.redis.sendCommand( + new Redis.Command("HEXPIRE", [ + toKey, + this.expiresIn, + "FIELDS", + 1, + field, + ]), + ); + } + + await this.redis.del(fromKey); + await this.removeLobby(fromType, fromId); + + if (Object.keys(messagesObject).length === 0) { + return; + } + + const merged = await this.redis.hgetall(toKey); + const messages = Object.values(merged) + .map((value) => JSON.parse(value)) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + void this.to(toType, toId, "messages", { id: toId, messages }); + } } diff --git a/src/draft-games/draft-games.module.ts b/src/draft-games/draft-games.module.ts index df197ba0..f91c90c0 100644 --- a/src/draft-games/draft-games.module.ts +++ b/src/draft-games/draft-games.module.ts @@ -5,6 +5,7 @@ import { loggerFactory } from "../utilities/LoggerFactory"; import { HasuraModule } from "src/hasura/hasura.module"; import { RedisModule } from "src/redis/redis.module"; import { CacheModule } from "src/cache/cache.module"; +import { ChatModule } from "src/chat/chat.module"; import { MatchesModule } from "src/matches/matches.module"; import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; import { BullBoardModule } from "@bull-board/nestjs"; @@ -23,6 +24,7 @@ import { CleanExpiredDraftGames } from "./jobs/CleanExpiredDraftGames"; RedisModule, HasuraModule, CacheModule, + ChatModule, forwardRef(() => MatchesModule), BullModule.registerQueue({ name: DraftGameQueues.DraftGames, diff --git a/src/draft-games/draft-match.service.ts b/src/draft-games/draft-match.service.ts index 11e6699d..d0062219 100644 --- a/src/draft-games/draft-match.service.ts +++ b/src/draft-games/draft-match.service.ts @@ -4,6 +4,8 @@ import { e_map_pool_types_enum } from "generated"; import { HasuraService } from "src/hasura/hasura.service"; import { CacheService } from "src/cache/cache.service"; import { MatchAssistantService } from "src/matches/match-assistant/match-assistant.service"; +import { ChatService } from "src/chat/chat.service"; +import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; import { DraftGameService } from "./draft-game.service"; import { DraftGame } from "./types/DraftGame"; @@ -14,6 +16,7 @@ export class DraftMatchService { public readonly hasura: HasuraService, public readonly cache: CacheService, public readonly matchAssistant: MatchAssistantService, + private readonly chat: ChatService, @Inject(forwardRef(() => DraftGameService)) private readonly draftGameService: DraftGameService, ) {} @@ -67,6 +70,15 @@ export class DraftMatchService { await this.ensureLineups(draftGame, match); + // The room's conversation continues in the match chat: carry the history + // over and tear down the draft lobby so non-participants drop out. + await this.chat.migrateLobbyMessages( + ChatLobbyType.Draft, + draftGameId, + ChatLobbyType.Match, + match.id, + ); + const beforeComplete = await this.draftGameService.getDraftGame(draftGameId); if (!beforeComplete || beforeComplete.status === "Canceled") { diff --git a/test/tournament-reset.spec.ts b/test/tournament-reset.spec.ts index 94cbc734..b047bbb4 100644 --- a/test/tournament-reset.spec.ts +++ b/test/tournament-reset.spec.ts @@ -52,12 +52,29 @@ describe("tournament match reset (SQL-driven)", () => { matchId: string, newWinner: string | null = null, status = "WaitingForCheckIn", + scheduledAt: string | null = null, ) => postgres.query>( - "SELECT * FROM reset_tournament_match($1, $2, $3)", - [matchId, newWinner, status], + "SELECT * FROM reset_tournament_match($1, $2, $3, $4)", + [matchId, newWinner, status, scheduledAt], ); + // Wins every open match repeatedly until the bracket runs dry. + const sweep = async (stageId: string) => { + for (let i = 0; i < 12; i++) { + const open = await postgres.query>( + `SELECT match_id FROM tournament_brackets + WHERE tournament_stage_id = $1 AND match_id IS NOT NULL AND finished = false + ORDER BY round, match_number`, + [stageId], + ); + if (!open.length) break; + for (const bracket of open) { + await tfx.winMatch(bracket.match_id); + } + } + }; + it("previews the downstream chain of a reset", async () => { const t = await playedOutCup(); const semi = (await tfx.getBrackets(t.stageIds[0])).find( @@ -168,6 +185,379 @@ describe("tournament match reset (SQL-driven)", () => { expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); }); + it("reset to Scheduled applies the provided schedule", async () => { + const t = await playedOutCup(); + const semi = (await tfx.getBrackets(t.stageIds[0])).find( + (b) => b.round === 1 && b.match_number === 1, + )!; + + const when = "2027-01-01T12:00:00.000Z"; + await resetMatch(semi.match_id!, null, "Scheduled", when); + + const [source] = await postgres.query< + Array<{ status: string; scheduled_at: Date; winning_lineup_id: string | null }> + >("SELECT status, scheduled_at, winning_lineup_id FROM matches WHERE id = $1", [ + semi.match_id, + ]); + expect(source.status).toBe("Scheduled"); + expect(new Date(source.scheduled_at).toISOString()).toBe(when); + expect(source.winning_lineup_id).toBeNull(); + }); + + // 22 teams in a 32-slot bracket: ten round-1 byes are pruned and their seeds + // pushed into the round-2 parents. Round-2 match 5 holds the seed-2 bye team + // in slot 1 with a single surviving round-1 feeder for slot 2. + describe("byes in a 22-team bracket", () => { + type SeededBracket = { + id: string; + match_id: string | null; + team_1_seed: number | null; + team_2_seed: number | null; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }; + + const getRoundTwoMatchFive = async (stageId: string) => { + const [row] = await postgres.query>( + `SELECT id, match_id, team_1_seed, team_2_seed, + tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets + WHERE tournament_stage_id = $1 AND round = 2 AND match_number = 5`, + [stageId], + ); + return row; + }; + + const SE32 = [ + { type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 32 }, + ]; + + const launchAndPlayRoundOne = async () => { + const t = await tfx.launch(SE32, 22); + const stage = t.stageIds[0]; + + const roundOne = (await tfx.getBrackets(stage)).filter( + (b) => b.round === 1, + ); + expect(roundOne.length).toBe(6); + await tfx.playRound(stage, 1); + + const m5 = await getRoundTwoMatchFive(stage); + // The pruned bye pushed its seed into slot 1; the feeder winner fills slot 2. + expect(m5.team_1_seed).not.toBeNull(); + expect(m5.team_2_seed).toBeNull(); + expect(m5.tournament_team_id_1).not.toBeNull(); + expect(m5.tournament_team_id_2).not.toBeNull(); + + const feeders = await postgres.query< + Array<{ id: string; match_id: string }> + >( + `SELECT id, match_id FROM tournament_brackets WHERE parent_bracket_id = $1`, + [m5.id], + ); + expect(feeders.length).toBe(1); + + return { t, stage, m5, feederMatchId: feeders[0].match_id }; + }; + + it("resetting the feeder into round-2 match 5 keeps the bye team seated", async () => { + const { stage, m5, feederMatchId } = await launchAndPlayRoundOne(); + const byeTeam = m5.tournament_team_id_1; + + await resetMatch(feederMatchId); + + const after = await getRoundTwoMatchFive(stage); + // Only the feeder's contribution (slot 2) is vacated; the bye team keeps its seat. + expect(after.tournament_team_id_2).toBeNull(); + expect(after.tournament_team_id_1).toBe(byeTeam); + + // Replaying the feeder restores a playable round-2 match with both teams. + await tfx.winMatch(feederMatchId); + const replayed = await getRoundTwoMatchFive(stage); + expect(replayed.tournament_team_id_1).toBe(byeTeam); + expect(replayed.tournament_team_id_2).toBe(m5.tournament_team_id_2); + expect(replayed.match_id).not.toBeNull(); + }); + + it("resetting round-2 match 5 itself keeps both teams in place", async () => { + const { stage, m5 } = await launchAndPlayRoundOne(); + + await tfx.winMatch(m5.match_id!); + await resetMatch(m5.match_id!); + + const after = await getRoundTwoMatchFive(stage); + expect(after.tournament_team_id_1).toBe(m5.tournament_team_id_1); + expect(after.tournament_team_id_2).toBe(m5.tournament_team_id_2); + + const [source] = await postgres.query>( + "SELECT status FROM matches WHERE id = $1", + [m5.match_id], + ); + expect(source.status).toBe("WaitingForCheckIn"); + }); + + it("a corrected feeder winner lands opposite the bye team", async () => { + const { stage, m5, feederMatchId } = await launchAndPlayRoundOne(); + + const [feeder] = await postgres.query< + Array<{ + tournament_team_id_1: string; + tournament_team_id_2: string; + }> + >( + `SELECT tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets WHERE parent_bracket_id = $1`, + [m5.id], + ); + // Lineup 1 took round 1, so slot 2 currently holds the feeder's team 1. + expect(m5.tournament_team_id_2).toBe(feeder.tournament_team_id_1); + + const [lineups] = await postgres.query>( + "SELECT lineup_2_id FROM matches WHERE id = $1", + [feederMatchId], + ); + await resetMatch(feederMatchId, lineups.lineup_2_id); + + const after = await getRoundTwoMatchFive(stage); + expect(after.tournament_team_id_1).toBe(m5.tournament_team_id_1); + expect(after.tournament_team_id_2).toBe(feeder.tournament_team_id_2); + expect(after.match_id).not.toBeNull(); + }); + + it("a deep reset after full playout keeps the bye seat and replays to a finish", async () => { + const { t, stage, m5, feederMatchId } = await launchAndPlayRoundOne(); + for (let round = 2; round <= 5; round++) { + await tfx.playRound(stage, round); + } + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + + // Feeder -> R2M5 -> R3 -> R4 -> final: four downstream matches deleted. + const deleted = await resetMatch(feederMatchId); + expect(deleted.length).toBe(4); + expect(await tfx.tournamentStatus(t.id)).toBe("Live"); + + const after = await getRoundTwoMatchFive(stage); + expect(after.tournament_team_id_1).toBe(m5.tournament_team_id_1); + expect(after.tournament_team_id_2).toBeNull(); + + // The sibling round-2 winner outside the chain keeps its round-3 seat. + const [parent] = await postgres.query< + Array<{ + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }> + >( + `SELECT tournament_team_id_1, tournament_team_id_2 FROM tournament_brackets + WHERE id = (SELECT parent_bracket_id FROM tournament_brackets WHERE id = $1)`, + [m5.id], + ); + expect(parent.tournament_team_id_1).toBeNull(); + expect(parent.tournament_team_id_2).not.toBeNull(); + + await tfx.winMatch(feederMatchId); + await sweep(stage); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + const [{ c }] = await postgres.query>( + "SELECT count(*) AS c FROM tournament_trophies WHERE tournament_id = $1", + [t.id], + ); + expect(Number(c)).toBeGreaterThan(0); + }); + + it("resetting a both-bye round-2 match keeps both seed-placed teams", async () => { + const t = await tfx.launch(SE32, 22); + const stage = t.stageIds[0]; + + // Seeds 8v9 and 7v10 meet directly in round 2: both feeders were byes, + // so both teams are seed-placed and a match exists from launch. + const doubleByes = await postgres.query< + Array<{ + id: string; + match_id: string; + parent_bracket_id: string; + tournament_team_id_1: string; + tournament_team_id_2: string; + }> + >( + `SELECT id, match_id, parent_bracket_id, tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets + WHERE tournament_stage_id = $1 AND round = 2 + AND team_1_seed IS NOT NULL AND team_2_seed IS NOT NULL + ORDER BY match_number`, + [stage], + ); + expect(doubleByes.length).toBe(2); + const bracket = doubleByes[0]; + expect(bracket.match_id).not.toBeNull(); + expect(bracket.tournament_team_id_1).not.toBeNull(); + expect(bracket.tournament_team_id_2).not.toBeNull(); + + await tfx.winMatch(bracket.match_id); + const parentSlots = () => + postgres.query< + Array<{ + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }> + >( + `SELECT tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets WHERE id = $1`, + [bracket.parent_bracket_id], + ); + expect((await parentSlots())[0].tournament_team_id_2).toBe( + bracket.tournament_team_id_1, + ); + + await resetMatch(bracket.match_id); + + const [after] = await postgres.query< + Array<{ + match_id: string | null; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }> + >( + `SELECT match_id, tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets WHERE id = $1`, + [bracket.id], + ); + expect(after.tournament_team_id_1).toBe(bracket.tournament_team_id_1); + expect(after.tournament_team_id_2).toBe(bracket.tournament_team_id_2); + expect(after.match_id).toBe(bracket.match_id); + + const [parentAfter] = await parentSlots(); + expect(parentAfter.tournament_team_id_1).toBeNull(); + expect(parentAfter.tournament_team_id_2).toBeNull(); + }); + }); + + // 4-team double elimination: WB r1m1/m2 -> WB final (r2) -> grand final (r3), + // WB r1 losers meet in LB r1, whose winner faces the WB final loser in LB r2. + describe("double elimination resets", () => { + const DE4 = [ + { type: "DoubleElimination", order: 1, minTeams: 4, maxTeams: 4 }, + ]; + + type DeBracket = { + id: string; + match_id: string | null; + finished: boolean; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }; + + const getDe = async (stageId: string, path: string, round: number) => { + const [row] = await postgres.query>( + `SELECT id, match_id, finished, tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets + WHERE tournament_stage_id = $1 AND path = $2 AND round = $3 + ORDER BY match_number`, + [stageId, path, round], + ); + return row; + }; + + const getWbRoundOne = (stageId: string) => + postgres.query>( + `SELECT id, match_id, finished, tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets + WHERE tournament_stage_id = $1 AND path = 'WB' AND round = 1 + ORDER BY match_number`, + [stageId], + ); + + it("resetting a WB opener vacates its winner and loser drops, then replays", async () => { + const t = await tfx.launch(DE4, 4); + const stage = t.stageIds[0]; + + const wbR1 = await getWbRoundOne(stage); + await tfx.winMatch(wbR1[0].match_id!, "lineup_1_id"); + await tfx.winMatch(wbR1[1].match_id!, "lineup_2_id"); + + // m1's winner/loser landed in slot 1 of the WB final / LB r1. + const wbFinalBefore = await getDe(stage, "WB", 2); + const lbBefore = await getDe(stage, "LB", 1); + expect(wbFinalBefore.tournament_team_id_1).toBe(wbR1[0].tournament_team_id_1); + expect(wbFinalBefore.tournament_team_id_2).toBe(wbR1[1].tournament_team_id_2); + expect(lbBefore.tournament_team_id_1).toBe(wbR1[0].tournament_team_id_2); + expect(lbBefore.tournament_team_id_2).toBe(wbR1[1].tournament_team_id_1); + + // Chain spans both parents: source, WB final, grand final, LB r1, LB final. + const preview = await postgres.query>( + "SELECT * FROM preview_tournament_match_reset($1)", + [wbR1[0].match_id], + ); + expect(preview.length).toBe(5); + + const deleted = await resetMatch(wbR1[0].match_id!); + expect(deleted.length).toBe(2); // WB final + LB r1 matches existed + + const wbFinal = await getDe(stage, "WB", 2); + const lb = await getDe(stage, "LB", 1); + expect(wbFinal.tournament_team_id_1).toBeNull(); + expect(wbFinal.tournament_team_id_2).toBe(wbR1[1].tournament_team_id_2); + expect(wbFinal.match_id).toBeNull(); + expect(lb.tournament_team_id_1).toBeNull(); + expect(lb.tournament_team_id_2).toBe(wbR1[1].tournament_team_id_1); + expect(lb.match_id).toBeNull(); + + await tfx.winMatch(wbR1[0].match_id!, "lineup_1_id"); + await sweep(stage); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }); + + it("resetting the WB final unwinds the grand final and the LB final drop", async () => { + const t = await tfx.launch(DE4, 4); + const stage = t.stageIds[0]; + + const wbR1 = await getWbRoundOne(stage); + await tfx.winMatch(wbR1[0].match_id!); + await tfx.winMatch(wbR1[1].match_id!); + const lbR1 = await getDe(stage, "LB", 1); + await tfx.winMatch(lbR1.match_id!); + const wbFinal = await getDe(stage, "WB", 2); + await tfx.winMatch(wbFinal.match_id!); + + // WB final loser owns LB final slot 1; the LB r1 winner keeps slot 2. + const lbFinalBefore = await getDe(stage, "LB", 2); + expect(lbFinalBefore.tournament_team_id_1).toBe(wbFinal.tournament_team_id_2); + expect(lbFinalBefore.tournament_team_id_2).toBe(lbR1.tournament_team_id_1); + await tfx.winMatch(lbFinalBefore.match_id!); + + const gfBefore = await getDe(stage, "WB", 3); + expect(gfBefore.tournament_team_id_1).toBe(wbFinal.tournament_team_id_1); + expect(gfBefore.tournament_team_id_2).toBe(wbFinal.tournament_team_id_2); + expect(gfBefore.match_id).not.toBeNull(); + + const deleted = await resetMatch(wbFinal.match_id!); + expect(deleted.length).toBe(2); // LB final + grand final + + const gf = await getDe(stage, "WB", 3); + const lbFinal = await getDe(stage, "LB", 2); + expect(gf.tournament_team_id_1).toBeNull(); + expect(gf.tournament_team_id_2).toBeNull(); + expect(gf.match_id).toBeNull(); + expect(lbFinal.tournament_team_id_1).toBeNull(); + expect(lbFinal.tournament_team_id_2).toBe(lbR1.tournament_team_id_1); + expect(lbFinal.match_id).toBeNull(); + expect(lbFinal.finished).toBe(false); + + // Replay with the other team winning: the new loser drops to LB final + // slot 1, and the new WB champion takes grand final slot 1. + await tfx.winMatch(wbFinal.match_id!, "lineup_2_id"); + const lbReplay = await getDe(stage, "LB", 2); + expect(lbReplay.tournament_team_id_1).toBe(wbFinal.tournament_team_id_1); + expect(lbReplay.tournament_team_id_2).toBe(lbR1.tournament_team_id_1); + await tfx.winMatch(lbReplay.match_id!); + + const gfReplay = await getDe(stage, "WB", 3); + expect(gfReplay.tournament_team_id_1).toBe(wbFinal.tournament_team_id_2); + expect(gfReplay.tournament_team_id_2).toBe(lbReplay.tournament_team_id_1); + await tfx.winMatch(gfReplay.match_id!); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }); + }); + it("refuses to reset live matches, foreign winners, and non-bracket matches", async () => { const t = await tfx.launch(SE4, 4); const semi = (await tfx.getBrackets(t.stageIds[0])).find( @@ -200,4 +590,31 @@ describe("tournament match reset (SQL-driven)", () => { /not linked to a tournament bracket/i, ); }); + + // Swiss/RoundRobin results feed pool assignment and standings advancement + // that the parent-chain unwind cannot restore, so resets are rejected. + it("refuses to reset Swiss and RoundRobin matches", async () => { + for (const [type, teams] of [ + ["Swiss", 16], + ["RoundRobin", 4], + ] as Array<[string, number]>) { + const t = await tfx.launch( + [{ type, order: 1, minTeams: teams, maxTeams: teams }], + teams, + ); + const bracket = (await tfx.getBrackets(t.stageIds[0])).find( + (b) => b.match_id !== null, + )!; + await tfx.winMatch(bracket.match_id!); + + await expect(resetMatch(bracket.match_id!)).rejects.toThrow( + /only elimination stage matches can be reset/i, + ); + await expect( + postgres.query("SELECT * FROM preview_tournament_match_reset($1)", [ + bracket.match_id, + ]), + ).rejects.toThrow(/only elimination stage matches can be reset/i); + } + }); });