Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions hasura/functions/tournaments/sync_tournament_match_options.sql
Original file line number Diff line number Diff line change
@@ -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;
$$;
15 changes: 15 additions & 0 deletions hasura/triggers/match_options.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
$$;
Expand Down
267 changes: 267 additions & 0 deletions test/tournament-region-change.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>; tv_delay?: number },
) => {
const [options] = await postgres.query<Array<{ id: string }>>(
`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<string>;
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");
});
});
Loading