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
32 changes: 21 additions & 11 deletions hasura/functions/match/map-veto/get_map_veto_pattern.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ DECLARE
i INT;
pool_size INT;
_type TEXT;
pattern_matched BOOLEAN := false;
BEGIN
SELECT mo.best_of INTO best_of
FROM matches m
Expand All @@ -33,36 +34,45 @@ BEGIN
FOR i IN 1..(pool_size - 1) LOOP
base_pattern := array_append(base_pattern, 'Ban');
END LOOP;
base_pattern := array_append(base_pattern, 'Decider');
pattern_matched := true;
ELSIF pool_size = best_of THEN
FOR i IN 1..(pool_size - 1) LOOP
base_pattern := array_append(base_pattern, 'Pick');
END LOOP;
base_pattern := array_append(base_pattern, 'Decider');
pattern_matched := true;
ELSIF best_of = 3 THEN
IF pool_size = 4 THEN
base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Decider'];
base_pattern := ARRAY['Ban', 'Pick', 'Pick'];
ELSIF pool_size = 5 THEN
base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Ban', 'Decider'];
base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Ban'];
ELSIF pool_size = 6 THEN
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban', 'Decider'];
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban'];
ELSE
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban', 'Ban', 'Decider'];
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban', 'Ban'];
END IF;
pattern_matched := true;
ELSIF best_of = 5 THEN
if pool_size = 6 THEN
base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Pick', 'Pick', 'Decider'];
base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Pick', 'Pick'];
ELSE
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Pick', 'Pick', 'Decider'];
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Pick', 'Pick'];
END IF;
pattern_matched := true;
END IF;

IF pool_size > array_length(base_pattern, 1) THEN
FOR i IN 1..(pool_size - array_length(base_pattern, 1)) LOOP
-- Maps the pattern above doesn't account for become extra Bans, and they
-- MUST land before the Decider. get_map_veto_type reads this array
-- positionally, and the Decider is only ever auto-inserted by
-- create_match_map_from_veto once exactly one map is left, so any step
-- sitting after it can never be satisfied and the veto deadlocks.
IF pattern_matched THEN
FOR i IN 1..(pool_size - 1 - coalesce(array_length(base_pattern, 1), 0)) LOOP
base_pattern := array_append(base_pattern, 'Ban');
END LOOP;

base_pattern := array_append(base_pattern, 'Decider');
END IF;

FOR i IN 1..(pool_size) LOOP
_type := base_pattern[i];

Expand Down
18 changes: 18 additions & 0 deletions hasura/functions/match/options/cleanup_orphaned_match_options.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE OR REPLACE FUNCTION public.cleanup_orphaned_match_options(target_id uuid)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF target_id IS NULL THEN
RETURN;
END IF;

IF NOT EXISTS (SELECT 1 FROM public.matches WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournaments WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_stages WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_brackets WHERE match_options_id = target_id)
THEN
DELETE FROM public.match_options WHERE id = target_id;
END IF;
END;
$$;
7 changes: 4 additions & 3 deletions hasura/triggers/draft_games.sql
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ CREATE OR REPLACE FUNCTION public.tad_draft_games() RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.match_options_id IS NOT NULL THEN
DELETE FROM public.match_options WHERE id = OLD.match_options_id;
END IF;
-- Deleting unconditionally fails: tbd_matches removes the draft_games row
-- before the owning matches row is gone, and matches.match_options_id still
-- points at it (ON DELETE RESTRICT). tad_matches cleans up afterwards.
PERFORM cleanup_orphaned_match_options(OLD.match_options_id);
RETURN OLD;
END;
$$;
Expand Down
21 changes: 1 addition & 20 deletions hasura/triggers/match_options.sql
Original file line number Diff line number Diff line change
Expand Up @@ -163,23 +163,4 @@ END;
$$;

DROP TRIGGER IF EXISTS tad_match_options ON public.match_options;
CREATE TRIGGER tad_match_options AFTER DELETE ON public.match_options FOR EACH ROW EXECUTE FUNCTION public.tad_match_options();

CREATE OR REPLACE FUNCTION public.cleanup_orphaned_match_options(target_id uuid)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF target_id IS NULL THEN
RETURN;
END IF;

