diff --git a/hasura/functions/match/veto/auto_pick_expired_veto.sql b/hasura/functions/match/veto/auto_pick_expired_veto.sql new file mode 100644 index 00000000..14ae24d1 --- /dev/null +++ b/hasura/functions/match/veto/auto_pick_expired_veto.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION public.auto_pick_expired_veto( + _match_id uuid DEFAULT NULL, + _expected_pick_count int DEFAULT NULL +) RETURNS VOID + LANGUAGE plpgsql + AS $$ +DECLARE + _match matches; + _lineup_id uuid; + _pick_type text; + _map_id uuid; + _side text; + _region text; + _available_regions text[]; + _regions text[]; + _picked boolean; +BEGIN + -- FOR UPDATE SKIP LOCKED so a slow iteration never blocks (or gets blocked + -- by) a real player pick landing on the same match row concurrently. + FOR _match IN + SELECT * FROM matches + WHERE status = 'Veto' + AND veto_pick_expires_at IS NOT NULL + AND veto_pick_expires_at <= NOW() + AND (_match_id IS NULL OR id = _match_id) + FOR UPDATE SKIP LOCKED + LOOP + -- Fencing token: a timer armed for turn N must never act on turn N+1, + -- so a job whose cancellation was missed (or that fired while we were + -- behind on events) is inert rather than wrong. The sweep pass passes + -- NULL and leans on the expiry predicate above instead. + CONTINUE WHEN _expected_pick_count IS NOT NULL + AND veto_pick_count(_match.id) != _expected_pick_count; + + _picked := false; + + -- Subtransaction per match: verify_region_veto_pick and + -- get_map_veto_picking_lineup_id both RAISE on states we can legitimately + -- observe, and one bad match must not roll back the rest of the batch. + BEGIN + IF _match.region IS NULL THEN + _lineup_id := get_region_veto_picking_lineup_id(_match); + + IF _lineup_id IS NOT NULL THEN + _regions := sanitize_match_options_regions(_match.match_options_id); + + SELECT array_agg(r) INTO _available_regions + FROM unnest(_regions) AS r + WHERE NOT EXISTS ( + SELECT 1 FROM match_region_veto_picks mvp + WHERE mvp.match_id = _match.id AND lower(mvp.region) = lower(r) + ); + + -- Strictly greater than one: verify_region_veto_pick refuses to + -- ban the last available region, and auto_select_region_veto + -- locks that one in by itself. + IF COALESCE(array_length(_available_regions, 1), 0) > 1 THEN + _region := _available_regions[ + 1 + floor(random() * array_length(_available_regions, 1))::int + ]; + + INSERT INTO match_region_veto_picks + (match_id, type, match_lineup_id, region, auto_picked) + VALUES (_match.id, 'Ban', _lineup_id, _region, true); + + _picked := true; + END IF; + END IF; + ELSE + _pick_type := get_map_veto_type(_match); + + IF _pick_type IS NOT NULL THEN + _lineup_id := get_map_veto_picking_lineup_id(_match); + END IF; + + IF _pick_type IS NOT NULL AND _lineup_id IS NOT NULL THEN + IF _pick_type = 'Side' THEN + SELECT map_id INTO _map_id + FROM match_map_veto_picks + WHERE match_id = _match.id AND type = 'Pick' + ORDER BY created_at DESC + LIMIT 1; + + IF _map_id IS NOT NULL THEN + _side := CASE WHEN random() < 0.5 THEN 'CT' ELSE 'TERRORIST' END; + + INSERT INTO match_map_veto_picks + (match_id, type, match_lineup_id, map_id, side, auto_picked) + VALUES (_match.id, 'Side', _lineup_id, _map_id, _side, true); + + _picked := true; + END IF; + ELSE + SELECT mp.map_id INTO _map_id + FROM match_options mo + INNER JOIN _map_pool mp ON mp.map_pool_id = mo.map_pool_id + LEFT JOIN match_map_veto_picks mvp + ON mvp.match_id = _match.id AND mvp.map_id = mp.map_id + WHERE mo.id = _match.match_options_id AND mvp IS NULL + ORDER BY random() + LIMIT 1; + + IF _map_id IS NOT NULL THEN + INSERT INTO match_map_veto_picks + (match_id, type, match_lineup_id, map_id, auto_picked) + VALUES (_match.id, _pick_type, _lineup_id, _map_id, true); + + _picked := true; + END IF; + END IF; + END IF; + END IF; + EXCEPTION WHEN OTHERS THEN + _picked := false; + RAISE WARNING 'auto_pick_expired_veto failed for match %: %', _match.id, SQLERRM; + END; + + -- Nothing was inserted, so no AFTER INSERT trigger refreshed the deadline. + -- Clearing it is what stops this match being re-swept on every single pass + -- for the rest of its life. + IF NOT _picked THEN + UPDATE matches SET veto_pick_expires_at = NULL WHERE id = _match.id; + END IF; + END LOOP; +END; +$$; diff --git a/hasura/functions/match/veto/refresh_veto_pick_expiry.sql b/hasura/functions/match/veto/refresh_veto_pick_expiry.sql new file mode 100644 index 00000000..3a224b74 --- /dev/null +++ b/hasura/functions/match/veto/refresh_veto_pick_expiry.sql @@ -0,0 +1,23 @@ +CREATE OR REPLACE FUNCTION public.refresh_veto_pick_expiry(_match_id uuid) RETURNS VOID + LANGUAGE plpgsql + AS $$ +DECLARE + _timeout int; +BEGIN + SELECT mo.veto_pick_timeout INTO _timeout + FROM matches m + INNER JOIN match_options mo ON mo.id = m.match_options_id + WHERE m.id = _match_id; + + -- Guarded on status so this only bumps while veto is actually running: + -- create_match_map_from_veto / auto_select_region_veto may have already + -- flipped the match to Live in this same statement, in which case the + -- timer is meaningless and tbu_matches has already nulled it. + UPDATE matches + SET veto_pick_expires_at = CASE + WHEN COALESCE(_timeout, 0) > 0 THEN NOW() + (_timeout || ' seconds')::interval + ELSE NULL + END + WHERE id = _match_id AND status = 'Veto'; +END; +$$; diff --git a/hasura/functions/match/veto/veto_pick_count.sql b/hasura/functions/match/veto/veto_pick_count.sql new file mode 100644 index 00000000..511abe36 --- /dev/null +++ b/hasura/functions/match/veto/veto_pick_count.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE FUNCTION public.veto_pick_count(_match_id uuid) RETURNS int + LANGUAGE sql STABLE + AS $$ + SELECT (SELECT COUNT(*) FROM match_region_veto_picks WHERE match_id = _match_id)::int + + (SELECT COUNT(*) FROM match_map_veto_picks WHERE match_id = _match_id)::int; +$$; diff --git a/hasura/functions/tournaments/clone_match_options.sql b/hasura/functions/tournaments/clone_match_options.sql index eeb8833c..8de8324f 100644 --- a/hasura/functions/tournaments/clone_match_options.sql +++ b/hasura/functions/tournaments/clone_match_options.sql @@ -14,14 +14,16 @@ BEGIN map_veto, timeout_setting, tech_timeout_setting, map_pool_id, type, regions, prefer_dedicated_server, invite_code, region_veto, ready_setting, check_in_setting, default_models, tv_delay, - auto_cancellation, match_mode, auto_cancel_duration, live_match_timeout + auto_cancellation, match_mode, auto_cancel_duration, live_match_timeout, + veto_pick_timeout ) SELECT overtime, knife_round, mr, best_of, coaches, number_of_substitutes, map_veto, timeout_setting, tech_timeout_setting, map_pool_id, type, regions, prefer_dedicated_server, invite_code, region_veto, ready_setting, check_in_setting, default_models, tv_delay, - auto_cancellation, match_mode, auto_cancel_duration, live_match_timeout + auto_cancellation, match_mode, auto_cancel_duration, live_match_timeout, + veto_pick_timeout FROM match_options WHERE id = _match_options_id RETURNING id INTO cloned_id; diff --git a/hasura/functions/tournaments/update_match_options_best_of.sql b/hasura/functions/tournaments/update_match_options_best_of.sql index 7e5caf27..82c418fc 100644 --- a/hasura/functions/tournaments/update_match_options_best_of.sql +++ b/hasura/functions/tournaments/update_match_options_best_of.sql @@ -51,7 +51,8 @@ BEGIN overtime, knife_round, mr, best_of, coaches, number_of_substitutes, map_veto, timeout_setting, tech_timeout_setting, map_pool_id, type, regions, prefer_dedicated_server, invite_code, - region_veto, ready_setting, check_in_setting, default_models, tv_delay + region_veto, ready_setting, check_in_setting, default_models, tv_delay, + veto_pick_timeout ) VALUES ( match_options_record.overtime, match_options_record.knife_round, match_options_record.mr, match_options_record.best_of, @@ -62,7 +63,8 @@ BEGIN match_options_record.prefer_dedicated_server, match_options_record.invite_code, match_options_record.region_veto, match_options_record.ready_setting, match_options_record.check_in_setting, - match_options_record.default_models, match_options_record.tv_delay + match_options_record.default_models, match_options_record.tv_delay, + match_options_record.veto_pick_timeout ) RETURNING id INTO final_match_options_id; diff --git a/hasura/metadata/databases/default/tables/public_match_map_veto_picks.yaml b/hasura/metadata/databases/default/tables/public_match_map_veto_picks.yaml index 306d704f..878395db 100644 --- a/hasura/metadata/databases/default/tables/public_match_map_veto_picks.yaml +++ b/hasura/metadata/databases/default/tables/public_match_map_veto_picks.yaml @@ -61,6 +61,7 @@ select_permissions: columns: - side - type + - auto_picked - created_at - id - map_id diff --git a/hasura/metadata/databases/default/tables/public_match_options.yaml b/hasura/metadata/databases/default/tables/public_match_options.yaml index 200e9d9b..e11d3663 100644 --- a/hasura/metadata/databases/default/tables/public_match_options.yaml +++ b/hasura/metadata/databases/default/tables/public_match_options.yaml @@ -70,6 +70,7 @@ insert_permissions: - live_match_timeout - auto_cancellation - tv_delay + - veto_pick_timeout - type - halftime_pausematch - round_restart_delay @@ -94,6 +95,7 @@ insert_permissions: - tech_timeout_setting - timeout_setting - tv_delay + - veto_pick_timeout - type - halftime_pausematch - round_restart_delay @@ -124,6 +126,7 @@ select_permissions: - live_match_timeout - auto_cancellation - tv_delay + - veto_pick_timeout - type - halftime_pausematch - round_restart_delay @@ -154,6 +157,7 @@ update_permissions: - live_match_timeout - auto_cancellation - tv_delay + - veto_pick_timeout - type - halftime_pausematch - round_restart_delay @@ -211,6 +215,7 @@ update_permissions: - tech_timeout_setting - timeout_setting - tv_delay + - veto_pick_timeout - type - halftime_pausematch - round_restart_delay diff --git a/hasura/metadata/databases/default/tables/public_match_region_veto_picks.yaml b/hasura/metadata/databases/default/tables/public_match_region_veto_picks.yaml index b7901e56..7fe78e2e 100644 --- a/hasura/metadata/databases/default/tables/public_match_region_veto_picks.yaml +++ b/hasura/metadata/databases/default/tables/public_match_region_veto_picks.yaml @@ -54,6 +54,7 @@ select_permissions: columns: - region - type + - auto_picked - created_at - id - match_id diff --git a/hasura/metadata/databases/default/tables/public_matches.yaml b/hasura/metadata/databases/default/tables/public_matches.yaml index d8a1f237..4be18eb5 100644 --- a/hasura/metadata/databases/default/tables/public_matches.yaml +++ b/hasura/metadata/databases/default/tables/public_matches.yaml @@ -540,6 +540,7 @@ select_permissions: - source - started_at - status + - veto_pick_expires_at - winning_lineup_id computed_fields: - can_assign_server @@ -600,6 +601,7 @@ select_permissions: - source - started_at - status + - veto_pick_expires_at - winning_lineup_id computed_fields: - can_assign_server diff --git a/hasura/migrations/default/1876000000200_veto_pick_timeout/down.sql b/hasura/migrations/default/1876000000200_veto_pick_timeout/down.sql new file mode 100644 index 00000000..682fde65 --- /dev/null +++ b/hasura/migrations/default/1876000000200_veto_pick_timeout/down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS public.matches_veto_pick_expires_at_idx; + +ALTER TABLE public.match_region_veto_picks DROP COLUMN IF EXISTS auto_picked; + +ALTER TABLE public.match_map_veto_picks DROP COLUMN IF EXISTS auto_picked; + +ALTER TABLE public.matches DROP COLUMN IF EXISTS veto_pick_expires_at; + +ALTER TABLE public.match_options + DROP CONSTRAINT IF EXISTS match_options_veto_pick_timeout_check; + +ALTER TABLE public.match_options DROP COLUMN IF EXISTS veto_pick_timeout; diff --git a/hasura/migrations/default/1876000000200_veto_pick_timeout/up.sql b/hasura/migrations/default/1876000000200_veto_pick_timeout/up.sql new file mode 100644 index 00000000..7b973fd4 --- /dev/null +++ b/hasura/migrations/default/1876000000200_veto_pick_timeout/up.sql @@ -0,0 +1,23 @@ +ALTER TABLE public.match_options + ADD COLUMN IF NOT EXISTS veto_pick_timeout integer NOT NULL DEFAULT 60; + +-- 0 is the disable value, so this is >= 0 rather than the > 0 used by +-- auto_cancel_duration / live_match_timeout. +ALTER TABLE public.match_options + DROP CONSTRAINT IF EXISTS match_options_veto_pick_timeout_check; + +ALTER TABLE public.match_options + ADD CONSTRAINT match_options_veto_pick_timeout_check CHECK (veto_pick_timeout >= 0); + +ALTER TABLE public.matches + ADD COLUMN IF NOT EXISTS veto_pick_expires_at timestamptz; + +ALTER TABLE public.match_map_veto_picks + ADD COLUMN IF NOT EXISTS auto_picked boolean NOT NULL DEFAULT false; + +ALTER TABLE public.match_region_veto_picks + ADD COLUMN IF NOT EXISTS auto_picked boolean NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS matches_veto_pick_expires_at_idx + ON public.matches (veto_pick_expires_at) + WHERE veto_pick_expires_at IS NOT NULL; diff --git a/hasura/triggers/match_map_veto_picks.sql b/hasura/triggers/match_map_veto_picks.sql index ce828704..88ea1eee 100644 --- a/hasura/triggers/match_map_veto_picks.sql +++ b/hasura/triggers/match_map_veto_picks.sql @@ -4,6 +4,7 @@ LANGUAGE plpgsql AS $$ BEGIN PERFORM create_match_map_from_veto(NEW); + PERFORM refresh_veto_pick_expiry(NEW.match_id); RETURN NEW; END; $$; diff --git a/hasura/triggers/match_options.sql b/hasura/triggers/match_options.sql index 88ed9641..15a0533b 100644 --- a/hasura/triggers/match_options.sql +++ b/hasura/triggers/match_options.sql @@ -125,6 +125,12 @@ BEGIN PERFORM setup_match_maps(_match_id, NEW.id); END IF; + -- Retiming mid-veto: the resulting matches update re-fires match_events, + -- which re-arms the delayed job against the new deadline. + IF (NEW.veto_pick_timeout IS DISTINCT FROM OLD.veto_pick_timeout AND _match_status = 'Veto') THEN + PERFORM refresh_veto_pick_expiry(_match_id); + END IF; + RETURN NEW; END; $$; diff --git a/hasura/triggers/match_region_veto_picks.sql b/hasura/triggers/match_region_veto_picks.sql index a4a74fbf..74e07c55 100644 --- a/hasura/triggers/match_region_veto_picks.sql +++ b/hasura/triggers/match_region_veto_picks.sql @@ -4,6 +4,7 @@ LANGUAGE plpgsql AS $$ BEGIN PERFORM auto_select_region_veto(NEW); + PERFORM refresh_veto_pick_expiry(NEW.match_id); RETURN NEW; END; $$; diff --git a/hasura/triggers/matches.sql b/hasura/triggers/matches.sql index b0b592d3..181ddd67 100644 --- a/hasura/triggers/matches.sql +++ b/hasura/triggers/matches.sql @@ -284,6 +284,7 @@ DECLARE _match_options match_options%ROWTYPE; _auto_cancellation boolean; _auto_cancel_duration_override integer; + _veto_pick_timeout integer; BEGIN SELECT auto_cancellation, auto_cancel_duration INTO _auto_cancellation, _auto_cancel_duration_override FROM resolve_match_auto_cancel(NEW.id); _auto_cancel_duration := COALESCE(_auto_cancel_duration_override, get_int_setting('auto_cancel_duration', 15))::text || ' minutes'; @@ -417,16 +418,30 @@ BEGIN NEW.cancels_at = COALESCE(scheduled_at, NOW()) + (_auto_cancel_duration)::interval; END IF; NEW.ended_at = null; + + -- Arm the first veto turn. Set here rather than via + -- refresh_veto_pick_expiry because this is a BEFORE trigger. + SELECT veto_pick_timeout INTO _veto_pick_timeout + FROM match_options + WHERE id = NEW.match_options_id; + + IF COALESCE(_veto_pick_timeout, 0) > 0 THEN + NEW.veto_pick_expires_at = NOW() + (_veto_pick_timeout || ' seconds')::interval; + ELSE + NEW.veto_pick_expires_at = null; + END IF; END IF; IF NEW.status = 'WaitingForServer' AND OLD.status != 'WaitingForServer' THEN NEW.cancels_at = null; NEW.ended_at = null; + NEW.veto_pick_expires_at = null; END IF; IF (NEW.status = 'Canceled' AND OLD.status != 'Canceled') THEN NEW.cancels_at = NOW(); NEW.ended_at = null; + NEW.veto_pick_expires_at = null; DELETE FROM match_region_veto_picks WHERE match_id = NEW.id; DELETE FROM match_map_veto_picks WHERE match_id = NEW.id; @@ -436,6 +451,7 @@ BEGIN NEW.started_at = NOW(); NEW.cancels_at = null; NEW.ended_at = null; + NEW.veto_pick_expires_at = null; END IF; IF @@ -445,6 +461,7 @@ BEGIN THEN NEW.ended_at = NOW(); NEW.cancels_at = null; + NEW.veto_pick_expires_at = null; END IF; PERFORM check_match_status(NEW); diff --git a/src/draft-games/draft-game.service.ts b/src/draft-games/draft-game.service.ts index d7542b75..9c938999 100644 --- a/src/draft-games/draft-game.service.ts +++ b/src/draft-games/draft-game.service.ts @@ -1028,6 +1028,7 @@ export class DraftGameService { ready_setting: source.ready_setting, tech_timeout_setting: source.tech_timeout_setting, tv_delay: source.tv_delay, + veto_pick_timeout: source.veto_pick_timeout, }; if (source.map_pool_id) { diff --git a/src/hasura/hasura.service.ts b/src/hasura/hasura.service.ts index 4cd01b68..c6e1ad53 100644 --- a/src/hasura/hasura.service.ts +++ b/src/hasura/hasura.service.ts @@ -160,6 +160,13 @@ export class HasuraService { await this.postgresService.query( "insert into settings (name, value) values ('public.grant_awards_role', 'administrator') on conflict (name) do nothing", ); + + // Seeds the platform default new match_options rows inherit. `do nothing` + // preserves an operator's explicit value (including 0, which disables the + // veto timer entirely). + await this.postgresService.query( + "insert into settings (name, value) values ('public.veto_pick_timeout', '60') on conflict (name) do nothing", + ); } private async applyMigrations(path: string): Promise { diff --git a/src/matches/enums/MatchJobs.ts b/src/matches/enums/MatchJobs.ts index 1b397a81..e412b0da 100644 --- a/src/matches/enums/MatchJobs.ts +++ b/src/matches/enums/MatchJobs.ts @@ -1,3 +1,4 @@ export const MatchJobs = { CheckOnDemandServerJob: `CheckOnDemandServerJob`, + AutoPickExpiredVeto: `AutoPickExpiredVeto`, } as const; diff --git a/src/matches/jobs/AutoPickExpiredVeto.ts b/src/matches/jobs/AutoPickExpiredVeto.ts new file mode 100644 index 00000000..062516ab --- /dev/null +++ b/src/matches/jobs/AutoPickExpiredVeto.ts @@ -0,0 +1,26 @@ +import { Job } from "bullmq"; +import { WorkerHost } from "@nestjs/bullmq"; +import { MatchQueues } from "../enums/MatchQueues"; +import { UseQueue } from "../../utilities/QueueProcessors"; +import { PostgresService } from "../../postgres/postgres.service"; + +@UseQueue("Matches", MatchQueues.ScheduledMatches) +export class AutoPickExpiredVeto extends WorkerHost { + constructor(private readonly postgres: PostgresService) { + super(); + } + + async process( + job: Job<{ matchId?: string; pickCount?: number } | undefined>, + ): Promise { + const { matchId = null, pickCount = null } = job.data ?? {}; + + // Both null is the fallback sweep: no fencing token, so it acts on any + // expired veto whose per-match timer was lost. Casts are required — + // Postgres cannot infer a parameter type from an untyped NULL. + await this.postgres.query( + "SELECT auto_pick_expired_veto($1::uuid, $2::int);", + [matchId, pickCount], + ); + } +} diff --git a/src/matches/match-assistant/match-assistant.service.spec.ts b/src/matches/match-assistant/match-assistant.service.spec.ts index 9f130984..795caee6 100644 --- a/src/matches/match-assistant/match-assistant.service.spec.ts +++ b/src/matches/match-assistant/match-assistant.service.spec.ts @@ -19,6 +19,10 @@ describe("MatchAssistantService", () => { let queue: { add: jest.Mock; }; + let scheduledMatchesQueue: { + add: jest.Mock; + getDelayed: jest.Mock; + }; beforeEach(() => { hasura = { @@ -31,6 +35,10 @@ describe("MatchAssistantService", () => { queue = { add: jest.fn(), }; + scheduledMatchesQueue = { + add: jest.fn(), + getDelayed: jest.fn(async (): Promise => []), + }; service = new MatchAssistantService( { @@ -64,6 +72,7 @@ describe("MatchAssistantService", () => { resolvePluginRuntime: jest.fn(async () => "swiftlys2"), } as any, queue as any, + scheduledMatchesQueue as any, ); }); diff --git a/src/matches/match-assistant/match-assistant.service.ts b/src/matches/match-assistant/match-assistant.service.ts index a45faf59..53e2f398 100644 --- a/src/matches/match-assistant/match-assistant.service.ts +++ b/src/matches/match-assistant/match-assistant.service.ts @@ -60,6 +60,8 @@ export class MatchAssistantService { private readonly loggingService: LoggingService, private readonly pluginRuntimeService: PluginRuntimeService, @InjectQueue(MatchQueues.MatchServers) private queue: Queue, + @InjectQueue(MatchQueues.ScheduledMatches) + private scheduledMatchesQueue: Queue, ) { this.appConfig = this.config.get("app"); this.gameServerConfig = this.config.get("gameServers"); @@ -1170,6 +1172,93 @@ export class MatchAssistantService { }); } + public static VetoPickJobPrefix(matchId: string) { + return `match.${matchId}.veto-pick.`; + } + + /** + * Mirrors matches.veto_pick_expires_at (which Postgres owns) onto a delayed + * job that fires at exactly that moment. Passing null just cancels. + */ + public async scheduleVetoPickTimeout( + matchId: string, + expiresAt: string | null, + ) { + await this.removeVetoPickTimeout(matchId); + + if (!expiresAt) { + return; + } + + const pickCount = await this.getVetoPickCount(matchId); + + await this.scheduledMatchesQueue.add( + MatchJobs.AutoPickExpiredVeto, + { + matchId, + pickCount, + }, + { + // Floored at 0 so an event we picked up late fires immediately rather + // than being rejected for a negative delay. + delay: Math.max(0, new Date(expiresAt).getTime() - Date.now()), + attempts: 1, + removeOnFail: true, + removeOnComplete: true, + jobId: `${MatchAssistantService.VetoPickJobPrefix(matchId)}${pickCount}`, + }, + ); + } + + public async removeVetoPickTimeout(matchId: string) { + const prefix = MatchAssistantService.VetoPickJobPrefix(matchId); + + try { + const delayed = await this.scheduledMatchesQueue.getDelayed(); + + for (const job of delayed) { + if (job.id?.startsWith(prefix)) { + await job.remove(); + } + } + } catch { + this.logger.debug(`[${matchId}] no veto pick timers to remove`); + } + } + + private async getVetoPickCount(matchId: string) { + const { + match_map_veto_picks_aggregate, + match_region_veto_picks_aggregate, + } = await this.hasura.query({ + match_map_veto_picks_aggregate: { + __args: { + where: { + match_id: { _eq: matchId }, + }, + }, + aggregate: { + count: true, + }, + }, + match_region_veto_picks_aggregate: { + __args: { + where: { + match_id: { _eq: matchId }, + }, + }, + aggregate: { + count: true, + }, + }, + }); + + return ( + (match_map_veto_picks_aggregate?.aggregate?.count ?? 0) + + (match_region_veto_picks_aggregate?.aggregate?.count ?? 0) + ); + } + public async delayCheckOnDemandServer(matchId: string) { await this.queue.add( MatchJobs.CheckOnDemandServerJob, diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 0aa7ceae..3024f9a3 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -69,6 +69,16 @@ export class MatchesController { private static readonly BLOCKING_RESET_STATUSES: string[] = ["Live", "Veto"]; + // The event payload carries every matches column, but the generated + // matches_set_input only regains veto_pick_expires_at once codegen is re-run + // against the migrated schema. + private static vetoPickExpiresAt(row: object): string | null { + return ( + (row as { veto_pick_expires_at?: string | null }).veto_pick_expires_at ?? + null + ); + } + constructor( private readonly logger: Logger, private readonly hasura: HasuraService, @@ -512,6 +522,22 @@ export class MatchesController { void this.notifications.sendMatchWaitingForServerNotification(matchId); } + // Postgres owns the deadline; this mirrors every change to it onto the + // delayed job. Entering Veto arms it, each pick re-arms it, and leaving + // Veto nulls the column, which is what cancels. + if (data.op === "DELETE") { + await this.matchAssistant.removeVetoPickTimeout(matchId); + } else if (data.op === "UPDATE") { + const vetoPickExpiresAt = MatchesController.vetoPickExpiresAt(data.new); + + if (vetoPickExpiresAt !== MatchesController.vetoPickExpiresAt(data.old)) { + await this.matchAssistant.scheduleVetoPickTimeout( + matchId, + vetoPickExpiresAt, + ); + } + } + if ( data.op === "UPDATE" && MatchesController.PLAYED_TERMINAL_STATUSES.includes(status) && diff --git a/src/matches/matches.module.ts b/src/matches/matches.module.ts index 4d47ff73..f22e14de 100644 --- a/src/matches/matches.module.ts +++ b/src/matches/matches.module.ts @@ -31,6 +31,7 @@ import { MatchServerMiddlewareMiddleware } from "./match-server-middleware/match import { Queue } from "bullmq"; import { CheckForScheduledMatches } from "./jobs/CheckForScheduledMatches"; import { CancelExpiredMatches } from "./jobs/CancelExpiredMatches"; +import { AutoPickExpiredVeto } from "./jobs/AutoPickExpiredVeto"; import { RemoveCancelledMatches } from "./jobs/RemoveCancelledMatches"; import { CheckForTournamentStart } from "./jobs/CheckForTournamentStart"; import { CheckForScheduledTournamentBrackets } from "./jobs/CheckForScheduledTournamentBrackets"; @@ -157,6 +158,7 @@ import { LeaguesModule } from "../leagues/leagues.module"; CheckOnDemandServerJob, CheckOnDemandServerJobEvents, CancelExpiredMatches, + AutoPickExpiredVeto, CheckForTournamentStart, CheckForScheduledTournamentBrackets, CheckLeagueSeasonTransitions, @@ -261,6 +263,20 @@ export class MatchesModule implements NestModule { }, ); + // Fallback only. The real timer is a per-match delayed job armed from + // match_events; this catches vetoes whose job was lost to a Redis flush or + // a dropped event. Empty payload means no fencing token, so it sweeps + // anything already past its deadline. + void scheduleMatchQueue.add( + AutoPickExpiredVeto.name, + {}, + { + repeat: { + pattern: "* * * * *", + }, + }, + ); + void matchServersQueue.add( CheckForTournamentStart.name, {}, diff --git a/src/system/enums/SystemSettingName.ts b/src/system/enums/SystemSettingName.ts index a8726966..75cf8a4d 100644 --- a/src/system/enums/SystemSettingName.ts +++ b/src/system/enums/SystemSettingName.ts @@ -3,6 +3,7 @@ export enum SystemSettingName { ChatMessageTtl = "chat_message_ttl", DemoNetworkLimiter = "demo_network_limiter", PublicDefaultModels = "public.default_models", + VetoPickTimeout = "public.veto_pick_timeout", SupportsDiscordBot = "public.supports_discord_bot", SupportsGameServerNodes = "supports_game_server_nodes", SupportsGameServerVersionPinning = "supports_game_server_version_pinning", diff --git a/src/system/system.service.ts b/src/system/system.service.ts index f7cef6fb..b2af6c0b 100644 --- a/src/system/system.service.ts +++ b/src/system/system.service.ts @@ -646,6 +646,23 @@ export class SystemService { `ALTER TABLE "public"."match_options" ALTER COLUMN "default_models" SET DEFAULT ${setting.value === "true" ? true : false}`, ); break; + case SystemSettingName.VetoPickTimeout: { + // Interpolated into DDL, which cannot take a bind parameter, so the + // value has to be proven to be a plain non-negative integer first. + const vetoPickTimeout = Number.parseInt(setting.value, 10); + + if (Number.isNaN(vetoPickTimeout) || vetoPickTimeout < 0) { + this.logger.warn( + `ignoring invalid ${SystemSettingName.VetoPickTimeout} setting: ${setting.value}`, + ); + break; + } + + await this.postgres.query( + `ALTER TABLE "public"."match_options" ALTER COLUMN "veto_pick_timeout" SET DEFAULT ${vetoPickTimeout}`, + ); + break; + } default: break; } diff --git a/test/utils/fixtures.ts b/test/utils/fixtures.ts index 4bf157a7..7eb84f32 100644 --- a/test/utils/fixtures.ts +++ b/test/utils/fixtures.ts @@ -19,6 +19,7 @@ export type MatchOptionsOverrides = { regions?: Array; mapPoolId?: string; substitutes?: number; + vetoPickTimeout?: number; }; export type MatchRow = { @@ -135,8 +136,9 @@ export class Fixtures { )); const [row] = await this.postgres.query>( `INSERT INTO match_options - (mr, best_of, type, map_pool_id, map_veto, region_veto, regions, number_of_substitutes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + (mr, best_of, type, map_pool_id, map_veto, region_veto, regions, number_of_substitutes, + veto_pick_timeout) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, [ over.mr ?? 12, over.bestOf ?? 1, @@ -146,6 +148,7 @@ export class Fixtures { over.regionVeto ?? true, over.regions ?? ["TestA"], over.substitutes ?? 0, + over.vetoPickTimeout ?? 60, ], ); return row.id; diff --git a/test/veto-timeout.spec.ts b/test/veto-timeout.spec.ts new file mode 100644 index 00000000..a1eec027 --- /dev/null +++ b/test/veto-timeout.spec.ts @@ -0,0 +1,432 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the veto pick timer SQL: matches.veto_pick_expires_at lifecycle +// (armed on entering Veto, refreshed by every pick, cleared on the way out), +// match_options.veto_pick_timeout as the per-match duration with 0 disabling it, +// and auto_pick_expired_veto's fencing token / per-match error isolation. +describe("veto pick timeout (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("VetoTimeoutTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + await seedRegionWithServer(postgres, "TestA", 27015); + await seedRegionWithServer(postgres, "TestB", 27016); + await seedRegionWithServer(postgres, "TestC", 27017); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("UPDATE servers SET enabled = true"); + }); + + // A map-veto match sitting in Veto. Single region so the region veto is + // already resolved and only map picks are outstanding. + const createMapVetoMatch = async ( + poolSize: number, + { bestOf = 1, vetoPickTimeout = 60 } = {}, + ) => { + const { poolId, mapIds } = await fx.mapPool(poolSize); + const match = await fx.match({ + bestOf, + mapVeto: true, + mapPoolId: poolId, + vetoPickTimeout, + }); + // tbu_matches redirects Live to Veto while maps are missing. + await postgres.query("UPDATE matches SET status = 'Live' WHERE id = $1", [ + match.id, + ]); + return { ...match, mapIds }; + }; + + const createRegionVetoMatch = async ({ vetoPickTimeout = 60 } = {}) => { + const { poolId } = await fx.mapPool(1); + const match = await fx.match({ + regions: ["TestA", "TestB", "TestC"], + mapVeto: false, + mapPoolId: poolId, + vetoPickTimeout, + }); + await postgres.query("UPDATE matches SET status = 'Veto' WHERE id = $1", [ + match.id, + ]); + return match; + }; + + const matchRow = async (id: string) => { + const [row] = await postgres.query< + Array<{ + status: string; + region: string | null; + veto_pick_expires_at: Date | null; + }> + >( + "SELECT status, region, veto_pick_expires_at FROM matches WHERE id = $1", + [id], + ); + return row; + }; + + const expire = (id: string) => + postgres.query( + "UPDATE matches SET veto_pick_expires_at = NOW() - interval '1 second' WHERE id = $1", + [id], + ); + + const autoPick = ( + id: string | null = null, + pickCount: number | null = null, + ) => + postgres.query("SELECT auto_pick_expired_veto($1::uuid, $2::int)", [ + id, + pickCount, + ]); + + const mapPicks = (id: string) => + postgres.query< + Array<{ type: string; map_id: string; auto_picked: boolean }> + >( + `SELECT type, map_id, auto_picked FROM match_map_veto_picks + WHERE match_id = $1 ORDER BY created_at ASC`, + [id], + ); + + const regionPicks = (id: string) => + postgres.query< + Array<{ type: string; region: string; auto_picked: boolean }> + >( + `SELECT type, region, auto_picked FROM match_region_veto_picks + WHERE match_id = $1 ORDER BY created_at ASC`, + [id], + ); + + const secondsUntil = (at: Date | null) => + at === null ? null : (at.getTime() - Date.now()) / 1000; + + describe("deadline lifecycle", () => { + it("arms the deadline from the match's own timeout when entering Veto", async () => { + const match = await createMapVetoMatch(3, { vetoPickTimeout: 45 }); + const row = await matchRow(match.id); + + expect(row.status).toBe("Veto"); + expect(secondsUntil(row.veto_pick_expires_at)).toBeGreaterThan(40); + expect(secondsUntil(row.veto_pick_expires_at)).toBeLessThanOrEqual(45); + }); + + it("leaves the deadline null when the timeout is 0", async () => { + const match = await createMapVetoMatch(3, { vetoPickTimeout: 0 }); + const row = await matchRow(match.id); + + expect(row.status).toBe("Veto"); + expect(row.veto_pick_expires_at).toBeNull(); + }); + + it("does not auto-pick a match whose timer is disabled", async () => { + const match = await createMapVetoMatch(3, { vetoPickTimeout: 0 }); + + // Even a full sweep can't see it: there is no deadline to expire. + await autoPick(); + + expect(await mapPicks(match.id)).toHaveLength(0); + expect((await matchRow(match.id)).status).toBe("Veto"); + }); + + it("pushes the deadline forward on every pick", async () => { + const match = await createMapVetoMatch(4, { bestOf: 1 }); + const before = await matchRow(match.id); + + // Wind it down so a refresh is unambiguous. + await postgres.query( + "UPDATE matches SET veto_pick_expires_at = NOW() + interval '5 seconds' WHERE id = $1", + [match.id], + ); + + await postgres.query( + `INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id) + VALUES ($1, 'Ban', $2, $3)`, + [match.id, match.lineup_1_id, match.mapIds[0]], + ); + + const after = await matchRow(match.id); + expect(secondsUntil(after.veto_pick_expires_at)).toBeGreaterThan(50); + expect(after.veto_pick_expires_at!.getTime()).toBeGreaterThan( + before.veto_pick_expires_at!.getTime() - 1000, + ); + }); + + it("clears the deadline when the veto completes and the match goes Live", async () => { + const match = await createMapVetoMatch(2, { bestOf: 1 }); + + // One ban leaves a single map, so create_match_map_from_veto inserts the + // Decider and flips the match to Live. + await postgres.query( + `INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id) + VALUES ($1, 'Ban', $2, $3)`, + [match.id, match.lineup_1_id, match.mapIds[0]], + ); + + const row = await matchRow(match.id); + expect(row.status).toBe("Live"); + expect(row.veto_pick_expires_at).toBeNull(); + }); + + it("clears the deadline when the match is canceled", async () => { + const match = await createMapVetoMatch(3); + + await postgres.query( + "UPDATE matches SET status = 'Canceled' WHERE id = $1", + [match.id], + ); + + expect((await matchRow(match.id)).veto_pick_expires_at).toBeNull(); + }); + + it("reschedules when the timeout is edited mid-veto", async () => { + const match = await createMapVetoMatch(3, { vetoPickTimeout: 30 }); + + await postgres.query( + "UPDATE match_options SET veto_pick_timeout = 200 WHERE id = $1", + [match.options_id], + ); + + const seconds = secondsUntil( + (await matchRow(match.id)).veto_pick_expires_at, + ); + expect(seconds).toBeGreaterThan(190); + expect(seconds).toBeLessThanOrEqual(200); + }); + + it("drops the deadline when the timeout is disabled mid-veto", async () => { + const match = await createMapVetoMatch(3, { vetoPickTimeout: 30 }); + + await postgres.query( + "UPDATE match_options SET veto_pick_timeout = 0 WHERE id = $1", + [match.options_id], + ); + + expect((await matchRow(match.id)).veto_pick_expires_at).toBeNull(); + }); + }); + + describe("auto picking", () => { + it("bans a region for the lineup that ran out of time", async () => { + const match = await createRegionVetoMatch(); + await expire(match.id); + + await autoPick(match.id); + + const picks = await regionPicks(match.id); + expect(picks).toHaveLength(1); + expect(picks[0].type).toBe("Ban"); + expect(picks[0].auto_picked).toBe(true); + expect(["TestA", "TestB", "TestC"]).toContain(picks[0].region); + + // The insert trigger re-armed the timer for the other lineup. + expect( + secondsUntil((await matchRow(match.id)).veto_pick_expires_at), + ).toBeGreaterThan(50); + }); + + it("picks the correct veto type for the pattern position", async () => { + const match = await createMapVetoMatch(4, { bestOf: 1 }); + await expire(match.id); + + await autoPick(match.id); + + const picks = await mapPicks(match.id); + expect(picks).toHaveLength(1); + // Bo1 over a 4 map pool is Ban, Ban, Ban, Decider. + expect(picks[0].type).toBe("Ban"); + expect(picks[0].auto_picked).toBe(true); + }); + + it("marks only the auto picks, not the human ones", async () => { + const match = await createMapVetoMatch(4, { bestOf: 1 }); + + await postgres.query( + `INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id) + VALUES ($1, 'Ban', $2, $3)`, + [match.id, match.lineup_1_id, match.mapIds[0]], + ); + await expire(match.id); + await autoPick(match.id); + + const picks = await mapPicks(match.id); + expect(picks).toHaveLength(2); + expect(picks[0].auto_picked).toBe(false); + expect(picks[1].auto_picked).toBe(true); + }); + + it("drives a whole Bo1 to Live on auto picks alone", async () => { + const match = await createMapVetoMatch(4, { bestOf: 1 }); + + for (let i = 0; i < 5; i++) { + const row = await matchRow(match.id); + if (row.status !== "Veto") { + break; + } + await expire(match.id); + await autoPick(match.id); + } + + const row = await matchRow(match.id); + expect(row.status).toBe("Live"); + expect(row.veto_pick_expires_at).toBeNull(); + + const [{ count }] = await postgres.query>( + "SELECT COUNT(*) AS count FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(Number(count)).toBe(1); + }); + + it("never bans the last available region", async () => { + const match = await createRegionVetoMatch(); + + // Two bans resolve the third region via auto_select_region_veto. + for (let i = 0; i < 4; i++) { + const row = await matchRow(match.id); + if (row.region) { + break; + } + await expire(match.id); + await autoPick(match.id); + } + + const row = await matchRow(match.id); + expect(row.region).not.toBeNull(); + // map_veto is off, so locking the region takes the match Live. + expect(row.status).toBe("Live"); + }); + }); + + describe("fencing token", () => { + it("ignores a timer armed for an earlier turn", async () => { + const match = await createMapVetoMatch(4, { bestOf: 1 }); + + await postgres.query( + `INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id) + VALUES ($1, 'Ban', $2, $3)`, + [match.id, match.lineup_1_id, match.mapIds[0]], + ); + await expire(match.id); + + // The job was armed when no picks existed; one has since landed. + await autoPick(match.id, 0); + + expect(await mapPicks(match.id)).toHaveLength(1); + }); + + it("acts when the token still matches the current turn", async () => { + const match = await createMapVetoMatch(4, { bestOf: 1 }); + await expire(match.id); + + await autoPick(match.id, 0); + + expect(await mapPicks(match.id)).toHaveLength(1); + }); + + it("counts region and map picks in one sequence", async () => { + const { poolId } = await fx.mapPool(4); + const match = await fx.match({ + bestOf: 1, + mapVeto: true, + mapPoolId: poolId, + regions: ["TestA", "TestB", "TestC"], + }); + await postgres.query("UPDATE matches SET status = 'Veto' WHERE id = $1", [ + match.id, + ]); + + // Two region bans, then the Decider auto-inserted by the region veto. + for (let i = 0; i < 4; i++) { + if ((await matchRow(match.id)).region) { + break; + } + await expire(match.id); + await autoPick(match.id); + } + + const [{ count }] = await postgres.query>( + "SELECT veto_pick_count($1) AS count", + [match.id], + ); + const regions = await regionPicks(match.id); + expect(Number(count)).toBe(regions.length); + expect(Number(count)).toBeGreaterThan(0); + + // A token from before the region veto must not fire a map pick. + await expire(match.id); + await autoPick(match.id, 0); + expect(await mapPicks(match.id)).toHaveLength(0); + }); + }); + + describe("sweep isolation", () => { + it("still picks for healthy matches when another is unpickable", async () => { + const healthy = await createMapVetoMatch(4, { bestOf: 1 }); + + // Veto with no veto steps left to take: map veto off and the region + // already locked in, so get_map_veto_type returns null. + const { poolId } = await fx.mapPool(1); + const stuck = await fx.match({ mapVeto: false, mapPoolId: poolId }); + await postgres.query("UPDATE matches SET status = 'Veto' WHERE id = $1", [ + stuck.id, + ]); + + await expire(healthy.id); + await expire(stuck.id); + + await autoPick(); + + expect(await mapPicks(healthy.id)).toHaveLength(1); + // Cleared rather than left expired, so the sweep does not reprocess it + // on every pass for the rest of the match's life. + expect((await matchRow(stuck.id)).veto_pick_expires_at).toBeNull(); + }); + + it("clears the deadline of a match it cannot act on", async () => { + const { poolId } = await fx.mapPool(1); + const stuck = await fx.match({ mapVeto: false, mapPoolId: poolId }); + await postgres.query("UPDATE matches SET status = 'Veto' WHERE id = $1", [ + stuck.id, + ]); + await expire(stuck.id); + + await autoPick(); + expect((await matchRow(stuck.id)).veto_pick_expires_at).toBeNull(); + + // Second pass has nothing left to find. + await autoPick(); + expect((await matchRow(stuck.id)).veto_pick_expires_at).toBeNull(); + }); + + it("only touches the requested match when given an id", async () => { + const a = await createMapVetoMatch(4, { bestOf: 1 }); + const b = await createMapVetoMatch(4, { bestOf: 1 }); + + await expire(a.id); + await expire(b.id); + + await autoPick(a.id); + + expect(await mapPicks(a.id)).toHaveLength(1); + expect(await mapPicks(b.id)).toHaveLength(0); + }); + }); +});