diff --git a/hasura/functions/tournaments/sync_tournament_match_options.sql b/hasura/functions/tournaments/sync_tournament_match_options.sql new file mode 100644 index 00000000..d4553293 --- /dev/null +++ b/hasura/functions/tournaments/sync_tournament_match_options.sql @@ -0,0 +1,56 @@ +CREATE OR REPLACE FUNCTION public.sync_tournament_match_options( + _old public.match_options, + _new public.match_options +) RETURNS void + LANGUAGE plpgsql + AS $$ +DECLARE + _assignments text; + _descendant_ids uuid[]; +BEGIN + -- A descendant follows a column only while it still holds the template's + -- old value; anything else is a deliberate stage override or a hand-edited + -- match. best_of is never inherited: schedule_tournament_match resolves it + -- from the stage's round config. + SELECT string_agg( + format( + '%1$I = CASE WHEN %1$I IS NOT DISTINCT FROM ($1).%1$I THEN ($2).%1$I ELSE %1$I END', + changed.key + ), + ', ' + ) + INTO _assignments + FROM jsonb_each(to_jsonb(_new)) changed + WHERE changed.value IS DISTINCT FROM to_jsonb(_old) -> changed.key + AND changed.key NOT IN ('id', 'invite_code', 'best_of'); + + IF _assignments IS NULL THEN + RETURN; + END IF; + + SELECT array_agg(descendant.match_options_id) + INTO _descendant_ids + FROM ( + SELECT ts.match_options_id + FROM tournaments t + INNER JOIN tournament_stages ts ON ts.tournament_id = t.id + WHERE t.match_options_id = _new.id + AND ts.match_options_id IS NOT NULL + UNION + SELECT m.match_options_id + FROM tournament_brackets tb + INNER JOIN tournament_stages ts ON ts.id = tb.tournament_stage_id + INNER JOIN tournaments t ON t.id = ts.tournament_id + INNER JOIN matches m ON m.id = tb.match_id + WHERE COALESCE(tb.match_options_id, ts.match_options_id, t.match_options_id) = _new.id + AND m.status IN ('PickingPlayers', 'Scheduled', 'WaitingForCheckIn') + ) descendant; + + IF _descendant_ids IS NULL THEN + RETURN; + END IF; + + EXECUTE format('UPDATE match_options SET %s WHERE id = ANY($3)', _assignments) + USING _old, _new, _descendant_ids; +END; +$$; diff --git a/hasura/triggers/match_options.sql b/hasura/triggers/match_options.sql index 0b038a7d..be6f0267 100644 --- a/hasura/triggers/match_options.sql +++ b/hasura/triggers/match_options.sql @@ -140,6 +140,21 @@ BEGIN PERFORM refresh_veto_pick_expiry(_match_id); END IF; + -- tbi_match pins matches.region from a single-region option set, and + -- nothing re-reads it before Veto, so a match drawn early keeps hosting on + -- the region it was created with unless the pin moves with its options. + IF (NEW.regions IS DISTINCT FROM OLD.regions AND _match_status IN ('PickingPlayers', 'Scheduled', 'WaitingForCheckIn')) THEN + IF cardinality(NEW.regions) = 1 THEN + UPDATE matches SET region = NEW.regions[1] + WHERE id = _match_id AND region IS DISTINCT FROM NEW.regions[1]; + ELSIF NEW.region_veto THEN + UPDATE matches SET region = NULL + WHERE id = _match_id AND region IS NOT NULL; + END IF; + END IF; + + PERFORM sync_tournament_match_options(OLD, NEW); + RETURN NEW; END; $$; diff --git a/test/tournament-region-change.spec.ts b/test/tournament-region-change.spec.ts new file mode 100644 index 00000000..58cb1783 --- /dev/null +++ b/test/tournament-region-change.spec.ts @@ -0,0 +1,267 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// An organizer moves a tournament onto the LAN region and the first matches +// still came out on the region it was created with: round 1 is drawn (and its +// options cloned) at RegistrationClosed, and a stage that customized any +// advanced setting carries its own full snapshot of the tournament's options. +describe("changing a tournament's region before it starts (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let tfx: TournamentFixtures; + + const SE4 = [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 4 }]; + + beforeAll(async () => { + db = await bootMigratedDb("TournamentRegionChangeTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199500000000n); + tfx = new TournamentFixtures(postgres, fx); + await seedRegionWithServer(postgres, "TestA", 27015); + await seedRegionWithServer(postgres, "TestB", 27017); + await postgres.query( + `INSERT INTO server_regions (value, description, is_lan) + VALUES ('TestLan', 'TestLan', true) ON CONFLICT (value) DO NOTHING`, + ); + await postgres.query( + `INSERT INTO servers (host, label, rcon_password, port, region, type, is_dedicated, enabled) + VALUES ('127.0.0.1', 'TestLan', $1, 27016, 'TestLan', 'Ranked', true, true)`, + [Buffer.from("password")], + ); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + const moveTournamentTo = (tournamentId: string, region: string) => + postgres.query( + `UPDATE match_options SET regions = $2, region_veto = false + WHERE id = (SELECT match_options_id FROM tournaments WHERE id = $1)`, + [tournamentId, [region]], + ); + + // What TournamentStageForm.createMatchOptions writes: a full copy of the + // tournament's options carrying whichever advanced settings differ. + const giveStageOwnOptions = async ( + tournamentId: string, + stageId: string, + overrides: { regions?: Array; tv_delay?: number }, + ) => { + const [options] = await postgres.query>( + `INSERT INTO match_options (mr, best_of, type, map_pool_id, map_veto, region_veto, regions, tv_delay) + SELECT mr, best_of, type, map_pool_id, map_veto, + CASE WHEN $2::text[] IS NULL THEN region_veto ELSE false END, + COALESCE($2::text[], regions), + COALESCE($3::int, tv_delay) + FROM match_options + WHERE id = (SELECT match_options_id FROM tournaments WHERE id = $1) + RETURNING id`, + [tournamentId, overrides.regions ?? null, overrides.tv_delay ?? null], + ); + await postgres.query( + `UPDATE tournament_stages SET match_options_id = $1 WHERE id = $2`, + [options.id, stageId], + ); + }; + + const closeRegistration = async (tournament: { + id: string; + organizer: string; + }) => { + await tfx.setStatus(tournament.id, tournament.organizer, "RegistrationOpen"); + for (let i = 0; i < 4; i++) { + await tfx.registerTeam(tournament.id, await fx.team(1)); + } + await tfx.setStatus( + tournament.id, + tournament.organizer, + "RegistrationClosed", + ); + }; + + const roundMatches = (stageId: string, round: number) => + postgres.query< + Array<{ + id: string; + match_options_id: string; + regions: Array; + region: string | null; + status: string; + tv_delay: number; + }> + >( + `SELECT m.id, m.match_options_id, mo.regions, m.region, m.status, mo.tv_delay + FROM tournament_brackets tb + INNER JOIN matches m ON m.id = tb.match_id + INNER JOIN match_options mo ON mo.id = m.match_options_id + WHERE tb.tournament_stage_id = $1 AND tb.round = $2 + ORDER BY tb.match_number`, + [stageId, round], + ); + + it("hosts round 1 on a region chosen before the draw", async () => { + const tournament = await tfx.createTournament(SE4); + await moveTournamentTo(tournament.id, "TestLan"); + + await closeRegistration(tournament); + await tfx.setStatus(tournament.id, tournament.organizer, "Live"); + + const matches = await roundMatches(tournament.stageIds[0], 1); + + expect(matches.length).toBe(2); + for (const match of matches) { + expect(match.regions).toEqual(["TestLan"]); + expect(match.region).toBe("TestLan"); + } + }); + + it("moves already-drawn round 1 matches when the region changes afterwards", async () => { + const tournament = await tfx.createTournament(SE4); + await closeRegistration(tournament); + + const drawn = await roundMatches(tournament.stageIds[0], 1); + expect(drawn.map((match) => match.status)).toEqual([ + "Scheduled", + "Scheduled", + ]); + + await moveTournamentTo(tournament.id, "TestLan"); + await tfx.setStatus(tournament.id, tournament.organizer, "Live"); + + const matches = await roundMatches(tournament.stageIds[0], 1); + + expect(matches.length).toBe(2); + for (const match of matches) { + expect(match.regions).toEqual(["TestLan"]); + expect(match.region).toBe("TestLan"); + } + }); + + it("moves matches still waiting for check-in once the tournament is live", async () => { + const tournament = await tfx.createTournament(SE4); + await closeRegistration(tournament); + await tfx.setStatus(tournament.id, tournament.organizer, "Live"); + + await moveTournamentTo(tournament.id, "TestLan"); + + const matches = await roundMatches(tournament.stageIds[0], 1); + + expect(matches.map((match) => match.status)).toEqual([ + "WaitingForCheckIn", + "WaitingForCheckIn", + ]); + for (const match of matches) { + expect(match.regions).toEqual(["TestLan"]); + expect(match.region).toBe("TestLan"); + } + }); + + it("carries the new region through a stage that only customized tv_delay", async () => { + const tournament = await tfx.createTournament(SE4); + await giveStageOwnOptions(tournament.id, tournament.stageIds[0], { + tv_delay: 90, + }); + + await moveTournamentTo(tournament.id, "TestLan"); + await closeRegistration(tournament); + await tfx.setStatus(tournament.id, tournament.organizer, "Live"); + + const matches = await roundMatches(tournament.stageIds[0], 1); + + expect(matches.length).toBe(2); + for (const match of matches) { + expect(match.regions).toEqual(["TestLan"]); + expect(match.region).toBe("TestLan"); + expect(match.tv_delay).toBe(90); + } + }); + + it("carries the new region through such a stage after the draw too", async () => { + const tournament = await tfx.createTournament(SE4); + await giveStageOwnOptions(tournament.id, tournament.stageIds[0], { + tv_delay: 90, + }); + await closeRegistration(tournament); + + await moveTournamentTo(tournament.id, "TestLan"); + + const matches = await roundMatches(tournament.stageIds[0], 1); + + expect(matches.length).toBe(2); + for (const match of matches) { + expect(match.regions).toEqual(["TestLan"]); + expect(match.region).toBe("TestLan"); + expect(match.tv_delay).toBe(90); + } + }); + + it("leaves a stage on the region it deliberately overrode", async () => { + const tournament = await tfx.createTournament(SE4); + await giveStageOwnOptions(tournament.id, tournament.stageIds[0], { + regions: ["TestLan"], + }); + await closeRegistration(tournament); + + await moveTournamentTo(tournament.id, "TestB"); + + const matches = await roundMatches(tournament.stageIds[0], 1); + + expect(matches.length).toBe(2); + for (const match of matches) { + expect(match.regions).toEqual(["TestLan"]); + expect(match.region).toBe("TestLan"); + } + }); + + it("leaves a hand-edited match where the organizer put it", async () => { + const tournament = await tfx.createTournament(SE4); + await closeRegistration(tournament); + + const [edited] = await roundMatches(tournament.stageIds[0], 1); + await postgres.query( + `UPDATE match_options SET regions = '{TestLan}', region_veto = false WHERE id = $1`, + [edited.match_options_id], + ); + + await moveTournamentTo(tournament.id, "TestB"); + + const matches = await roundMatches(tournament.stageIds[0], 1); + + expect(matches.map((match) => match.region)).toEqual(["TestLan", "TestB"]); + }); + + it("does not reach back into finished matches", async () => { + const tournament = await tfx.createTournament(SE4); + await closeRegistration(tournament); + await tfx.setStatus(tournament.id, tournament.organizer, "Live"); + await tfx.playRound(tournament.stageIds[0], 1); + + await moveTournamentTo(tournament.id, "TestLan"); + + const played = await roundMatches(tournament.stageIds[0], 1); + const final = await roundMatches(tournament.stageIds[0], 2); + + for (const match of played) { + expect(match.regions).toEqual(["TestA"]); + } + expect(final.length).toBe(1); + expect(final[0].regions).toEqual(["TestLan"]); + expect(final[0].region).toBe("TestLan"); + }); +});