IF NOT EXISTS (SELECT 1 FROM public.matches WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournaments WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_stages WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_brackets WHERE match_options_id = target_id)
THEN
DELETE FROM public.match_options WHERE id = target_id;
END IF;
END;
$$;
CREATE TRIGGER tad_match_options AFTER DELETE ON public.match_options FOR EACH ROW EXECUTE FUNCTION public.tad_match_options();
3 changes: 1 addition & 2 deletions src/awards/awards.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Module } from "@nestjs/common";
import { AwardsService } from "./awards.service";
import { AwardsController } from "./awards.controller";
import { TrophiesLegacyController } from "./trophies-legacy.controller";
import { S3Module } from "../s3/s3.module";
import { PostgresModule } from "../postgres/postgres.module";
import { SystemModule } from "../system/system.module";
Expand All @@ -10,7 +9,7 @@ import { loggerFactory } from "../utilities/LoggerFactory";
@Module({
imports: [S3Module, PostgresModule, SystemModule],
providers: [AwardsService, loggerFactory()],
controllers: [AwardsController, TrophiesLegacyController],
controllers: [AwardsController],
exports: [AwardsService],
})
export class AwardsModule {}
5 changes: 3 additions & 2 deletions src/awards/awards.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,9 @@ export class AwardsService {
public async getStream(
filename: string,
): Promise<{ stream: Readable; contentType: string; etag?: string } | null> {
// Images uploaded before the awards rename still live under `trophies/`
// and their keys are stored verbatim in image_url.
// Images uploaded before the awards rename still live under `trophies/`:
// the migration renamed tables in place, so image_url keeps those keys
// verbatim. Callers pass a bare filename, so try both prefixes.
let key = `awards/${filename}`;

if (!(await this.s3.has(key))) {
Expand Down
33 changes: 0 additions & 33 deletions src/awards/trophies-legacy.controller.ts

This file was deleted.

171 changes: 171 additions & 0 deletions test/map-veto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ describe("map veto (SQL-driven)", () => {
[matchId, type, lineupId, mapId, side],
);

const patternFor = async (bestOf: number, poolSize: number) => {
const match = await createVetoMatch(bestOf, poolSize);
const [{ pattern }] = await postgres.query<Array<{ pattern: string[] }>>(
"SELECT get_map_veto_pattern(m) AS pattern FROM matches m WHERE id = $1",
[match.id],
);
return pattern;
};

it("computes the CS rulebook patterns", async () => {
const bo1 = await createVetoMatch(1, 3);
const [{ pattern: p1 }] = await postgres.query<
Expand Down Expand Up @@ -266,6 +275,168 @@ describe("map veto (SQL-driven)", () => {
expect(maps.length).toBe(0);
});

// Pools larger than the hardcoded rulebook patterns get their surplus maps
// banned. Those bans used to be appended AFTER the Decider, so once the
// rulebook steps ran out get_map_veto_type reported 'Decider' with several
// maps still unaccounted for: nothing could satisfy that step (the Decider is
// only ever auto-inserted once one map is left) and every BO3/BO5 veto on a
// pool larger than 7 hung there permanently.
describe("pools larger than the rulebook pattern", () => {
it.each([
[1, 8],
[1, 12],
[3, 8],
[3, 9],
[3, 12],
[5, 8],
[5, 9],
[5, 12],
])(
"BO%i pool %i: the pattern covers the whole pool and ends on the Decider",
async (bestOf, poolSize) => {
const pattern = await patternFor(bestOf, poolSize);

const bans = pattern.filter((type) => type === "Ban").length;
const picks = pattern.filter((type) => type === "Pick").length;
const sides = pattern.filter((type) => type === "Side").length;
const deciders = pattern.filter((type) => type === "Decider").length;

// Every map in the pool is consumed exactly once, and the maps that
// survive to be played are the picks plus the decider.
expect(bans + picks + deciders).toBe(poolSize);
expect(picks + deciders).toBe(bestOf);
expect(sides).toBe(picks);
expect(deciders).toBe(1);
expect(pattern[pattern.length - 1]).toBe("Decider");
},
);

it.each([
[3, 5, ["Ban", "Pick", "Side", "Pick", "Side", "Ban", "Decider"]],
[3, 6, ["Ban", "Ban", "Pick", "Side", "Pick", "Side", "Ban", "Decider"]],
[
3,
7,
[
"Ban",
"Ban",
"Pick",
"Side",
"Pick",
"Side",
"Ban",
"Ban",
"Decider",
],
],
[
5,
6,
[
"Ban",
"Pick",
"Side",
"Pick",
"Side",
"Pick",
"Side",
"Pick",
"Side",
"Decider",
],
],
[
5,
7,
[
"Ban",
"Ban",
"Pick",
"Side",
"Pick",
"Side",
"Pick",
"Side",
"Pick",
"Side",
"Decider",
],
],
])(
"BO%i pool %i is unchanged by the surplus-ban fix",
async (bestOf, poolSize, expected) => {
expect(await patternFor(bestOf as number, poolSize as number)).toEqual(
expected,
);
},
);

// Drives the veto by always submitting whatever the SQL reports as next,
// which is the actual proof a large pool completes: asserting on the
// pattern array alone would not have caught the original hang.
const runVetoToCompletion = async (bestOf: number, poolSize: number) => {
const match = await createVetoMatch(bestOf, poolSize);
const used = new Set<string>();
let lastPicked: string | null = null;

for (let step = 0; step <= poolSize * 2; step++) {
const state = await vetoState(match.id);
if (state.status !== "Veto") {
return match;
}

const remaining = match.mapIds.filter((id) => !used.has(id));
if (state.veto_type === "Decider") {
throw new Error(
`Decider requested with ${remaining.length} maps left — the veto cannot progress`,
);
}

if (state.veto_type === "Side") {
await insertPick(match.id, "Side", state.picking!, lastPicked!, "CT");
continue;
}

const mapId = remaining[0];
used.add(mapId);
if (state.veto_type === "Pick") {
lastPicked = mapId;
}
await insertPick(match.id, state.veto_type!, state.picking!, mapId);
}

throw new Error("veto never completed");
};

it.each([
[3, 12],
[5, 12],
])(
"BO%i pool %i runs to completion and goes Live",
async (bestOf, poolSize) => {
const match = await runVetoToCompletion(bestOf, poolSize);

expect((await vetoState(match.id)).status).toBe("Live");

const maps = await postgres.query<Array<{ id: string }>>(
"SELECT id FROM match_maps WHERE match_id = $1",
[match.id],
);
expect(maps.length).toBe(bestOf);

const picks = await postgres.query<Array<{ type: string }>>(
"SELECT type FROM match_map_veto_picks WHERE match_id = $1",
[match.id],
);
expect(picks.filter((p) => p.type === "Ban").length).toBe(
poolSize - bestOf,
);
expect(picks.filter((p) => p.type === "Pick").length).toBe(bestOf - 1);
expect(picks.filter((p) => p.type === "Decider").length).toBe(1);
},
);
});

it("cancelling a match mid-veto wipes its veto picks", async () => {
const match = await createVetoMatch(1, 3);
await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]);
Expand Down
Loading
Loading