From 06e3d10b7d8dce00e77d1980faaa8234363e53a0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:44:50 -0400 Subject: [PATCH 1/4] bug: veto turn order and decider side picks --- .../match/map-veto/get_map_veto_pattern.sql | 4 +- .../get_map_veto_picking_lineup_id.sql | 23 +- .../match/map-veto/verify_map_veto_pick.sql | 31 +- test/map-veto.spec.ts | 381 +++++++++++++++++- test/veto-timeout.spec.ts | 50 +++ 5 files changed, 459 insertions(+), 30 deletions(-) diff --git a/hasura/functions/match/map-veto/get_map_veto_pattern.sql b/hasura/functions/match/map-veto/get_map_veto_pattern.sql index 6728f3467..2177b07a1 100644 --- a/hasura/functions/match/map-veto/get_map_veto_pattern.sql +++ b/hasura/functions/match/map-veto/get_map_veto_pattern.sql @@ -35,9 +35,7 @@ BEGIN ELSIF pool_size = 5 THEN -- Both bans open the veto, as in the 6- and 7-map patterns below and -- in the linked rulebook. Ban/Pick/Pick/Ban let a map be picked - -- before either team had finished banning, and it was the only BO3 - -- pool that did not lead with two bans -- which is also what the - -- turn-swap in get_map_veto_picking_lineup_id assumes. + -- before either team had finished banning. base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick']; ELSIF pool_size = 6 THEN base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban']; diff --git a/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql b/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql index 199016af9..ce8e2f37b 100644 --- a/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql +++ b/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql @@ -8,9 +8,7 @@ DECLARE action_index int; next_action text; turn_index int; - best_of int; current_team int; - team int; last_pick_lineup uuid; BEGIN IF match.status != 'Veto' THEN @@ -50,21 +48,12 @@ BEGIN WHERE match_id = match.id AND type IN ('Ban', 'Pick', 'Decider'); - select mo.best_of into best_of from matches m - inner join match_options mo on mo.id = m.match_options_id - where m.id = match.id; - - -- best of 3 swaps teams after the 4th pick - IF best_of = 3 THEN - IF turn_index < 4 THEN - current_team := CASE WHEN turn_index % 2 = 0 THEN 1 ELSE 2 END; - ELSE - current_team := CASE WHEN turn_index % 2 = 0 THEN 2 ELSE 1 END; - END IF; - ELSE - current_team := CASE WHEN turn_index % 2 = 0 THEN 1 ELSE 2 END; - END IF; - + -- Bans and picks strictly alternate with lineup 1 opening, so the second + -- ban phase starts with the team that started the veto, as in the rulebook. + -- Best of 3 used to swap teams from the 5th turn on, which on the 7 map + -- pool gave lineup 1 the first ban, the first pick AND the last ban before + -- the decider. + current_team := CASE WHEN turn_index % 2 = 0 THEN 1 ELSE 2 END; IF current_team = 1 THEN RETURN match.lineup_1_id; diff --git a/hasura/functions/match/map-veto/verify_map_veto_pick.sql b/hasura/functions/match/map-veto/verify_map_veto_pick.sql index dcbae0a2d..b50155c2a 100644 --- a/hasura/functions/match/map-veto/verify_map_veto_pick.sql +++ b/hasura/functions/match/map-veto/verify_map_veto_pick.sql @@ -4,6 +4,7 @@ CREATE OR REPLACE FUNCTION public.verify_map_veto_pick(match_map_veto_pick match DECLARE pickType VARCHAR(255); lineup_id uuid; + picked_map_id uuid; _match matches; map_pool uuid[]; use_active_pool BOOLEAN; @@ -37,8 +38,34 @@ BEGIN END IF; -- Ensure that a side is picked for 'Side' type veto - IF pickType = 'Side' AND match_map_veto_pick.side IS NULL THEN - RAISE EXCEPTION 'Must pick a side' USING ERRCODE = '22000'; + IF pickType = 'Side' THEN + IF match_map_veto_pick.side IS NULL THEN + RAISE EXCEPTION 'Must pick a side' USING ERRCODE = '22000'; + END IF; + + -- A Side answers the Pick before it and nothing else. Unchecked, the + -- side could be recorded against the leftover map -- the decider, which + -- no one ever picks sides on -- leaving the picked map unplayed and the + -- veto stuck on a Decider step with no maps left to satisfy it. + -- Picks and sides alternate, so only one picked map is ever waiting on + -- a side. Matched on the rows themselves rather than the latest + -- created_at, which ties when picks share a transaction. + SELECT mvp.map_id INTO picked_map_id + FROM match_map_veto_picks mvp + WHERE mvp.match_id = match_map_veto_pick.match_id + AND mvp.type = 'Pick' + AND NOT EXISTS ( + SELECT 1 + FROM match_map_veto_picks sided + WHERE sided.match_id = mvp.match_id + AND sided.map_id = mvp.map_id + AND sided.type = 'Side' + ) + LIMIT 1; + + IF match_map_veto_pick.map_id IS DISTINCT FROM picked_map_id THEN + RAISE EXCEPTION 'Must pick a side for the map that was just picked' USING ERRCODE = '22000'; + END IF; END IF; -- Ensure that a side is not picked for 'Pick' or 'Ban' type veto diff --git a/test/map-veto.spec.ts b/test/map-veto.spec.ts index 55043a9cb..915280416 100644 --- a/test/map-veto.spec.ts +++ b/test/map-veto.spec.ts @@ -114,6 +114,40 @@ describe("map veto (SQL-driven)", () => { throw new Error("veto never completed"); }; + // The same drive, but recording each step as " " so turn order + // can be asserted. + const playOutVeto = async (bestOf: number, poolSize: number) => { + const match = await createVetoMatch(bestOf, poolSize); + const steps: Array<{ step: string; mapId: string }> = []; + const used = new Set(); + 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, steps }; + } + + const actor = state.picking === match.lineup_1_id ? "L1" : "L2"; + + if (state.veto_type === "Side") { + steps.push({ step: `Side ${actor}`, mapId: lastPicked! }); + await insertPick(match.id, "Side", state.picking!, lastPicked!, "CT"); + continue; + } + + const mapId = match.mapIds.filter((id) => !used.has(id))[0]; + used.add(mapId); + if (state.veto_type === "Pick") { + lastPicked = mapId; + } + steps.push({ step: `${state.veto_type} ${actor}`, mapId }); + await insertPick(match.id, state.veto_type!, state.picking!, mapId); + } + + throw new Error("veto never completed"); + }; + it("computes the CS rulebook patterns", async () => { expect(await patternFor(1, 3)).toEqual(["Ban", "Ban", "Decider"]); expect(await patternFor(3, 4)).toEqual([ @@ -240,18 +274,15 @@ describe("map veto (SQL-driven)", () => { await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]); await insertPick(match.id, "Ban", match.lineup_2_id, match.mapIds[1]); - const [row] = await postgres.query< - Array<{ last_ban: string; decider: string }> - >( - `SELECT max(created_at) FILTER (WHERE type = 'Ban') AS last_ban, - max(created_at) FILTER (WHERE type = 'Decider') AS decider + // Compared in SQL: the gap is microseconds, which a JS Date truncates away. + const [row] = await postgres.query>( + `SELECT max(created_at) FILTER (WHERE type = 'Decider') + > max(created_at) FILTER (WHERE type = 'Ban') AS decider_last FROM match_map_veto_picks WHERE match_id = $1`, [match.id], ); - expect(new Date(row.decider).getTime()).toBeGreaterThan( - new Date(row.last_ban).getTime(), - ); + expect(row.decider_last).toBe(true); }); it("runs the BO3 Pick/Side steps and assigns the chosen side to the picking lineup", async () => { @@ -509,6 +540,340 @@ describe("map veto (SQL-driven)", () => { }); }); + // https://docs.5stack.gg/features/map-veto#when-there-is-nothing-to-veto + // A pool holding exactly best_of maps has no decision in it: every map gets + // played, so setup_match_maps assigns them straight from the pool with + // alternating starting sides and no veto ever runs. + describe("when there is nothing to veto", () => { + const goLive = async (bestOf: number, poolSize: number) => { + const { poolId, mapIds } = await fx.mapPool(poolSize); + const match = await fx.match({ + bestOf, + mapVeto: true, + mapPoolId: poolId, + }); + await postgres.query("UPDATE matches SET status = 'Live' WHERE id = $1", [ + match.id, + ]); + return { ...match, mapIds }; + }; + + it.each([ + [1, 1], + [3, 3], + [5, 5], + ])( + "BO%i pool %i skips the veto and goes straight to the maps", + async (bestOf, poolSize) => { + const match = await goLive(bestOf, poolSize); + + // Map veto is on, but with nothing to veto the match stays Live + // instead of being bounced into Veto. + const state = await vetoState(match.id); + expect(state.status).toBe("Live"); + expect(state.veto_type).toBeNull(); + expect(state.picking).toBeNull(); + + const maps = await postgres.query< + Array<{ + map_id: string; + lineup_1_side: string; + lineup_2_side: string; + }> + >( + `SELECT map_id, lineup_1_side, lineup_2_side FROM match_maps + WHERE match_id = $1 ORDER BY "order"`, + [match.id], + ); + expect(maps.length).toBe(bestOf); + expect(maps.map((m) => m.map_id).sort()).toEqual( + [...match.mapIds].sort(), + ); + + // Alternating starting sides down the series. + maps.forEach((map, i) => { + expect(map.lineup_1_side).toBe(i % 2 === 0 ? "CT" : "TERRORIST"); + expect(map.lineup_2_side).toBe(i % 2 === 0 ? "TERRORIST" : "CT"); + }); + + const picks = await postgres.query>( + "SELECT id FROM match_map_veto_picks WHERE match_id = $1", + [match.id], + ); + expect(picks).toEqual([]); + + // No step is outstanding, so no pick timer is left armed. + const [{ expires_at }] = await postgres.query< + Array<{ expires_at: string | null }> + >( + "SELECT veto_pick_expires_at AS expires_at FROM matches WHERE id = $1", + [match.id], + ); + expect(expires_at).toBeNull(); + }, + ); + + it("refuses a pick when there was nothing to veto", async () => { + const match = await goLive(3, 3); + + await expect( + insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]), + ).rejects.toThrow(/No map veto in progress/i); + + const [{ allowed }] = await postgres.query< + Array<{ allowed: boolean | null }> + >( + `SELECT lineup_is_picking_map_veto(ml) AS allowed + FROM match_lineups ml WHERE ml.id = $1`, + [match.lineup_1_id], + ); + expect(allowed).toBeFalsy(); + }); + }); + + // The 7 map active duty pool: the shape the linked CS rulebook is actually + // written for, and the one nearly every real match runs. + // https://github.com/ValveSoftware/counter-strike_rules_and_regs/blob/main/major-supplemental-rulebook.md#map-pick-ban + describe("a 7 map pool", () => { + it("BO1: the teams alternate bans down to the decider", async () => { + const { match, steps } = await playOutVeto(1, 7); + + expect(steps.map((s) => s.step)).toEqual([ + "Ban L1", + "Ban L2", + "Ban L1", + "Ban L2", + "Ban L1", + "Ban L2", + ]); + + const maps = await postgres.query>( + 'SELECT map_id FROM match_maps WHERE match_id = $1 ORDER BY "order"', + [match.id], + ); + expect(maps.map((m) => m.map_id)).toEqual([match.mapIds[6]]); + expect((await vetoState(match.id)).status).toBe("Live"); + }); + + it("BO3: ban, ban, pick+side, pick+side, ban, ban, decider", async () => { + const { match, steps } = await playOutVeto(3, 7); + + // Rulebook order: the team that opened the veto also opens the second + // ban phase, so the last ban before the decider falls to lineup 2. + expect(steps.map((s) => s.step)).toEqual([ + "Ban L1", + "Ban L2", + "Pick L1", + "Side L2", + "Pick L2", + "Side L1", + "Ban L1", + "Ban L2", + ]); + + const picked = steps + .filter((s) => s.step.startsWith("Pick")) + .map((s) => s.mapId); + const banned = steps + .filter((s) => s.step.startsWith("Ban")) + .map((s) => s.mapId); + const leftover = match.mapIds.filter( + (id) => !picked.includes(id) && !banned.includes(id), + ); + expect(leftover.length).toBe(1); + + const maps = await postgres.query< + Array<{ map_id: string; lineup_1_side: string; lineup_2_side: string }> + >( + `SELECT map_id, lineup_1_side, lineup_2_side FROM match_maps + WHERE match_id = $1 ORDER BY "order"`, + [match.id], + ); + + // Maps are played in the order they were picked, decider last. + expect(maps.map((m) => m.map_id)).toEqual([...picked, leftover[0]]); + + // Each pick's side went to the lineup that answered it: lineup 2 chose + // CT on lineup 1's pick, lineup 1 chose CT on lineup 2's pick. + expect(maps[0].lineup_2_side).toBe("CT"); + expect(maps[0].lineup_1_side).toBe("TERRORIST"); + expect(maps[1].lineup_1_side).toBe("CT"); + expect(maps[1].lineup_2_side).toBe("TERRORIST"); + + const [decider] = await postgres.query< + Array<{ map_id: string; side: string | null }> + >( + "SELECT map_id, side FROM match_map_veto_picks WHERE match_id = $1 AND type = 'Decider'", + [match.id], + ); + expect(decider.map_id).toBe(leftover[0]); + expect(decider.side).toBeNull(); + + const state = await vetoState(match.id); + expect(state.status).toBe("Live"); + expect(state.veto_type).toBeNull(); + expect(state.picking).toBeNull(); + }); + + it("BO5: ban, ban, then four picks with the opponent on sides", async () => { + const { match, steps } = await playOutVeto(5, 7); + + expect(steps.map((s) => s.step)).toEqual([ + "Ban L1", + "Ban L2", + "Pick L1", + "Side L2", + "Pick L2", + "Side L1", + "Pick L1", + "Side L2", + "Pick L2", + "Side L1", + ]); + + const maps = await postgres.query>( + 'SELECT map_id FROM match_maps WHERE match_id = $1 ORDER BY "order"', + [match.id], + ); + expect(maps.length).toBe(5); + expect((await vetoState(match.id)).status).toBe("Live"); + }); + + it.each([[1], [3], [5]])( + "BO%i: every map in the pool is accounted for exactly once", + async (bestOf) => { + const match = await runVetoToCompletion(bestOf, 7); + + const picks = await postgres.query< + Array<{ type: string; map_id: string }> + >( + "SELECT type, map_id FROM match_map_veto_picks WHERE match_id = $1 AND type <> 'Side'", + [match.id], + ); + expect(new Set(picks.map((p) => p.map_id)).size).toBe(7); + expect(picks.filter((p) => p.type === "Decider").length).toBe(1); + expect(picks.filter((p) => p.type === "Pick").length).toBe(bestOf - 1); + expect(picks.filter((p) => p.type === "Ban").length).toBe(7 - bestOf); + }, + ); + }); + + // Nobody ever picks a side on the decider. It is the map neither team chose, + // so the veto ends the moment it is inserted: no Side step is generated for + // it, no Side row may name it, and neither captain is left on the clock. + describe("the decider is never a side pick", () => { + const combos: Array<[number, number]> = [ + [1, 3], + [1, 7], + [2, 7], + [3, 4], + [3, 5], + [3, 6], + [3, 7], + [3, 12], + [5, 6], + [5, 7], + [5, 12], + ]; + + it.each(combos)( + "BO%i pool %i: the pattern has no Side step for the decider", + async (bestOf, poolSize) => { + const pattern = await patternFor(bestOf, poolSize); + + expect(pattern[pattern.length - 1]).toBe("Decider"); + expect(pattern.indexOf("Side")).toBeLessThan( + pattern.indexOf("Decider"), + ); + // A Side only ever answers the Pick before it. + pattern.forEach((type, i) => { + if (type === "Side") { + expect(pattern[i - 1]).toBe("Pick"); + } + }); + }, + ); + + it.each(combos)( + "BO%i pool %i: no side is chosen for the decider map", + async (bestOf, poolSize) => { + const match = await runVetoToCompletion(bestOf, poolSize); + + const picks = await postgres.query< + Array<{ type: string; map_id: string; side: string | null }> + >( + `SELECT type, map_id, side FROM match_map_veto_picks + WHERE match_id = $1 ORDER BY created_at`, + [match.id], + ); + + const decider = picks.find((pick) => pick.type === "Decider"); + expect(decider).toBeDefined(); + expect(decider!.side).toBeNull(); + + // The decider closes the veto: nothing is recorded after it, and no + // Side row names its map. + expect(picks[picks.length - 1].type).toBe("Decider"); + expect( + picks.filter( + (pick) => pick.type === "Side" && pick.map_id === decider!.map_id, + ), + ).toEqual([]); + + // Neither captain is still on the clock once the decider lands. + const state = await vetoState(match.id); + expect(state.status).toBe("Live"); + expect(state.veto_type).toBeNull(); + expect(state.picking).toBeNull(); + + const [{ expires_at }] = await postgres.query< + Array<{ expires_at: string | null }> + >( + "SELECT veto_pick_expires_at AS expires_at FROM matches WHERE id = $1", + [match.id], + ); + expect(expires_at).toBeNull(); + }, + ); + + it("rejects a side submitted against the leftover decider map", async () => { + // BO3 pool 4: Ban, Pick, Side, Pick, Side, Decider. After the second + // Pick the only unvetoed map left IS the decider, so the outstanding + // Side step answers the map that was just picked — pointing it at the + // leftover map instead would make the decider a side pick. + const match = await createVetoMatch(3, 4); + + await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]); + + let picker = (await vetoState(match.id)).picking!; + await insertPick(match.id, "Pick", picker, match.mapIds[1]); + let sider = + picker === match.lineup_1_id ? match.lineup_2_id : match.lineup_1_id; + await insertPick(match.id, "Side", sider, match.mapIds[1], "CT"); + + picker = (await vetoState(match.id)).picking!; + await insertPick(match.id, "Pick", picker, match.mapIds[2]); + + const state = await vetoState(match.id); + expect(state.veto_type).toBe("Side"); + sider = + picker === match.lineup_1_id ? match.lineup_2_id : match.lineup_1_id; + expect(state.picking).toBe(sider); + + await expect( + insertPick(match.id, "Side", sider, match.mapIds[3], "CT"), + ).rejects.toThrow(/side/i); + + await insertPick(match.id, "Side", sider, match.mapIds[2], "CT"); + + const [decider] = await postgres.query>( + "SELECT map_id FROM match_map_veto_picks WHERE match_id = $1 AND type = 'Decider'", + [match.id], + ); + expect(decider.map_id).toBe(match.mapIds[3]); + }); + }); + 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]); diff --git a/test/veto-timeout.spec.ts b/test/veto-timeout.spec.ts index a1eec0278..503b05274 100644 --- a/test/veto-timeout.spec.ts +++ b/test/veto-timeout.spec.ts @@ -295,6 +295,56 @@ describe("veto pick timeout (SQL-driven)", () => { expect(Number(count)).toBe(1); }); + it("drives a whole Bo3 on the 7 map pool, sides included", async () => { + const match = await createMapVetoMatch(7, { bestOf: 3 }); + + for (let i = 0; i < 12; 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 picks = await mapPicks(match.id); + expect(picks.map((pick) => pick.type)).toEqual([ + "Ban", + "Ban", + "Pick", + "Side", + "Pick", + "Side", + "Ban", + "Ban", + "Decider", + ]); + + // Auto-taken sides answer the map that was just picked, never the + // leftover decider. + const decider = picks.find((pick) => pick.type === "Decider")!; + picks + .filter((pick) => pick.type === "Side") + .forEach((side) => { + expect(side.map_id).not.toBe(decider.map_id); + expect( + picks.some( + (pick) => pick.type === "Pick" && pick.map_id === side.map_id, + ), + ).toBe(true); + }); + + const [{ count }] = await postgres.query>( + "SELECT COUNT(*) AS count FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(Number(count)).toBe(3); + }); + it("never bans the last available region", async () => { const match = await createRegionVetoMatch(); From d4d35486ffd9ac481ec9ba874d10588e9bcc86a0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:58:28 -0400 Subject: [PATCH 2/4] test: odd and low map pool counts --- test/map-veto.spec.ts | 167 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/test/map-veto.spec.ts b/test/map-veto.spec.ts index 915280416..008a1d17d 100644 --- a/test/map-veto.spec.ts +++ b/test/map-veto.spec.ts @@ -540,6 +540,173 @@ describe("map veto (SQL-driven)", () => { }); }); + // Pool sizes well past the rulebook ladder, and the small ones underneath it. + // Leagues and custom pools hand out whatever count they like, so the pattern + // has to hold for all of them, not just 5/6/7. + describe("odd and low map pool sizes", () => { + const bestOfs = [1, 2, 3, 4, 5, 7]; + + it.each([ + [2], + [3], + [4], + [5], + [6], + [7], + [8], + [9], + [10], + [11], + [12], + [13], + [14], + [15], + [16], + [20], + [24], + ])( + "pool %i: every best of consumes the pool exactly once and ends on the decider", + async (poolSize) => { + for (const bestOf of bestOfs.filter((n) => n < poolSize)) { + const match = await createVetoMatch(bestOf, poolSize); + // A short seed table would quietly build a smaller pool and make the + // rest of this vacuous. + expect(match.mapIds.length).toBe(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], + ); + + const count = (type: string) => + pattern.filter((step) => step === type).length; + const label = `BO${bestOf} pool ${poolSize}`; + + expect({ label, total: count("Ban") + count("Pick") + 1 }).toEqual({ + label, + total: poolSize, + }); + expect({ label, played: count("Pick") + 1 }).toEqual({ + label, + played: bestOf, + }); + expect({ label, sides: count("Side") }).toEqual({ + label, + sides: count("Pick"), + }); + expect({ label, deciders: count("Decider") }).toEqual({ + label, + deciders: 1, + }); + expect({ label, last: pattern[pattern.length - 1] }).toEqual({ + label, + last: "Decider", + }); + pattern.forEach((step, i) => { + if (step === "Side") { + expect({ label, before: pattern[i - 1] }).toEqual({ + label, + before: "Pick", + }); + } + }); + } + }, + ); + + // The turn order regression guard. Nothing used to assert who acted when, + // which is how a best-of-3 turn swap survived: on a pool of 8 it handed + // BOTH picks to lineup 2, and on 12 it had lineup 2 ban twice in a row. + it.each([ + [3, 8], + [3, 9], + [3, 10], + [3, 12], + [3, 15], + [5, 10], + [5, 12], + [5, 15], + [1, 15], + ])( + "BO%i pool %i: bans and picks alternate, lineup 1 opening", + async (bestOf, poolSize) => { + const { steps } = await playOutVeto(bestOf, poolSize); + + const turns = steps + .map((s) => s.step) + .filter((step) => !step.startsWith("Side")); + + turns.forEach((turn, i) => { + expect(turn.endsWith(i % 2 === 0 ? "L1" : "L2")).toBe(true); + }); + }, + ); + + it.each([ + [1, 10], + [2, 10], + [3, 10], + [5, 10], + [1, 12], + [2, 12], + [3, 12], + [5, 12], + [1, 15], + [2, 15], + [3, 15], + [5, 15], + [1, 2], + [1, 3], + [2, 3], + [1, 4], + [3, 4], + ])( + "BO%i pool %i runs to completion with the right maps left standing", + async (bestOf, poolSize) => { + const match = await runVetoToCompletion(bestOf, poolSize); + + const state = await vetoState(match.id); + expect(state.status).toBe("Live"); + expect(state.veto_type).toBeNull(); + + const maps = await postgres.query>( + "SELECT map_id FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(maps.length).toBe(bestOf); + + const picks = await postgres.query< + Array<{ type: string; map_id: string }> + >( + "SELECT type, map_id FROM match_map_veto_picks WHERE match_id = $1 AND type <> 'Side'", + [match.id], + ); + expect(new Set(picks.map((p) => p.map_id)).size).toBe(poolSize); + expect(picks.filter((p) => p.type === "Ban").length).toBe( + poolSize - bestOf, + ); + expect(picks.filter((p) => p.type === "Decider").length).toBe(1); + }, + ); + + it.each([ + [3, 2], + [5, 4], + [7, 6], + ])( + "BO%i on a pool of %i is refused at match creation", + async (bestOf, poolSize) => { + const { poolId } = await fx.mapPool(poolSize); + + await expect( + fx.match({ bestOf, mapVeto: true, mapPoolId: poolId }), + ).rejects.toThrow(/Not enough maps in the pool/i); + }, + ); + }); + // https://docs.5stack.gg/features/map-veto#when-there-is-nothing-to-veto // A pool holding exactly best_of maps has no decision in it: every map gets // played, so setup_match_maps assigns them straight from the pool with From d0045f0043a6cc48fd7c90a2869c4bfd38950ab2 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 22:11:19 -0400 Subject: [PATCH 3/4] bug: follow the documented veto pattern on larger pools --- .../match/map-veto/get_map_veto_pattern.sql | 80 ++++++------- test/map-veto.spec.ts | 109 ++++++++++++++++-- 2 files changed, 133 insertions(+), 56 deletions(-) diff --git a/hasura/functions/match/map-veto/get_map_veto_pattern.sql b/hasura/functions/match/map-veto/get_map_veto_pattern.sql index 2177b07a1..9d1ca6983 100644 --- a/hasura/functions/match/map-veto/get_map_veto_pattern.sql +++ b/hasura/functions/match/map-veto/get_map_veto_pattern.sql @@ -3,12 +3,16 @@ CREATE OR REPLACE FUNCTION public.get_map_veto_pattern(_match public.matches) RE AS $$ DECLARE best_of int; + pool_size int; pattern TEXT[] := '{}'; - base_pattern TEXT[] := '{}'; - i INT; - pool_size INT; - surplus INT; + -- https://docs.5stack.gg/features/map-veto + unit TEXT[] := ARRAY['Ban', 'Ban', 'Pick', 'Pick']; + unit_index int := 0; + picks_needed int; + picks_made int := 0; + steps_left int; _type TEXT; + i INT; BEGIN SELECT mo.best_of INTO best_of FROM matches m @@ -27,55 +31,37 @@ BEGIN RAISE EXCEPTION 'Not enough maps in the pool for the best of %', best_of USING ERRCODE = '22000'; END IF; - -- https://github.com/ValveSoftware/counter-strike_rules_and_regs/blob/main/major-supplemental-rulebook.md#map-pick-ban + picks_needed := best_of - 1; - IF best_of = 3 AND pool_size >= 4 THEN - IF pool_size = 4 THEN - base_pattern := ARRAY['Ban', 'Pick', 'Pick']; - ELSIF pool_size = 5 THEN - -- Both bans open the veto, as in the 6- and 7-map patterns below and - -- in the linked rulebook. Ban/Pick/Pick/Ban let a map be picked - -- before either team had finished banning. - base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick']; - ELSIF pool_size = 6 THEN - base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban']; + -- The veto runs for pool - 1 steps; the one map nobody bans or picks is the + -- Decider, which always closes (it is auto-inserted once a single map is + -- left, so any step sitting after it could never be satisfied). + FOR i IN 1..(pool_size - 1) LOOP + -- Steps remaining, this one included. + steps_left := pool_size - i; + + IF picks_made = picks_needed THEN + -- Enough maps are picked to fill the series: ban out the rest. This + -- is where a pool larger than the pattern spends its extra bans, + -- after the picks and before the Decider. + _type := 'Ban'; + ELSIF steps_left = picks_needed - picks_made THEN + -- Any more banning would leave too few maps to pick from. + _type := 'Pick'; ELSE - base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban', 'Ban']; + _type := unit[unit_index % 4 + 1]; + unit_index := unit_index + 1; END IF; - ELSIF best_of = 5 AND pool_size >= 6 THEN - IF pool_size = 6 THEN - base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Pick', 'Pick']; + + IF _type = 'Pick' THEN + picks_made := picks_made + 1; + -- Every Pick is answered by a Side. + pattern := pattern || ARRAY['Pick', 'Side']; ELSE - base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Pick', 'Pick']; + pattern := pattern || ARRAY['Ban']; END IF; - ELSE - -- Everything the rulebook doesn't cover (BO1, a pool only big enough to - -- pick from, any other best of): pick the maps that get played and let - -- the bans below account for the rest. Without this arm an unsupported - -- best of returned a pattern of NULLs and the veto could never finish. - base_pattern := array_fill('Pick'::text, ARRAY[best_of - 1]); - END IF; - - -- Maps the pattern doesn't account for are banned up front, trimming the - -- pool to the rulebook shape before the picks. The Decider closes: it is - -- only ever auto-inserted by create_match_map_from_veto once one map is - -- left, so any step sitting after it can never be satisfied. - surplus := pool_size - 1 - coalesce(array_length(base_pattern, 1), 0); - base_pattern := array_append( - array_fill('Ban'::text, ARRAY[surplus]) || base_pattern, - 'Decider' - ); - - FOR i IN 1..(pool_size) LOOP - _type := base_pattern[i]; - - pattern := pattern || - CASE - WHEN _type = 'Pick' THEN ARRAY['Pick', 'Side'] - ELSE ARRAY[_type] - END; END LOOP; - RETURN pattern; + RETURN pattern || ARRAY['Decider']; END; $$; diff --git a/test/map-veto.spec.ts b/test/map-veto.spec.ts index 008a1d17d..2927653eb 100644 --- a/test/map-veto.spec.ts +++ b/test/map-veto.spec.ts @@ -160,6 +160,42 @@ describe("map veto (SQL-driven)", () => { ]); }); + // The worked examples in the docs, verbatim. + // https://docs.5stack.gg/features/map-veto#examples + it.each([ + [1, 7, ["Ban", "Ban", "Ban", "Ban", "Ban", "Ban", "Decider"]], + [3, 5, ["Ban", "Ban", "Pick", "Side", "Pick", "Side", "Decider"]], + [ + 3, + 7, + ["Ban", "Ban", "Pick", "Side", "Pick", "Side", "Ban", "Ban", "Decider"], + ], + [ + 5, + 7, + [ + "Ban", + "Ban", + "Pick", + "Side", + "Pick", + "Side", + "Pick", + "Side", + "Pick", + "Side", + "Decider", + ], + ], + ])( + "BO%i pool %i matches the documented example", + async (bestOf, poolSize, expected) => { + expect(await patternFor(bestOf as number, poolSize as number)).toEqual( + expected, + ); + }, + ); + it("refuses a veto on an empty map pool", async () => { const { poolId } = await fx.mapPool(0); const match = await fx.match({ @@ -453,19 +489,74 @@ describe("map veto (SQL-driven)", () => { }, ); - it.each([[3], [5]])( - "BO%i bans the surplus down to the rulebook shape before the picks", + // "Any extra bans a larger pool requires land after the picks and before + // the Decider." Those bans used to be spent up front instead, so a 12 map + // pool opened with seven straight bans before anyone picked anything. + it.each([[2], [3], [5]])( + "BO%i spends a larger pool's extra bans after the picks", async (bestOf) => { - const rulebook = await patternFor(bestOf, 7); - const large = await patternFor(bestOf, 12); + for (const poolSize of [8, 12, 16, 24]) { + const pattern = await patternFor(bestOf, poolSize); + const label = `BO${bestOf} pool ${poolSize}`; - expect(large.slice(-rulebook.length)).toEqual(rulebook); - expect(large.slice(0, large.length - rulebook.length)).toEqual( - Array(5).fill("Ban"), - ); + // The opening never grows with the pool: the picks start as soon as + // the Ban, Ban, Pick, Pick unit reaches them. + expect({ label, firstPick: pattern.indexOf("Pick") }).toEqual({ + label, + firstPick: 2, + }); + + // Everything from the last side to the decider is a ban. + const tail = pattern.slice(pattern.lastIndexOf("Side") + 1); + expect({ label, tail: tail.slice(0, -1) }).toEqual({ + label, + tail: Array(tail.length - 1).fill("Ban"), + }); + expect({ label, last: tail[tail.length - 1] }).toEqual({ + label, + last: "Decider", + }); + } }, ); + it.each([ + [ + 3, + 12, + ["Ban", "Ban", "Pick", "Side", "Pick", "Side"] + .concat(Array(7).fill("Ban")) + .concat(["Decider"]), + ], + [ + 5, + 12, + // Two turns of the unit, because a best of 5 needs four picks. + [ + "Ban", + "Ban", + "Pick", + "Side", + "Pick", + "Side", + "Ban", + "Ban", + "Pick", + "Side", + "Pick", + "Side", + "Ban", + "Ban", + "Ban", + "Decider", + ], + ], + ])("BO%i pool %i follows the documented pattern", async (bestOf, poolSize, expected) => { + expect(await patternFor(bestOf as number, poolSize as number)).toEqual( + expected, + ); + }); + it.each([ [3, 12], [5, 12], @@ -502,7 +593,7 @@ describe("map veto (SQL-driven)", () => { // match sat in Veto until an admin canceled it. describe("a best of the rulebook doesn't cover", () => { it.each([ - [2, 7, ["Ban", "Ban", "Ban", "Ban", "Ban", "Pick", "Side", "Decider"]], + [2, 7, ["Ban", "Ban", "Pick", "Side", "Ban", "Ban", "Ban", "Decider"]], [ 4, 6, From c9267d4e0039162f11659fe85b5fde6ba597a9ad Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 22:22:53 -0400 Subject: [PATCH 4/4] bug: reverse the lead after each pick pair --- .../get_map_veto_picking_lineup_id.sql | 21 +++++--- test/map-veto.spec.ts | 51 ++++++++++++++----- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql b/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql index ce8e2f37b..c87897a5c 100644 --- a/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql +++ b/hasura/functions/match/map-veto/get_map_veto_picking_lineup_id.sql @@ -8,6 +8,7 @@ DECLARE action_index int; next_action text; turn_index int; + picks_made int; current_team int; last_pick_lineup uuid; BEGIN @@ -48,12 +49,20 @@ BEGIN WHERE match_id = match.id AND type IN ('Ban', 'Pick', 'Decider'); - -- Bans and picks strictly alternate with lineup 1 opening, so the second - -- ban phase starts with the team that started the veto, as in the rulebook. - -- Best of 3 used to swap teams from the 5th turn on, which on the 7 map - -- pool gave lineup 1 the first ban, the first pick AND the last ban before - -- the decider. - current_team := CASE WHEN turn_index % 2 = 0 THEN 1 ELSE 2 END; + SELECT COUNT(*) INTO picks_made + FROM match_map_veto_picks + WHERE match_id = match.id + AND type = 'Pick'; + + -- Turns alternate, and every completed pair of picks reverses who leads, so + -- the picks snake: lineup 1, lineup 2, lineup 2, lineup 1. Without the + -- reverse the team that opens the veto takes every odd pick and the last + -- ban before the decider. + IF (picks_made / 2) % 2 = 1 THEN + current_team := CASE WHEN turn_index % 2 = 0 THEN 2 ELSE 1 END; + ELSE + current_team := CASE WHEN turn_index % 2 = 0 THEN 1 ELSE 2 END; + END IF; IF current_team = 1 THEN RETURN match.lineup_1_id; diff --git a/test/map-veto.spec.ts b/test/map-veto.spec.ts index 2927653eb..274047744 100644 --- a/test/map-veto.spec.ts +++ b/test/map-veto.spec.ts @@ -721,16 +721,43 @@ describe("map veto (SQL-driven)", () => { [5, 15], [1, 15], ])( - "BO%i pool %i: bans and picks alternate, lineup 1 opening", + "BO%i pool %i: the picks snake and the bans alternate", async (bestOf, poolSize) => { const { steps } = await playOutVeto(bestOf, poolSize); + const actor = (step: string) => step.slice(-2); + + // Picks reverse every pair: L1, L2, L2, L1, L1, L2 ... + steps + .filter((s) => s.step.startsWith("Pick")) + .forEach((pick, i) => { + const expected = Math.floor((i + 1) / 2) % 2 === 0 ? "L1" : "L2"; + expect({ pick: i, actor: actor(pick.step) }).toEqual({ + pick: i, + actor: expected, + }); + }); + + steps.forEach((step, i) => { + const previous = steps[i - 1]; + if (!previous) { + return; + } - const turns = steps - .map((s) => s.step) - .filter((step) => !step.startsWith("Side")); + // Consecutive bans never land on the same team. + if (step.step.startsWith("Ban") && previous.step.startsWith("Ban")) { + expect({ step: i, actor: actor(step.step) }).not.toEqual({ + step: i, + actor: actor(previous.step), + }); + } - turns.forEach((turn, i) => { - expect(turn.endsWith(i % 2 === 0 ? "L1" : "L2")).toBe(true); + // A side is always answered by the opponent of whoever picked. + if (step.step.startsWith("Side")) { + expect({ step: i, actor: actor(step.step) }).not.toEqual({ + step: i, + actor: actor(previous.step), + }); + } }); }, ); @@ -916,8 +943,8 @@ describe("map veto (SQL-driven)", () => { it("BO3: ban, ban, pick+side, pick+side, ban, ban, decider", async () => { const { match, steps } = await playOutVeto(3, 7); - // Rulebook order: the team that opened the veto also opens the second - // ban phase, so the last ban before the decider falls to lineup 2. + // The pick pair reverses the lead, so the second ban phase opens with + // lineup 2 and the last ban before the decider falls to lineup 1. expect(steps.map((s) => s.step)).toEqual([ "Ban L1", "Ban L2", @@ -925,8 +952,8 @@ describe("map veto (SQL-driven)", () => { "Side L2", "Pick L2", "Side L1", - "Ban L1", "Ban L2", + "Ban L1", ]); const picked = steps @@ -973,7 +1000,7 @@ describe("map veto (SQL-driven)", () => { expect(state.picking).toBeNull(); }); - it("BO5: ban, ban, then four picks with the opponent on sides", async () => { + it("BO5: ban, ban, then four picks snaking L1, L2, L2, L1", async () => { const { match, steps } = await playOutVeto(5, 7); expect(steps.map((s) => s.step)).toEqual([ @@ -983,10 +1010,10 @@ describe("map veto (SQL-driven)", () => { "Side L2", "Pick L2", "Side L1", - "Pick L1", - "Side L2", "Pick L2", "Side L1", + "Pick L1", + "Side L2", ]); const maps = await postgres.query>(