import { expect, test } from "bun:test"; import { runBots } from "./bots.ts"; import { aliveOn, buyAbility, buyShield, buyWeapon, DEFUSE_CHECKPOINT, DEFUSE_TIME, FUSE_TIME, makePlayer, newMatch, PLANT_TIME, ROUND_TIME, SWAP_AFTER, tick, visible, WIN_ROUNDS, } from "./match.ts"; import { siteCentre, spawnCells, wallBetween } from "./map.ts"; import type { MatchState, Player } from "./state.ts"; const live = (seed = 5): MatchState => { const state = newMatch(seed); state.phase = "live"; state.clock = ROUND_TIME; return state; }; const atk = (state: MatchState): Player[] => state.players.filter((p) => p.side === "atk"); const def = (state: MatchState): Player[] => state.players.filter((p) => p.side === "def"); const run = (state: MatchState, seconds: number): void => { for (let i = 0; i < Math.round(seconds * 30); i++) tick(state, 1 / 30); }; /** Put a carrier on a site with the spike, everyone else out of the way. */ function onSite(state: MatchState): Player { const spot = siteCentre("A"); const carrier = atk(state)[0]!; for (const player of state.players) { player.spike = false; player.x = 1200; player.y = 1320; } carrier.spike = true; carrier.x = spot.x; carrier.y = spot.y; state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; return carrier; } test("a match starts in the buy phase with ten seats and a spike carrier", () => { const state = newMatch(1); expect(state.players.length).toBe(10); expect(atk(state).length).toBe(5); expect(def(state).length).toBe(5); expect(state.phase).toBe("buy"); expect(state.players.some((p) => p.spike)).toBe(true); expect(state.players.every((p) => p.credits === 800)).toBe(true); }); test("planting takes four seconds of standing on a site and arms the fuse", () => { const state = live(); const carrier = onSite(state); carrier.input.use = true; run(state, PLANT_TIME - 1); expect(state.spike.planted).toBe(false); run(state, 1.2); expect(state.spike.planted).toBe(true); expect(state.spike.site).toBe("A"); expect(Math.round(state.spike.fuse)).toBeLessThanOrEqual(FUSE_TIME); // The plant pays the attacking team and charges the planter's ult. expect(atk(state).every((p) => p.credits >= 800 + 300)).toBe(true); expect(carrier.ult).toBeGreaterThan(0); }); test("a plant off a site does nothing at all", () => { const state = live(); const carrier = onSite(state); carrier.x = 1200; carrier.y = 1320; // attacker spawn, not a site carrier.input.use = true; run(state, PLANT_TIME + 2); expect(state.spike.planted).toBe(false); }); test("a defuse killed at the half way mark leaves the checkpoint for the next defender", () => { const state = live(); const carrier = onSite(state); carrier.input.use = true; run(state, PLANT_TIME + 0.2); expect(state.spike.planted).toBe(true); carrier.input.use = false; const first = def(state)[0]!; first.x = state.spike.x; first.y = state.spike.y; first.input.use = true; run(state, DEFUSE_CHECKPOINT + 0.2); expect(state.spike.checkpoint).toBe(DEFUSE_CHECKPOINT); expect(state.spike.defuse).toBeGreaterThanOrEqual(DEFUSE_CHECKPOINT); // He dies here. The defuse falls back to the checkpoint, not to zero. first.hp = 1; first.alive = false; first.deaths += 1; state.spike.defuse = state.spike.checkpoint; expect(state.spike.defuse).toBe(DEFUSE_CHECKPOINT); const second = def(state)[1]!; second.x = state.spike.x; second.y = state.spike.y; second.input.use = true; // Half a defuse is left, so well under the full seven seconds finishes it. run(state, DEFUSE_TIME - DEFUSE_CHECKPOINT + 0.3); expect(state.phase).toBe("end"); expect(state.events.some((e) => e.text.includes("defused"))).toBe(true); }); test("an interrupted defuse before the checkpoint starts again from nothing", () => { const state = live(); const carrier = onSite(state); carrier.input.use = true; run(state, PLANT_TIME + 0.2); carrier.input.use = false; const defender = def(state)[0]!; defender.x = state.spike.x; defender.y = state.spike.y; defender.input.use = true; run(state, 2); expect(state.spike.defuse).toBeGreaterThan(1.5); defender.input.use = false; run(state, 0.2); expect(state.spike.defuse).toBe(0); expect(state.spike.checkpoint).toBe(0); }); test("the spike detonating wins the round for the attackers", () => { const state = live(); const carrier = onSite(state); carrier.input.use = true; run(state, PLANT_TIME + 0.2); carrier.input.use = false; state.spike.fuse = 1; run(state, 1.5); expect(state.phase).toBe("end"); expect(state.score[state.attackers]).toBe(1); }); test("time running out with no plant is a defence win, but the clock stops mattering once it is down", () => { const timeout = live(); timeout.clock = 0.5; run(timeout, 1); expect(timeout.phase).toBe("end"); expect(timeout.score[timeout.attackers]).toBe(0); const planted = live(); const carrier = onSite(planted); carrier.input.use = true; run(planted, PLANT_TIME + 0.2); carrier.input.use = false; planted.clock = 0.2; run(planted, 1); expect(planted.phase).toBe("live"); }); test("eliminating a team ends the round, and a planted spike still wins it", () => { const wipe = live(); for (const player of def(wipe)) player.alive = false; run(wipe, 0.1); expect(wipe.phase).toBe("end"); expect(wipe.score[wipe.attackers]).toBe(1); const traded = live(); const carrier = onSite(traded); carrier.input.use = true; run(traded, PLANT_TIME + 0.2); for (const player of atk(traded)) player.alive = false; run(traded, 0.1); // Everyone is dead but the spike is armed: the round is not over yet. expect(traded.phase).toBe("live"); }); test("a buy is refused server-side when the credits are not there", () => { const state = newMatch(2); const player = state.players[0]!; player.agent = "ember"; player.credits = 800; expect(buyWeapon(state, player, "vandal")).toBe(false); expect(player.weapon).toBe("classic"); expect(buyWeapon(state, player, "ghost")).toBe(true); expect(player.credits).toBe(300); expect(buyShield(state, player, "heavy")).toBe(false); // 250 for the flash leaves fifty, and fifty buys nothing. expect(buyAbility(state, player, "c")).toBe(true); expect(player.credits).toBe(50); expect(buyAbility(state, player, "c")).toBe(false); expect(buyAbility(state, player, "q")).toBe(false); // And nothing is for sale once the round is live. player.credits = 9000; state.phase = "live"; expect(buyWeapon(state, player, "vandal")).toBe(false); }); test("a basic is bought by the charge and never past its limit", () => { const state = newMatch(2); const player = state.players.find((p) => p.agent === "veil")!; player.credits = 9000; expect(buyAbility(state, player, "c")).toBe(true); expect(buyAbility(state, player, "c")).toBe(true); expect(player.charges.c).toBe(2); expect(buyAbility(state, player, "c")).toBe(false); expect(buyAbility(state, player, "e")).toBe(false); expect(buyAbility(state, player, "x")).toBe(false); }); test("sides swap after twelve rounds and a thirteenth round wins the match", () => { const state = newMatch(4); const started = state.attackers; state.round = SWAP_AFTER; state.phase = "live"; state.score[started] = WIN_ROUNDS - 2; for (const player of def(state)) player.alive = false; run(state, 0.1); expect(state.phase).toBe("end"); run(state, 6); expect(state.round).toBe(SWAP_AFTER + 1); expect(state.attackers).not.toBe(started); // One more round, taken by whoever now attacks the old defenders' side. const winners = state.attackers === started ? started : state.attackers; state.phase = "live"; state.score[started] = WIN_ROUNDS - 1; for (const player of state.players) if (player.team !== started) player.alive = false; run(state, 0.1); expect(state.score[started]).toBe(WIN_ROUNDS); expect(state.phase).toBe("over"); expect(state.result).toContain(`${started} win`); expect(winners.length).toBeGreaterThan(0); }); test("an ended match never parks: it shows the result and starts a fresh one", () => { const state = newMatch(6); state.phase = "live"; state.score[state.attackers] = WIN_ROUNDS - 1; for (const player of def(state)) player.alive = false; run(state, 0.1); expect(state.phase).toBe("over"); expect(state.result).not.toBe(null); run(state, 13); expect(state.phase).toBe("buy"); expect(state.round).toBe(1); expect(state.score).toEqual({ red: 0, blue: 0 }); expect(state.result).toBe(null); expect(state.players.length).toBe(10); }); test("line of sight is the server's answer: a smoke hides an enemy in the open", () => { const state = live(); const viewer = atk(state)[0]!; const enemy = def(state)[0]!; viewer.x = 1200; viewer.y = 1320; enemy.x = 1200; enemy.y = 1000; expect(visible(state, viewer, enemy)).toBe(true); state.zones.push({ id: 99, kind: "smoke", owner: "x", side: "def", x: 1200, y: 1160, r: 150, life: 8, fuse: 0, beat: 0, pulses: 0, damage: 0, blocks: true, solid: false, }); expect(visible(state, viewer, enemy)).toBe(false); // A revealed enemy is seen through anything; so is a team mate. enemy.revealed = 2; expect(visible(state, viewer, enemy)).toBe(true); }); test("a wall hides two players from each other, and open floor does not", () => { const state = live(); const viewer = atk(state)[0]!; const enemy = def(state)[0]!; viewer.blind = 0; enemy.revealed = 0; // Either side of the block between A site and the middle of the map. viewer.x = 400; viewer.y = 400; enemy.x = 1200; enemy.y = 400; expect(wallBetween(viewer, enemy)).toBe(true); expect(visible(state, viewer, enemy)).toBe(false); // The same two down the open back corridor, with nothing in between. viewer.x = 400; viewer.y = 1400; enemy.x = 1000; enemy.y = 1400; expect(wallBetween(viewer, enemy)).toBe(false); expect(visible(state, viewer, enemy)).toBe(true); }); test("no spawn can see a spawn: mid is staggered, not a corridor", () => { for (const from of spawnCells("atk")) for (const to of spawnCells("def")) expect(wallBetween(from, to)).toBe(true); }); test("a dead player watches through the team, never through walls", () => { const state = live(); const viewer = atk(state)[0]!; const mate = atk(state)[1]!; const enemy = def(state)[0]!; for (const player of atk(state)) { player.alive = false; player.x = 400; player.y = 400; } enemy.revealed = 0; enemy.x = 1000; enemy.y = 1400; // Every attacker is dead: the enemy across the map is nobody's to see. expect(visible(state, viewer, enemy)).toBe(false); // One team mate alive with a sight line, and the dead player sees it too. mate.alive = true; mate.x = 400; mate.y = 1400; expect(visible(state, viewer, enemy)).toBe(true); // That team mate behind a wall takes the vision away again. mate.x = 400; mate.y = 400; expect(visible(state, viewer, enemy)).toBe(false); }); test("a blinded player sees nothing", () => { const state = live(); const viewer = atk(state)[0]!; const enemy = def(state)[0]!; viewer.x = 1200; viewer.y = 1320; enemy.x = 1200; enemy.y = 1000; viewer.blind = 1.4; expect(visible(state, viewer, enemy)).toBe(false); }); test("a killed carrier drops the spike where they fell", () => { const state = live(); const carrier = onSite(state); const killer = def(state)[0]!; killer.x = carrier.x + 40; killer.y = carrier.y; killer.angle = Math.PI; killer.weapon = "operator"; killer.input.fire = true; run(state, 1); if (!carrier.alive) { expect(state.spike.carried).toBe(null); expect(Math.hypot(state.spike.x - carrier.x, state.spike.y - carrier.y)).toBeLessThan(80); } expect(state.shots.length + state.events.length).toBeGreaterThan(0); }); test("bots play the round: they buy, they take a site and they use their kit", () => { const state = newMatch(42); let casts = 0; let rounds = 0; for (let i = 0; i < 30 * 60 * 6; i++) { runBots(state, 1 / 30); tick(state, 1 / 30); casts += state.zones.length > 0 ? 1 : 0; if (state.round > rounds) rounds = state.round; } expect(rounds).toBeGreaterThan(1); expect(state.score.red + state.score.blue).toBeGreaterThan(1); // Something was on the map: smokes, flashes, mollies, walls. expect(casts).toBeGreaterThan(100); // And somebody bought a real gun with the credits they won. expect(state.players.some((p) => p.weapon !== "classic")).toBe(true); expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0); }); test("no match ever has two players with the same name", () => { // The kill feed, the round log and the scoreboard all address people by // name; two Kits read as a team kill this game cannot have. A thousand // rosters, no collision. for (let seed = 0; seed < 1000; seed++) { const names = newMatch(seed).players.map((p) => p.name); expect(names.length).toBe(10); expect(new Set(names).size).toBe(10); } }); test("a seat made on its own still gets a name of its own", () => { const state = newMatch(1234); const names = new Set(); for (const team of ["red", "blue"] as const) for (let i = 0; i < 5; i++) names.add(makePlayer(state, team, i).name); expect(names.size).toBe(10); }); test("the deal is the seeded shuffle, so the same match replays the same", () => { expect(newMatch(77).players.map((p) => p.name)).toEqual(newMatch(77).players.map((p) => p.name)); expect(newMatch(77).players.map((p) => p.name)).not.toEqual(newMatch(78).players.map((p) => p.name)); });