pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Model the match: map, guns, credits, kits, round loop and bots
The simulation half of val, with no server attached yet: one grid map with two sites, a mid and a back corridor; the damage table by hit location with armour; credits with the rising loss bonus; five agent kits built out of flashes, smokes, mollies, darts, heals, walls and trips; and a round loop that plants, defuses with the half-way checkpoint, swaps at twelve and starts a fresh match the moment one ends. tick(state, dt) is the whole game and takes no clock, so the tests play whole matches headlessly — six minutes of five bots against five in a couple of hundred milliseconds. Co-Authored-By: Claude Opus 5 <[email protected]>
11 files
changed +2335
−0
src/abilities.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { AGENTS, agentById, buyableSlots, cast, castable, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; | |
| 3 | import { newMatch, startRound, tick } from "./match.ts"; | |
| 4 | import type { MatchState, Player, Zone } from "./state.ts"; | |
| 5 | ||
| 6 | const live = (): MatchState => { | |
| 7 | const state = newMatch(11); | |
| 8 | state.phase = "live"; | |
| 9 | state.clock = 100; | |
| 10 | return state; | |
| 11 | }; | |
| 12 | const attacker = (state: MatchState): Player => state.players.find((p) => p.side === "atk")!; | |
| 13 | ||
| 14 | test("every agent has two bought basics, a free signature and an ultimate", () => { | |
| 15 | expect(AGENTS.length).toBe(5); | |
| 16 | for (const agent of AGENTS) { | |
| 17 | for (const slot of SLOTS) expect(agent.abilities[slot].name.length).toBeGreaterThan(0); | |
| 18 | for (const slot of buyableSlots) expect(agent.abilities[slot].cost).toBeGreaterThan(0); | |
| 19 | expect(agent.abilities.e.cost).toBe(0); | |
| 20 | expect(agent.abilities.e.cooldown).toBeGreaterThan(0); | |
| 21 | expect(agent.abilities.x.points).toBeGreaterThanOrEqual(6); | |
| 22 | } | |
| 23 | }); | |
| 24 | ||
| 25 | test("a flash blinds by the angle you were facing", () => { | |
| 26 | const at = { x: 1000, y: 1000 }; | |
| 27 | const staring = { x: 1200, y: 1000 }; | |
| 28 | const facingIt = Math.PI; // looking back down the line at the flash | |
| 29 | const facingAway = 0; | |
| 30 | expect(flashDuration(1.7, staring, at, facingIt, false)).toBeGreaterThan(1.6); | |
| 31 | expect(flashDuration(1.7, staring, at, facingAway, false)).toBe(0); | |
| 32 | // Half turned away is a shorter blind than staring straight at it. | |
| 33 | const glance = flashDuration(1.7, staring, at, facingIt - 1.2, false); | |
| 34 | expect(glance).toBeGreaterThan(0); | |
| 35 | expect(glance).toBeLessThan(1.7); | |
| 36 | }); | |
| 37 | ||
| 38 | test("a wall between you and the flash means no blind at all", () => { | |
| 39 | const blind = flashDuration(1.7, { x: 1200, y: 1000 }, { x: 1000, y: 1000 }, Math.PI, true); | |
| 40 | expect(blind).toBe(0); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a smoke blocks the sight line for as long as it lives", () => { | |
| 44 | const smoke: Zone = { | |
| 45 | id: 1, kind: "smoke", owner: "x", side: "atk", x: 1200, y: 1000, r: 140, | |
| 46 | life: 8, fuse: 0, beat: 0, pulses: 0, damage: 0, blocks: true, solid: false, | |
| 47 | }; | |
| 48 | // Straight down the mid corridor, which is open floor end to end. | |
| 49 | const from = { x: 1100, y: 1000 }; | |
| 50 | const to = { x: 1300, y: 1000 }; | |
| 51 | expect(sightBlocked(from, to, [])).toBe(false); | |
| 52 | expect(sightBlocked(from, to, [smoke])).toBe(true); | |
| 53 | // Standing off the line, the smoke is not in the way. | |
| 54 | expect(sightBlocked({ x: 1100, y: 700 }, { x: 1300, y: 700 }, [smoke])).toBe(false); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a charge is spent on a cast and refused when there is none", () => { | |
| 58 | const state = live(); | |
| 59 | const player = attacker(state); | |
| 60 | player.agent = "veil"; | |
| 61 | player.charges.c = 1; | |
| 62 | expect(cast(state, player, "c")).toBe(true); | |
| 63 | expect(player.charges.c).toBe(0); | |
| 64 | expect(state.zones.some((z) => z.kind === "smoke")).toBe(true); | |
| 65 | expect(cast(state, player, "c")).toBe(false); | |
| 66 | expect(castable(player, "c")).toBe(false); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the signature goes on cooldown and comes back on its own", () => { | |
| 70 | const state = live(); | |
| 71 | const player = attacker(state); | |
| 72 | player.agent = "veil"; | |
| 73 | const cooldown = agentById("veil").abilities.e.cooldown; | |
| 74 | expect(cast(state, player, "e")).toBe(true); | |
| 75 | expect(player.charges.e).toBe(0); | |
| 76 | expect(player.cooldown.e).toBe(cooldown); | |
| 77 | for (let i = 0; i < Math.ceil(cooldown * 10) + 4; i++) tick(state, 0.1); | |
| 78 | expect(player.cooldown.e).toBe(0); | |
| 79 | expect(castable(player, "e")).toBe(true); | |
| 80 | }); | |
| 81 | ||
| 82 | test("an ultimate costs its points and empties the meter", () => { | |
| 83 | const state = live(); | |
| 84 | const player = attacker(state); | |
| 85 | player.agent = "sable"; | |
| 86 | player.ult = 3; | |
| 87 | expect(cast(state, player, "x")).toBe(false); | |
| 88 | player.ult = 7; | |
| 89 | expect(cast(state, player, "x")).toBe(true); | |
| 90 | expect(player.ult).toBe(0); | |
| 91 | // Hunt reveals every enemy and leaves the caster's own side alone. | |
| 92 | for (const other of state.players) expect(other.revealed > 0).toBe(other.side !== player.side); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a molly burns whoever stands in it and leaves the thrower's team alone", () => { | |
| 96 | const state = live(); | |
| 97 | const player = attacker(state); | |
| 98 | player.agent = "ember"; | |
| 99 | player.charges.q = 1; | |
| 100 | const victim = state.players.find((p) => p.side === "def")!; | |
| 101 | const mate = state.players.find((p) => p.side === "atk" && p.id !== player.id)!; | |
| 102 | victim.x = player.x + 120; | |
| 103 | victim.y = player.y; | |
| 104 | mate.x = player.x + 120; | |
| 105 | mate.y = player.y; | |
| 106 | player.angle = 0; | |
| 107 | player.x = victim.x - 120; | |
| 108 | cast(state, player, "q"); | |
| 109 | for (const zone of state.zones) { | |
| 110 | zone.x = victim.x; | |
| 111 | zone.y = victim.y; | |
| 112 | } | |
| 113 | for (let i = 0; i < 20; i++) tick(state, 0.1); | |
| 114 | expect(victim.hp).toBeLessThan(100); | |
| 115 | expect(mate.hp).toBe(100); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a wall is solid: it blocks the sight line that was open", () => { | |
| 119 | const state = live(); | |
| 120 | const player = attacker(state); | |
| 121 | player.agent = "rook"; | |
| 122 | player.x = 1200; | |
| 123 | player.y = 1320; | |
| 124 | player.angle = -Math.PI / 2; | |
| 125 | const behind = { x: 1200, y: 1320 - 600 }; | |
| 126 | const openBefore = !sightBlocked(player, behind, state.zones); | |
| 127 | expect(cast(state, player, "e")).toBe(true); | |
| 128 | expect(openBefore).toBe(true); | |
| 129 | expect(sightBlocked(player, behind, state.zones)).toBe(true); | |
| 130 | expect(state.zones.every((z) => z.solid)).toBe(true); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a round starts with the signature ready and no bought charges", () => { | |
| 134 | const state = newMatch(3); | |
| 135 | startRound(state, true); | |
| 136 | for (const player of state.players) { | |
| 137 | expect(player.charges.c).toBe(0); | |
| 138 | expect(player.charges.q).toBe(0); | |
| 139 | expect(player.charges.e).toBe(1); | |
| 140 | } | |
| 141 | }); |
src/abilities.ts added +273
| @@ -0,0 +1,273 @@ | ||
| 1 | /** | |
| 2 | * Agents and their kits. | |
| 3 | * | |
| 4 | * Five agents, each with two basics bought with credits (charges last the | |
| 5 | * round), one signature that is free and comes back on a cooldown, and one | |
| 6 | * ultimate paid for in ult points. The effects themselves are a short list of | |
| 7 | * kinds — flash, smoke, molly, dart, heal, wall, trip — so an agent is data, | |
| 8 | * not code. | |
| 9 | * | |
| 10 | * The two rules worth testing on their own: a flash blinds by the angle you | |
| 11 | * were facing and only with line of sight, and a smoke blocks that sight. | |
| 12 | */ | |
| 13 | ||
| 14 | import { rayHitWall, segmentHitsCircle, wallBetween, type Point } from "./map.ts"; | |
| 15 | import type { MatchState, Player, Slot, Zone } from "./state.ts"; | |
| 16 | ||
| 17 | export type Kind = | |
| 18 | | "flash" | |
| 19 | | "smoke" | |
| 20 | | "molly" | |
| 21 | | "dart" | |
| 22 | | "heal" | |
| 23 | | "wall" | |
| 24 | | "trip" | |
| 25 | | "haste" | |
| 26 | | "rage" | |
| 27 | | "revealall" | |
| 28 | | "slowall"; | |
| 29 | ||
| 30 | export type Ability = { | |
| 31 | slot: Slot; | |
| 32 | name: string; | |
| 33 | kind: Kind; | |
| 34 | /** Credits for a basic; 0 for the signature and the ultimate. */ | |
| 35 | cost: number; | |
| 36 | charges: number; | |
| 37 | /** Seconds the signature takes to come back. 0 means it does not. */ | |
| 38 | cooldown: number; | |
| 39 | /** Ult points the ultimate costs. */ | |
| 40 | points: number; | |
| 41 | range: number; | |
| 42 | radius: number; | |
| 43 | duration: number; | |
| 44 | damage: number; | |
| 45 | fuse: number; | |
| 46 | text: string; | |
| 47 | }; | |
| 48 | ||
| 49 | export type Agent = { | |
| 50 | id: string; | |
| 51 | name: string; | |
| 52 | role: string; | |
| 53 | blurb: string; | |
| 54 | abilities: Record<Slot, Ability>; | |
| 55 | }; | |
| 56 | ||
| 57 | const basic = (a: Partial<Ability> & { slot: Slot; name: string; kind: Kind; text: string }): Ability => ({ | |
| 58 | cost: 200, | |
| 59 | charges: 1, | |
| 60 | cooldown: 0, | |
| 61 | points: 0, | |
| 62 | range: 700, | |
| 63 | radius: 110, | |
| 64 | duration: 10, | |
| 65 | damage: 0, | |
| 66 | fuse: 0, | |
| 67 | ...a, | |
| 68 | }); | |
| 69 | ||
| 70 | export const AGENTS: Agent[] = [ | |
| 71 | { | |
| 72 | id: "ember", | |
| 73 | name: "Ember", | |
| 74 | role: "Duelist", | |
| 75 | blurb: "Flash in, burn them off the plant, come back from nothing.", | |
| 76 | abilities: { | |
| 77 | c: basic({ slot: "c", name: "Flashfire", kind: "flash", cost: 250, charges: 2, range: 620, radius: 900, fuse: 0.55, duration: 1.7, text: "Throws a flash that pops after half a second and blinds anyone looking near it." }), | |
| 78 | q: basic({ slot: "q", name: "Blaze", kind: "molly", cost: 200, charges: 1, range: 620, radius: 95, duration: 6, damage: 26, text: "A pool of fire: 26 damage a second to anyone standing in it." }), | |
| 79 | e: basic({ slot: "e", name: "Second Wind", kind: "haste", cost: 0, charges: 1, cooldown: 35, duration: 4, text: "Signature. Four seconds of speed — take the angle before they re-peek." }), | |
| 80 | x: basic({ slot: "x", name: "Rebirth", kind: "rage", cost: 0, charges: 0, points: 6, duration: 6, text: "Ultimate. Full health, armour back, every basic charge refilled, six seconds of speed." }), | |
| 81 | }, | |
| 82 | }, | |
| 83 | { | |
| 84 | id: "veil", | |
| 85 | name: "Veil", | |
| 86 | role: "Controller", | |
| 87 | blurb: "Takes the sightline away and gives the site to whoever she likes.", | |
| 88 | abilities: { | |
| 89 | c: basic({ slot: "c", name: "Cloudburst", kind: "smoke", cost: 100, charges: 2, range: 760, radius: 105, duration: 8, text: "A short smoke. Cheap enough to throw two on a pistol round." }), | |
| 90 | q: basic({ slot: "q", name: "Blind Spot", kind: "flash", cost: 250, charges: 1, range: 600, radius: 850, fuse: 0.5, duration: 1.6, text: "A flash for the entry you are about to make." }), | |
| 91 | e: basic({ slot: "e", name: "Curtain", kind: "smoke", cost: 0, charges: 1, cooldown: 35, range: 900, radius: 150, duration: 15, text: "Signature. A wide smoke for fifteen seconds — nobody sees through it, server side." }), | |
| 92 | x: basic({ slot: "x", name: "Blackout", kind: "smoke", cost: 0, charges: 0, points: 7, range: 1100, radius: 280, duration: 12, damage: -1, text: "Ultimate. A site-sized smoke that slows every enemy inside it." }), | |
| 93 | }, | |
| 94 | }, | |
| 95 | { | |
| 96 | id: "sable", | |
| 97 | name: "Sable", | |
| 98 | role: "Initiator", | |
| 99 | blurb: "Finds them before the team walks in, and moves them off the angle.", | |
| 100 | abilities: { | |
| 101 | c: basic({ slot: "c", name: "Recon Dart", kind: "dart", cost: 250, charges: 1, range: 900, radius: 340, duration: 6, text: "Sticks and sweeps three times, revealing enemies it can see for two seconds each." }), | |
| 102 | q: basic({ slot: "q", name: "Shock Bolt", kind: "molly", cost: 150, charges: 2, range: 820, radius: 90, duration: 1.1, damage: 70, text: "A bolt that cracks for 70 a second — clears a corner or finishes a plant." }), | |
| 103 | e: basic({ slot: "e", name: "Owl Flash", kind: "flash", cost: 0, charges: 1, cooldown: 40, range: 640, radius: 900, fuse: 0.6, duration: 1.6, text: "Signature. A flash on a cooldown, so there is always one for the retake." }), | |
| 104 | x: basic({ slot: "x", name: "Hunt", kind: "revealall", cost: 0, charges: 0, points: 7, duration: 6, text: "Ultimate. Every enemy on the map is revealed for six seconds." }), | |
| 105 | }, | |
| 106 | }, | |
| 107 | { | |
| 108 | id: "mender", | |
| 109 | name: "Mender", | |
| 110 | role: "Sentinel", | |
| 111 | blurb: "Holds the flank shut and keeps the entry alive long enough to trade.", | |
| 112 | abilities: { | |
| 113 | c: basic({ slot: "c", name: "Healing Pulse", kind: "heal", cost: 200, charges: 2, range: 260, radius: 260, duration: 3, damage: 60, text: "Heals you or the nearest hurt ally 60 health over three seconds." }), | |
| 114 | q: basic({ slot: "q", name: "Barrier", kind: "wall", cost: 400, charges: 1, range: 520, radius: 120, duration: 12, text: "A solid wall for twelve seconds: nothing walks through it and nothing sees through it." }), | |
| 115 | e: basic({ slot: "e", name: "Tripwire", kind: "trip", cost: 0, charges: 1, cooldown: 40, range: 300, radius: 120, duration: 45, text: "Signature. Watches the flank: the enemy who walks in is revealed and slowed." }), | |
| 116 | x: basic({ slot: "x", name: "Lockdown", kind: "slowall", cost: 0, charges: 0, points: 8, duration: 5, text: "Ultimate. Every enemy is slowed and revealed for five seconds." }), | |
| 117 | }, | |
| 118 | }, | |
| 119 | { | |
| 120 | id: "rook", | |
| 121 | name: "Rook", | |
| 122 | role: "Initiator", | |
| 123 | blurb: "Builds the angle he wants and sets the site on fire when it is lost.", | |
| 124 | abilities: { | |
| 125 | c: basic({ slot: "c", name: "Smoke Grenade", kind: "smoke", cost: 150, charges: 1, range: 800, radius: 130, duration: 10, text: "One smoke, thrown long." }), | |
| 126 | q: basic({ slot: "q", name: "Flashbang", kind: "flash", cost: 200, charges: 2, range: 600, radius: 800, fuse: 0.6, duration: 1.5, text: "Two flashes: one for the entry, one for the exit." }), | |
| 127 | e: basic({ slot: "e", name: "Stone Wall", kind: "wall", cost: 0, charges: 1, cooldown: 45, range: 480, radius: 130, duration: 10, text: "Signature. A free wall, back every forty-five seconds." }), | |
| 128 | x: basic({ slot: "x", name: "Firestorm", kind: "molly", cost: 0, charges: 0, points: 7, range: 900, radius: 210, duration: 8, damage: 34, text: "Ultimate. A site-wide fire nobody defuses through." }), | |
| 129 | }, | |
| 130 | }, | |
| 131 | ]; | |
| 132 | ||
| 133 | export const agentById = (id: string): Agent => AGENTS.find((a) => a.id === id) ?? AGENTS[0]!; | |
| 134 | export const isAgentId = (value: unknown): value is string => | |
| 135 | typeof value === "string" && AGENTS.some((a) => a.id === value); | |
| 136 | ||
| 137 | export const SLOTS: Slot[] = ["c", "q", "e", "x"]; | |
| 138 | /** Bought before the round; the signature and the ultimate are never for sale. */ | |
| 139 | export const buyableSlots: Slot[] = ["c", "q"]; | |
| 140 | ||
| 141 | /** | |
| 142 | * How long a flash blinds, in seconds. Facing it is the worst case; looking | |
| 143 | * away is free; a wall or a smoke between you and it means nothing at all. | |
| 144 | * `blocked` is the caller's line-of-sight answer, so this stays pure. | |
| 145 | */ | |
| 146 | export function flashDuration( | |
| 147 | full: number, | |
| 148 | from: Point, | |
| 149 | at: Point, | |
| 150 | facing: number, | |
| 151 | blocked: boolean, | |
| 152 | ): number { | |
| 153 | if (blocked) return 0; | |
| 154 | const toFlash = Math.atan2(at.y - from.y, at.x - from.x); | |
| 155 | let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI); | |
| 156 | off = Math.min(off, Math.PI); | |
| 157 | const wide = (100 * Math.PI) / 180; | |
| 158 | if (off > wide) return 0; | |
| 159 | const narrow = (45 * Math.PI) / 180; | |
| 160 | const facingFactor = off <= narrow ? 1 : 1 - ((off - narrow) / (wide - narrow)) * 0.75; | |
| 161 | const distance = Math.hypot(from.x - at.x, from.y - at.y); | |
| 162 | const near = distance < 900 ? 1 : distance < 1500 ? 0.6 : 0.35; | |
| 163 | return Math.round(full * facingFactor * near * 100) / 100; | |
| 164 | } | |
| 165 | ||
| 166 | /** Whether a sight line survives the smokes and walls standing on the map. */ | |
| 167 | export const sightBlocked = (from: Point, to: Point, zones: Zone[]): boolean => { | |
| 168 | if (wallBetween(from, to)) return true; | |
| 169 | for (const zone of zones) | |
| 170 | if (zone.blocks && zone.fuse <= 0 && segmentHitsCircle(from, to, zone, zone.r)) return true; | |
| 171 | return false; | |
| 172 | }; | |
| 173 | ||
| 174 | /** Can this player cast this slot right now, and why not. */ | |
| 175 | export function castable(player: Player, slot: Slot): boolean { | |
| 176 | if (!player.alive) return false; | |
| 177 | const ability = agentById(player.agent).abilities[slot]; | |
| 178 | if (slot === "x") return player.ult >= ability.points; | |
| 179 | if (player.cooldown[slot] > 0) return false; | |
| 180 | return player.charges[slot] > 0; | |
| 181 | } | |
| 182 | ||
| 183 | /** Where an ability lands: down the aim line, stopped by the first wall. */ | |
| 184 | export const castPoint = (player: Player, range: number): Point => | |
| 185 | rayHitWall(player, player.angle, range); | |
| 186 | ||
| 187 | const zone = (state: MatchState, z: Omit<Zone, "id">): Zone => { | |
| 188 | const made = { ...z, id: state.nextZone++ }; | |
| 189 | state.zones.push(made); | |
| 190 | return made; | |
| 191 | }; | |
| 192 | ||
| 193 | /** | |
| 194 | * Spend the charge and put the effect on the map. Returns false when the | |
| 195 | * cast was refused — no charge, still on cooldown, not enough ult points — | |
| 196 | * so the caller never has to guess. | |
| 197 | */ | |
| 198 | export function cast(state: MatchState, player: Player, slot: Slot): boolean { | |
| 199 | if (!castable(player, slot)) return false; | |
| 200 | const agent = agentById(player.agent); | |
| 201 | const ability = agent.abilities[slot]; | |
| 202 | const spot = castPoint(player, ability.range); | |
| 203 | const base = { owner: player.id, side: player.side, life: ability.duration, fuse: ability.fuse, beat: 0, pulses: 0, damage: ability.damage, blocks: false, solid: false }; | |
| 204 | ||
| 205 | switch (ability.kind) { | |
| 206 | case "flash": | |
| 207 | zone(state, { ...base, kind: "flash", x: spot.x, y: spot.y, r: ability.radius, life: ability.fuse + 0.2, damage: ability.duration }); | |
| 208 | break; | |
| 209 | case "smoke": | |
| 210 | zone(state, { ...base, kind: "smoke", x: spot.x, y: spot.y, r: ability.radius, blocks: true }); | |
| 211 | break; | |
| 212 | case "molly": | |
| 213 | zone(state, { ...base, kind: "molly", x: spot.x, y: spot.y, r: ability.radius, beat: 0.5 }); | |
| 214 | break; | |
| 215 | case "dart": | |
| 216 | zone(state, { ...base, kind: "dart", x: spot.x, y: spot.y, r: ability.radius, beat: 0.6, pulses: 3 }); | |
| 217 | break; | |
| 218 | case "trip": | |
| 219 | zone(state, { ...base, kind: "trip", x: spot.x, y: spot.y, r: ability.radius, fuse: 1 }); | |
| 220 | break; | |
| 221 | case "heal": { | |
| 222 | const hurt = state.players | |
| 223 | .filter((p) => p.alive && p.side === player.side && p.hp < 100 && Math.hypot(p.x - player.x, p.y - player.y) <= ability.range) | |
| 224 | .sort((a, b) => a.hp - b.hp)[0]; | |
| 225 | const target = hurt ?? player; | |
| 226 | zone(state, { ...base, kind: "heal", x: target.x, y: target.y, r: 40, beat: 0.5, owner: target.id }); | |
| 227 | break; | |
| 228 | } | |
| 229 | case "wall": { | |
| 230 | // Three blockers across the aim line: solid to walk through and to see | |
| 231 | // through, and made of circles so every other test still works. | |
| 232 | const across = player.angle + Math.PI / 2; | |
| 233 | for (let i = -1; i <= 1; i++) | |
| 234 | zone(state, { | |
| 235 | ...base, | |
| 236 | kind: "wall", | |
| 237 | x: spot.x + Math.cos(across) * i * (ability.radius * 0.7), | |
| 238 | y: spot.y + Math.sin(across) * i * (ability.radius * 0.7), | |
| 239 | r: ability.radius * 0.45, | |
| 240 | blocks: true, | |
| 241 | solid: true, | |
| 242 | }); | |
| 243 | break; | |
| 244 | } | |
| 245 | case "haste": | |
| 246 | player.haste = Math.max(player.haste, ability.duration); | |
| 247 | break; | |
| 248 | case "rage": | |
| 249 | player.hp = 100; | |
| 250 | player.armour = Math.max(player.armour, 25); | |
| 251 | player.haste = Math.max(player.haste, ability.duration); | |
| 252 | for (const s of buyableSlots) player.charges[s] = Math.max(player.charges[s], agent.abilities[s].charges); | |
| 253 | break; | |
| 254 | case "revealall": | |
| 255 | for (const other of state.players) | |
| 256 | if (other.side !== player.side) other.revealed = Math.max(other.revealed, ability.duration); | |
| 257 | break; | |
| 258 | case "slowall": | |
| 259 | for (const other of state.players) | |
| 260 | if (other.side !== player.side) { | |
| 261 | other.revealed = Math.max(other.revealed, ability.duration); | |
| 262 | other.slow = Math.max(other.slow, ability.duration); | |
| 263 | } | |
| 264 | break; | |
| 265 | } | |
| 266 | ||
| 267 | if (slot === "x") player.ult = 0; | |
| 268 | else { | |
| 269 | player.charges[slot] = Math.max(0, player.charges[slot] - 1); | |
| 270 | if (ability.cooldown > 0) player.cooldown[slot] = ability.cooldown; | |
| 271 | } | |
| 272 | return true; | |
| 273 | } |
src/bots.ts added +257
| @@ -0,0 +1,257 @@ | ||
| 1 | /** | |
| 2 | * Bots. They fill the seats nobody is sitting in, and they use their kit: | |
| 3 | * a smoke on the way in, a flash before the entry, a dart to find the hold, | |
| 4 | * a molly on the planted spike, an ultimate when it is worth something. | |
| 5 | * | |
| 6 | * A bot only ever writes to its own `input`, the same field a browser writes | |
| 7 | * to, so the match loop cannot tell them apart and neither can a cheat. | |
| 8 | */ | |
| 9 | ||
| 10 | import { agentById, castable } from "./abilities.ts"; | |
| 11 | import { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts"; | |
| 12 | import { buyAbility, buyShield, buyWeapon, random, SPIKE_REACH, visible } from "./match.ts"; | |
| 13 | import type { MatchState, Player, Slot } from "./state.ts"; | |
| 14 | import { WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 15 | ||
| 16 | /** | |
| 17 | * The site the attacking bots agree on this round. It comes from the match | |
| 18 | * id and the round number, never the running seed: five bots have to reach | |
| 19 | * the same answer on every tick or they walk in circles between the sites. | |
| 20 | */ | |
| 21 | export function attackSite(state: MatchState): Site { | |
| 22 | let hash = 0; | |
| 23 | for (const ch of state.id) hash = (hash * 31 + ch.charCodeAt(0)) % 9973; | |
| 24 | return (hash + state.round) % 2 === 0 ? "A" : "B"; | |
| 25 | } | |
| 26 | ||
| 27 | const holdSpots = (state: MatchState): Point[] => { | |
| 28 | const a = siteCentre("A"); | |
| 29 | const b = siteCentre("B"); | |
| 30 | return [ | |
| 31 | { x: a.x + 60, y: a.y + 90 }, | |
| 32 | { x: a.x - 40, y: a.y - 20 }, | |
| 33 | { x: 1200, y: 640 }, | |
| 34 | { x: b.x - 60, y: b.y + 90 }, | |
| 35 | { x: b.x + 40, y: b.y - 20 }, | |
| 36 | ]; | |
| 37 | }; | |
| 38 | ||
| 39 | const ladder: { weapon: WeaponId; shield: "light" | "heavy" | null; floor: number }[] = [ | |
| 40 | { weapon: "operator", shield: "heavy", floor: 6200 }, | |
| 41 | { weapon: "vandal", shield: "heavy", floor: 4400 }, | |
| 42 | { weapon: "phantom", shield: "heavy", floor: 4200 }, | |
| 43 | { weapon: "spectre", shield: "heavy", floor: 2900 }, | |
| 44 | { weapon: "spectre", shield: "light", floor: 2100 }, | |
| 45 | { weapon: "sheriff", shield: "light", floor: 1300 }, | |
| 46 | { weapon: "ghost", shield: "light", floor: 950 }, | |
| 47 | { weapon: "classic", shield: "light", floor: 400 }, | |
| 48 | ]; | |
| 49 | ||
| 50 | /** | |
| 51 | * What a bot buys. It saves on a broken economy — under 2000 with an empty | |
| 52 | * gun rack is an eco round, and the credits are worth more next round. | |
| 53 | */ | |
| 54 | export function botBuy(state: MatchState, player: Player): void { | |
| 55 | const saving = player.credits < 2000 && state.round > 1 && player.weapon === "classic" && state.lossStreak[player.team] > 0; | |
| 56 | if (!saving) { | |
| 57 | const pick = ladder.find((step) => player.credits >= step.floor); | |
| 58 | if (pick && WEAPONS[pick.weapon].cost > 0) buyWeapon(state, player, pick.weapon); | |
| 59 | if (pick?.shield) buyShield(state, player, player.credits > 3500 ? "heavy" : pick.shield); | |
| 60 | } | |
| 61 | const agent = agentById(player.agent); | |
| 62 | // Abilities last: a flash is worth more than the last hundred credits. | |
| 63 | for (const slot of ["c", "q", "c", "q"] as Slot[]) { | |
| 64 | const ability = agent.abilities[slot]; | |
| 65 | if (player.credits >= ability.cost + (saving ? 400 : 0)) buyAbility(state, player, slot); | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | const enemiesSeen = (state: MatchState, bot: Player): Player[] => | |
| 70 | state.players.filter((p) => p.alive && p.side !== bot.side && visible(state, bot, p)); | |
| 71 | ||
| 72 | const towards = (bot: Player, point: Point): { mx: number; my: number } => { | |
| 73 | const dx = point.x - bot.x; | |
| 74 | const dy = point.y - bot.y; | |
| 75 | const len = Math.hypot(dx, dy) || 1; | |
| 76 | return { mx: dx / len, my: dy / len }; | |
| 77 | }; | |
| 78 | ||
| 79 | const turn = (bot: Player, target: number, rate: number): void => { | |
| 80 | let diff = ((target - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 81 | bot.input.angle = bot.angle + Math.max(-rate, Math.min(rate, diff)); | |
| 82 | }; | |
| 83 | ||
| 84 | /** One bot's decision for this tick. */ | |
| 85 | export function botThink(state: MatchState, bot: Player, dt: number): void { | |
| 86 | if (!bot.alive) return; | |
| 87 | if (state.phase === "buy") { | |
| 88 | if (bot.think <= 0) { | |
| 89 | botBuy(state, bot); | |
| 90 | bot.think = 99; | |
| 91 | } | |
| 92 | bot.input.mx = 0; | |
| 93 | bot.input.my = 0; | |
| 94 | return; | |
| 95 | } | |
| 96 | // The buy left the timer parked; the first live tick starts it thinking. | |
| 97 | if (bot.think > 5) bot.think = 0; | |
| 98 | bot.think -= dt; | |
| 99 | const spike = state.spike; | |
| 100 | const site = spike.planted && spike.site ? spike.site : attackSite(state); | |
| 101 | const target = enemiesSeen(state, bot).sort( | |
| 102 | (a, b) => Math.hypot(a.x - bot.x, a.y - bot.y) - Math.hypot(b.x - bot.x, b.y - bot.y), | |
| 103 | )[0]; | |
| 104 | ||
| 105 | // --- where to be | |
| 106 | let goal: Point = siteCentre(site); | |
| 107 | if (bot.side === "atk") { | |
| 108 | if (spike.planted) goal = { x: spike.x + (random(state) - 0.5) * 220, y: spike.y + (random(state) - 0.5) * 220 }; | |
| 109 | else if (!spike.carried && !bot.spike) { | |
| 110 | const loose = { x: spike.x, y: spike.y }; | |
| 111 | if (walkDistance(bot, loose) < 8) goal = loose; | |
| 112 | } | |
| 113 | } else { | |
| 114 | const spots = holdSpots(state); | |
| 115 | const index = Number(bot.id.slice(-1)) % spots.length; | |
| 116 | goal = spike.planted ? { x: spike.x, y: spike.y } : spots[index]!; | |
| 117 | } | |
| 118 | bot.goal = goal; | |
| 119 | ||
| 120 | const distToGoal = Math.hypot(goal.x - bot.x, goal.y - bot.y); | |
| 121 | const step = pathStep(bot, goal); | |
| 122 | const walk = towards(bot, step); | |
| 123 | bot.input.mx = walk.mx; | |
| 124 | bot.input.my = walk.my; | |
| 125 | ||
| 126 | // --- the kit | |
| 127 | if (bot.think <= 0) { | |
| 128 | bot.think = 0.4 + random(state) * 0.5; | |
| 129 | useKit(state, bot, target ?? null, site, distToGoal); | |
| 130 | } | |
| 131 | ||
| 132 | // --- the spike | |
| 133 | bot.input.use = false; | |
| 134 | if (bot.side === "atk" && bot.spike && !spike.planted) { | |
| 135 | const onSite = siteAt(bot.x, bot.y) === site; | |
| 136 | if (onSite && (!target || bot.planting > 0)) { | |
| 137 | bot.input.use = true; | |
| 138 | bot.input.mx = 0; | |
| 139 | bot.input.my = 0; | |
| 140 | } | |
| 141 | } | |
| 142 | if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < SPIKE_REACH - 6) { | |
| 143 | const safe = !target || spike.fuse < 8; | |
| 144 | if (safe) { | |
| 145 | bot.input.use = true; | |
| 146 | bot.input.mx = 0; | |
| 147 | bot.input.my = 0; | |
| 148 | } | |
| 149 | } | |
| 150 | if (bot.blind > 0) { | |
| 151 | // Blinded: back off and stop shooting, like a person would. | |
| 152 | bot.input.fire = false; | |
| 153 | bot.input.mx *= -0.6; | |
| 154 | bot.input.my *= -0.6; | |
| 155 | return; | |
| 156 | } | |
| 157 | ||
| 158 | // --- the gun | |
| 159 | bot.input.reload = bot.ammo === 0; | |
| 160 | if (!target) { | |
| 161 | bot.input.fire = false; | |
| 162 | if (Math.hypot(bot.input.mx, bot.input.my) > 0.01) | |
| 163 | turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt); | |
| 164 | return; | |
| 165 | } | |
| 166 | const range = Math.hypot(target.x - bot.x, target.y - bot.y); | |
| 167 | const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + (random(state) - 0.5) * 0.06; | |
| 168 | turn(bot, aim, 7 * dt); | |
| 169 | const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI); | |
| 170 | const weapon = WEAPONS[bot.weapon]; | |
| 171 | bot.input.fire = off < 0.07 && range < weapon.range && bot.ammo > 0; | |
| 172 | // Stand still to shoot at range, close the gap up front. | |
| 173 | if (range > 420) { | |
| 174 | bot.input.mx *= 0.15; | |
| 175 | bot.input.my *= 0.15; | |
| 176 | } else if (range < 160) { | |
| 177 | const away = towards(bot, target); | |
| 178 | bot.input.mx = -away.mx * 0.5; | |
| 179 | bot.input.my = -away.my * 0.5; | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | /** Ability decisions, kept in one place so each is a sentence you can read. */ | |
| 184 | function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void { | |
| 185 | const agent = agentById(bot.agent); | |
| 186 | const spike = state.spike; | |
| 187 | const centre = siteCentre(site); | |
| 188 | const aimAt = (point: Point): void => { | |
| 189 | bot.input.angle = Math.atan2(point.y - bot.y, point.x - bot.x); | |
| 190 | bot.angle = bot.input.angle; | |
| 191 | }; | |
| 192 | const tryCast = (slot: Slot, point: Point): boolean => { | |
| 193 | if (!castable(bot, slot)) return false; | |
| 194 | aimAt(point); | |
| 195 | bot.input.cast = slot; | |
| 196 | return true; | |
| 197 | }; | |
| 198 | ||
| 199 | for (const slot of ["x", "c", "q", "e"] as Slot[]) { | |
| 200 | const ability = agent.abilities[slot]; | |
| 201 | if (!castable(bot, slot)) continue; | |
| 202 | const kind = ability.kind; | |
| 203 | ||
| 204 | if (kind === "rage" || kind === "haste") { | |
| 205 | if (bot.hp < 70 || (target && distToGoal < 500)) return void tryCast(slot, centre); | |
| 206 | continue; | |
| 207 | } | |
| 208 | if (kind === "revealall" || kind === "slowall") { | |
| 209 | if (target || distToGoal < 420) return void tryCast(slot, centre); | |
| 210 | continue; | |
| 211 | } | |
| 212 | if (kind === "heal") { | |
| 213 | const hurt = state.players.find((p) => p.alive && p.side === bot.side && p.hp <= 60 && Math.hypot(p.x - bot.x, p.y - bot.y) < ability.range); | |
| 214 | if (hurt) return void tryCast(slot, hurt); | |
| 215 | continue; | |
| 216 | } | |
| 217 | if (kind === "molly") { | |
| 218 | // Fire on the planted spike, or on the choke ahead of us. | |
| 219 | if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range) | |
| 220 | return void tryCast(slot, spike); | |
| 221 | if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && Math.hypot(target.x - bot.x, target.y - bot.y) > 120) | |
| 222 | return void tryCast(slot, target); | |
| 223 | continue; | |
| 224 | } | |
| 225 | if (kind === "flash") { | |
| 226 | const enemyClose = target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range; | |
| 227 | if (enemyClose) return void tryCast(slot, target!); | |
| 228 | if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 150) return void tryCast(slot, centre); | |
| 229 | continue; | |
| 230 | } | |
| 231 | if (kind === "smoke") { | |
| 232 | if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 260) return void tryCast(slot, centre); | |
| 233 | if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range && Math.hypot(bot.x - spike.x, bot.y - spike.y) > 200) | |
| 234 | return void tryCast(slot, spike); | |
| 235 | continue; | |
| 236 | } | |
| 237 | if (kind === "dart") { | |
| 238 | if (distToGoal < ability.range && distToGoal > 200) return void tryCast(slot, centre); | |
| 239 | continue; | |
| 240 | } | |
| 241 | if (kind === "wall") { | |
| 242 | if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && bot.hp < 60) | |
| 243 | return void tryCast(slot, target); | |
| 244 | if (bot.side === "def" && !spike.planted && distToGoal < 200 && state.clock > 60) return void tryCast(slot, centre); | |
| 245 | continue; | |
| 246 | } | |
| 247 | if (kind === "trip") { | |
| 248 | if (bot.side === "def" && distToGoal < 160) return void tryCast(slot, { x: bot.x + (random(state) - 0.5) * 200, y: bot.y + (random(state) - 0.5) * 200 }); | |
| 249 | continue; | |
| 250 | } | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | /** Drive every seat that nobody is sitting in. */ | |
| 255 | export function runBots(state: MatchState, dt: number): void { | |
| 256 | for (const player of state.players) if (player.bot) botThink(state, player, dt); | |
| 257 | } |
src/economy.test.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { award, CREDIT_CAP, lossBonus, roundReward, spend, START_CREDITS } from "./economy.ts"; | |
| 3 | ||
| 4 | test("a round starts on 800 and nothing goes past 9000", () => { | |
| 5 | expect(START_CREDITS).toBe(800); | |
| 6 | expect(award(8900, 200)).toBe(CREDIT_CAP); | |
| 7 | expect(award(100, -500)).toBe(0); | |
| 8 | }); | |
| 9 | ||
| 10 | test("the loss bonus rises 1900 / 2400 / 2900 and stops", () => { | |
| 11 | expect(lossBonus(1)).toBe(1900); | |
| 12 | expect(lossBonus(2)).toBe(2400); | |
| 13 | expect(lossBonus(3)).toBe(2900); | |
| 14 | expect(lossBonus(7)).toBe(2900); | |
| 15 | }); | |
| 16 | ||
| 17 | test("a losing attack that planted keeps the plant bonus on top", () => { | |
| 18 | expect(roundReward(false, 1, false)).toBe(1900); | |
| 19 | expect(roundReward(false, 1, true)).toBe(1900 + 300); | |
| 20 | expect(roundReward(true, 3, false)).toBe(3000); | |
| 21 | }); | |
| 22 | ||
| 23 | test("a buy you cannot afford is refused, never a debt", () => { | |
| 24 | expect(spend(800, 2900)).toEqual({ ok: false, credits: 800 }); | |
| 25 | expect(spend(2900, 2900)).toEqual({ ok: true, credits: 0 }); | |
| 26 | expect(spend(800, -100).ok).toBe(false); | |
| 27 | expect(spend(800, Number.NaN).ok).toBe(false); | |
| 28 | }); |
src/economy.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * Credits. The whole economy is four numbers and a losing streak, and all of | |
| 3 | * it is pure: the match calls these and stores what comes back. | |
| 4 | */ | |
| 5 | ||
| 6 | export const START_CREDITS = 800; | |
| 7 | export const CREDIT_CAP = 9000; | |
| 8 | export const KILL_REWARD = 200; | |
| 9 | export const PLANT_REWARD = 300; | |
| 10 | export const WIN_REWARD = 3000; | |
| 11 | /** Rising with the streak, and capped at the third step. */ | |
| 12 | export const LOSS_BONUS = [1900, 2400, 2900]; | |
| 13 | ||
| 14 | export const clampCredits = (credits: number): number => | |
| 15 | Math.max(0, Math.min(CREDIT_CAP, Math.floor(credits))); | |
| 16 | ||
| 17 | export const award = (credits: number, amount: number): number => clampCredits(credits + amount); | |
| 18 | ||
| 19 | /** The loss bonus for a team that has now lost `streak` rounds in a row. */ | |
| 20 | export const lossBonus = (streak: number): number => | |
| 21 | LOSS_BONUS[Math.min(Math.max(streak, 1), LOSS_BONUS.length) - 1]!; | |
| 22 | ||
| 23 | /** What a round's end pays each member of a team. */ | |
| 24 | export function roundReward(won: boolean, lossStreak: number, planted: boolean): number { | |
| 25 | if (won) return WIN_REWARD; | |
| 26 | // A team that lost the round but got the spike down keeps a little more. | |
| 27 | return lossBonus(lossStreak) + (planted ? PLANT_REWARD : 0); | |
| 28 | } | |
| 29 | ||
| 30 | /** A purchase either happens at a price or is refused; never a debt. */ | |
| 31 | export function spend(credits: number, cost: number): { ok: boolean; credits: number } { | |
| 32 | if (!Number.isFinite(cost) || cost < 0 || cost > credits) return { ok: false, credits }; | |
| 33 | return { ok: true, credits: clampCredits(credits - cost) }; | |
| 34 | } |
src/map.ts added +265
| @@ -0,0 +1,265 @@ | ||
| 1 | /** | |
| 2 | * The map: one grid, and the geometry everything else asks questions of. | |
| 3 | * | |
| 4 | * Two sites (A left, B right), a mid corridor between the spawns, and a back | |
| 5 | * corridor along the bottom that links A main, spawn and B main — the flank. | |
| 6 | * Tiles are 80 units; a player is a circle of radius 16, so a one-tile | |
| 7 | * corridor is comfortably wide and a wall is never thin enough for a ray to | |
| 8 | * step over. | |
| 9 | * | |
| 10 | * Everything here is a pure function of the grid: collision, line of sight | |
| 11 | * and the walk distance a bot uses to path. No state. | |
| 12 | */ | |
| 13 | ||
| 14 | export const TILE = 80; | |
| 15 | ||
| 16 | // '#' wall · '.' floor · 'A'/'B' plant site · 'a' attacker spawn · 'd' defender spawn | |
| 17 | const ROWS_SRC = [ | |
| 18 | "##############################", | |
| 19 | "#........###.dddd.###........#", | |
| 20 | "#.AAAAA..###.dddd.###..BBBBB.#", | |
| 21 | "#.AAAAA..##........##..BBBBB.#", | |
| 22 | "#.AAAAA..##.#....#.##..BBBBB.#", | |
| 23 | "#.AAAAA..##........##..BBBBB.#", | |
| 24 | "#...##......#....#......##...#", | |
| 25 | "#..####....#......#....####..#", | |
| 26 | "#..####..###......###..####..#", | |
| 27 | "#..####..####....####..####..#", | |
| 28 | "#..####..####....####..####..#", | |
| 29 | "#........####....####........#", | |
| 30 | "#..####..####....####..####..#", | |
| 31 | "#..####..####....####..####..#", | |
| 32 | "#..####..####....####..####..#", | |
| 33 | "#..####..###......###..####..#", | |
| 34 | "#........##..aaaa..##........#", | |
| 35 | "#............aaaa............#", | |
| 36 | "##############################", | |
| 37 | ]; | |
| 38 | ||
| 39 | export const COLS = ROWS_SRC[0]!.length; | |
| 40 | export const ROWS = ROWS_SRC.length; | |
| 41 | export const WIDTH = COLS * TILE; | |
| 42 | export const HEIGHT = ROWS * TILE; | |
| 43 | ||
| 44 | export const grid: string[] = ROWS_SRC; | |
| 45 | export const gridRows = (): string[] => ROWS_SRC.slice(); | |
| 46 | ||
| 47 | export type Site = "A" | "B"; | |
| 48 | export type Point = { x: number; y: number }; | |
| 49 | ||
| 50 | const at = (col: number, row: number): string => | |
| 51 | row < 0 || row >= ROWS || col < 0 || col >= COLS ? "#" : ROWS_SRC[row]![col]!; | |
| 52 | ||
| 53 | export const solidTile = (col: number, row: number): boolean => at(col, row) === "#"; | |
| 54 | ||
| 55 | export const tileOf = (x: number, y: number): { col: number; row: number } => ({ | |
| 56 | col: Math.floor(x / TILE), | |
| 57 | row: Math.floor(y / TILE), | |
| 58 | }); | |
| 59 | ||
| 60 | export const centreOf = (col: number, row: number): Point => ({ | |
| 61 | x: col * TILE + TILE / 2, | |
| 62 | y: row * TILE + TILE / 2, | |
| 63 | }); | |
| 64 | ||
| 65 | export const solidAt = (x: number, y: number): boolean => { | |
| 66 | const t = tileOf(x, y); | |
| 67 | return solidTile(t.col, t.row); | |
| 68 | }; | |
| 69 | ||
| 70 | /** The site a point plants on, or null: the spike only arms inside a site. */ | |
| 71 | export const siteAt = (x: number, y: number): Site | null => { | |
| 72 | const t = tileOf(x, y); | |
| 73 | const c = at(t.col, t.row); | |
| 74 | return c === "A" || c === "B" ? c : null; | |
| 75 | }; | |
| 76 | ||
| 77 | const cellsOf = (mark: string): Point[] => { | |
| 78 | const out: Point[] = []; | |
| 79 | for (let row = 0; row < ROWS; row++) | |
| 80 | for (let col = 0; col < COLS; col++) if (at(col, row) === mark) out.push(centreOf(col, row)); | |
| 81 | return out; | |
| 82 | }; | |
| 83 | ||
| 84 | export const spawnCells = (side: "atk" | "def"): Point[] => cellsOf(side === "atk" ? "a" : "d"); | |
| 85 | export const siteCells = (site: Site): Point[] => cellsOf(site); | |
| 86 | ||
| 87 | export const siteCentre = (site: Site): Point => { | |
| 88 | const cells = siteCells(site); | |
| 89 | const sum = cells.reduce((a, p) => ({ x: a.x + p.x, y: a.y + p.y }), { x: 0, y: 0 }); | |
| 90 | return { x: sum.x / cells.length, y: sum.y / cells.length }; | |
| 91 | }; | |
| 92 | ||
| 93 | /** Ult orbs: one on each half of mid, free for whoever walks over them. */ | |
| 94 | export const orbSpots = (): Point[] => [centreOf(4, 11), centreOf(25, 11)]; | |
| 95 | ||
| 96 | /** | |
| 97 | * Slide a circle through the grid: each axis is moved and resolved on its | |
| 98 | * own, so running along a wall slides instead of sticking. | |
| 99 | */ | |
| 100 | export function moveCircle(x: number, y: number, dx: number, dy: number, r: number): Point { | |
| 101 | let nx = x; | |
| 102 | let ny = y; | |
| 103 | if (dx !== 0) { | |
| 104 | const test = nx + dx; | |
| 105 | if (!blockedCircle(test, ny, r)) nx = test; | |
| 106 | else { | |
| 107 | const col = Math.floor(test / TILE); | |
| 108 | nx = dx > 0 ? col * TILE - r - 0.01 : (col + 1) * TILE + r + 0.01; | |
| 109 | if (blockedCircle(nx, ny, r)) nx = x; | |
| 110 | } | |
| 111 | } | |
| 112 | if (dy !== 0) { | |
| 113 | const test = ny + dy; | |
| 114 | if (!blockedCircle(nx, test, r)) ny = test; | |
| 115 | else { | |
| 116 | const row = Math.floor(test / TILE); | |
| 117 | ny = dy > 0 ? row * TILE - r - 0.01 : (row + 1) * TILE + r + 0.01; | |
| 118 | if (blockedCircle(nx, ny, r)) ny = y; | |
| 119 | } | |
| 120 | } | |
| 121 | return { x: nx, y: ny }; | |
| 122 | } | |
| 123 | ||
| 124 | /** True when a circle at (x,y) overlaps any solid tile. */ | |
| 125 | export function blockedCircle(x: number, y: number, r: number): boolean { | |
| 126 | const c0 = Math.floor((x - r) / TILE); | |
| 127 | const c1 = Math.floor((x + r) / TILE); | |
| 128 | const r0 = Math.floor((y - r) / TILE); | |
| 129 | const r1 = Math.floor((y + r) / TILE); | |
| 130 | for (let row = r0; row <= r1; row++) | |
| 131 | for (let col = c0; col <= c1; col++) { | |
| 132 | if (!solidTile(col, row)) continue; | |
| 133 | const nearX = Math.max(col * TILE, Math.min(x, col * TILE + TILE)); | |
| 134 | const nearY = Math.max(row * TILE, Math.min(y, row * TILE + TILE)); | |
| 135 | const dx = x - nearX; | |
| 136 | const dy = y - nearY; | |
| 137 | if (dx * dx + dy * dy < r * r) return true; | |
| 138 | } | |
| 139 | return false; | |
| 140 | } | |
| 141 | ||
| 142 | /** | |
| 143 | * Does a wall stand between two points? Sampled every third of a tile: a | |
| 144 | * wall is a whole tile thick, so nothing is ever stepped over. | |
| 145 | */ | |
| 146 | export function wallBetween(a: Point, b: Point): boolean { | |
| 147 | const dx = b.x - a.x; | |
| 148 | const dy = b.y - a.y; | |
| 149 | const dist = Math.hypot(dx, dy); | |
| 150 | const steps = Math.ceil(dist / (TILE / 3)); | |
| 151 | for (let i = 1; i < steps; i++) { | |
| 152 | const t = i / steps; | |
| 153 | if (solidAt(a.x + dx * t, a.y + dy * t)) return true; | |
| 154 | } | |
| 155 | return false; | |
| 156 | } | |
| 157 | ||
| 158 | /** Where a shot stops: the first wall along the ray, or its full range. */ | |
| 159 | export function rayHitWall(from: Point, angle: number, range: number): Point { | |
| 160 | const dx = Math.cos(angle); | |
| 161 | const dy = Math.sin(angle); | |
| 162 | const step = TILE / 4; | |
| 163 | for (let d = step; d <= range; d += step) { | |
| 164 | const x = from.x + dx * d; | |
| 165 | const y = from.y + dy * d; | |
| 166 | if (solidAt(x, y)) return { x: from.x + dx * (d - step), y: from.y + dy * (d - step) }; | |
| 167 | } | |
| 168 | return { x: from.x + dx * range, y: from.y + dy * range }; | |
| 169 | } | |
| 170 | ||
| 171 | /** Closest approach of a segment to a point, as distance and travel along it. */ | |
| 172 | export function segmentPoint(a: Point, b: Point, p: Point): { dist: number; travel: number } { | |
| 173 | const dx = b.x - a.x; | |
| 174 | const dy = b.y - a.y; | |
| 175 | const len2 = dx * dx + dy * dy; | |
| 176 | const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)); | |
| 177 | const cx = a.x + dx * t; | |
| 178 | const cy = a.y + dy * t; | |
| 179 | return { dist: Math.hypot(p.x - cx, p.y - cy), travel: t * Math.sqrt(len2) }; | |
| 180 | } | |
| 181 | ||
| 182 | export const segmentHitsCircle = (a: Point, b: Point, c: Point, r: number): boolean => | |
| 183 | segmentPoint(a, b, c).dist <= r; | |
| 184 | ||
| 185 | // Walk distance from a goal tile, breadth-first over floor. Bots ask for it | |
| 186 | // every few ticks; the field for a goal is built once and kept for the round. | |
| 187 | const fields = new Map<number, Int32Array>(); | |
| 188 | ||
| 189 | export const clearPaths = (): void => void fields.clear(); | |
| 190 | ||
| 191 | function field(goalCol: number, goalRow: number): Int32Array { | |
| 192 | const key = goalRow * COLS + goalCol; | |
| 193 | const cached = fields.get(key); | |
| 194 | if (cached) return cached; | |
| 195 | const dist = new Int32Array(COLS * ROWS).fill(-1); | |
| 196 | if (!solidTile(goalCol, goalRow)) { | |
| 197 | dist[key] = 0; | |
| 198 | const queue: number[] = [key]; | |
| 199 | for (let head = 0; head < queue.length; head++) { | |
| 200 | const here = queue[head]!; | |
| 201 | const col = here % COLS; | |
| 202 | const row = (here - col) / COLS; | |
| 203 | const d = dist[here]!; | |
| 204 | for (const [dc, dr] of [ | |
| 205 | [1, 0], | |
| 206 | [-1, 0], | |
| 207 | [0, 1], | |
| 208 | [0, -1], | |
| 209 | ] as const) { | |
| 210 | const nc = col + dc; | |
| 211 | const nr = row + dr; | |
| 212 | if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue; | |
| 213 | const nk = nr * COLS + nc; | |
| 214 | if (dist[nk] !== -1 || solidTile(nc, nr)) continue; | |
| 215 | dist[nk] = d + 1; | |
| 216 | queue.push(nk); | |
| 217 | } | |
| 218 | } | |
| 219 | } | |
| 220 | if (fields.size > 64) fields.clear(); | |
| 221 | fields.set(key, dist); | |
| 222 | return dist; | |
| 223 | } | |
| 224 | ||
| 225 | /** Walk distance in tiles between two points, or -1 when unreachable. */ | |
| 226 | export function walkDistance(from: Point, to: Point): number { | |
| 227 | const f = tileOf(from.x, from.y); | |
| 228 | const g = tileOf(to.x, to.y); | |
| 229 | return field(g.col, g.row)[f.row * COLS + f.col] ?? -1; | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * The next point to walk toward on the way to a goal: the centre of the | |
| 234 | * neighbouring tile that is one step closer, so bots round corners instead | |
| 235 | * of grinding into them. | |
| 236 | */ | |
| 237 | export function pathStep(from: Point, to: Point): Point { | |
| 238 | const f = tileOf(from.x, from.y); | |
| 239 | const g = tileOf(to.x, to.y); | |
| 240 | if (f.col === g.col && f.row === g.row) return to; | |
| 241 | const dist = field(g.col, g.row); | |
| 242 | const here = dist[f.row * COLS + f.col] ?? -1; | |
| 243 | if (here <= 0) return to; | |
| 244 | let best: Point | null = null; | |
| 245 | let bestD = here; | |
| 246 | for (const [dc, dr] of [ | |
| 247 | [1, 0], | |
| 248 | [-1, 0], | |
| 249 | [0, 1], | |
| 250 | [0, -1], | |
| 251 | ] as const) { | |
| 252 | const nc = f.col + dc; | |
| 253 | const nr = f.row + dr; | |
| 254 | if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue; | |
| 255 | const d = dist[nr * COLS + nc] ?? -1; | |
| 256 | if (d !== -1 && d < bestD) { | |
| 257 | bestD = d; | |
| 258 | best = centreOf(nc, nr); | |
| 259 | } | |
| 260 | } | |
| 261 | // Straight to the goal when nothing stands in the way — smoother than | |
| 262 | // walking tile centre to tile centre down an open corridor. | |
| 263 | if (best && !wallBetween(from, to) && (walkDistance(from, to) ?? 99) < 6) return to; | |
| 264 | return best ?? to; | |
| 265 | } |
src/match.test.ts added +320
| @@ -0,0 +1,320 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { runBots } from "./bots.ts"; | |
| 3 | import { | |
| 4 | aliveOn, | |
| 5 | buyAbility, | |
| 6 | buyShield, | |
| 7 | buyWeapon, | |
| 8 | DEFUSE_CHECKPOINT, | |
| 9 | DEFUSE_TIME, | |
| 10 | FUSE_TIME, | |
| 11 | newMatch, | |
| 12 | PLANT_TIME, | |
| 13 | ROUND_TIME, | |
| 14 | SWAP_AFTER, | |
| 15 | tick, | |
| 16 | visible, | |
| 17 | WIN_ROUNDS, | |
| 18 | } from "./match.ts"; | |
| 19 | import { siteCentre } from "./map.ts"; | |
| 20 | import type { MatchState, Player } from "./state.ts"; | |
| 21 | ||
| 22 | const live = (seed = 5): MatchState => { | |
| 23 | const state = newMatch(seed); | |
| 24 | state.phase = "live"; | |
| 25 | state.clock = ROUND_TIME; | |
| 26 | return state; | |
| 27 | }; | |
| 28 | const atk = (state: MatchState): Player[] => state.players.filter((p) => p.side === "atk"); | |
| 29 | const def = (state: MatchState): Player[] => state.players.filter((p) => p.side === "def"); | |
| 30 | const run = (state: MatchState, seconds: number): void => { | |
| 31 | for (let i = 0; i < Math.round(seconds * 30); i++) tick(state, 1 / 30); | |
| 32 | }; | |
| 33 | ||
| 34 | /** Put a carrier on a site with the spike, everyone else out of the way. */ | |
| 35 | function onSite(state: MatchState): Player { | |
| 36 | const spot = siteCentre("A"); | |
| 37 | const carrier = atk(state)[0]!; | |
| 38 | for (const player of state.players) { | |
| 39 | player.spike = false; | |
| 40 | player.x = 1200; | |
| 41 | player.y = 1320; | |
| 42 | } | |
| 43 | carrier.spike = true; | |
| 44 | carrier.x = spot.x; | |
| 45 | carrier.y = spot.y; | |
| 46 | state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; | |
| 47 | return carrier; | |
| 48 | } | |
| 49 | ||
| 50 | test("a match starts in the buy phase with ten seats and a spike carrier", () => { | |
| 51 | const state = newMatch(1); | |
| 52 | expect(state.players.length).toBe(10); | |
| 53 | expect(atk(state).length).toBe(5); | |
| 54 | expect(def(state).length).toBe(5); | |
| 55 | expect(state.phase).toBe("buy"); | |
| 56 | expect(state.players.some((p) => p.spike)).toBe(true); | |
| 57 | expect(state.players.every((p) => p.credits === 800)).toBe(true); | |
| 58 | }); | |
| 59 | ||
| 60 | test("planting takes four seconds of standing on a site and arms the fuse", () => { | |
| 61 | const state = live(); | |
| 62 | const carrier = onSite(state); | |
| 63 | carrier.input.use = true; | |
| 64 | run(state, PLANT_TIME - 1); | |
| 65 | expect(state.spike.planted).toBe(false); | |
| 66 | run(state, 1.2); | |
| 67 | expect(state.spike.planted).toBe(true); | |
| 68 | expect(state.spike.site).toBe("A"); | |
| 69 | expect(Math.round(state.spike.fuse)).toBeLessThanOrEqual(FUSE_TIME); | |
| 70 | // The plant pays the attacking team and charges the planter's ult. | |
| 71 | expect(atk(state).every((p) => p.credits >= 800 + 300)).toBe(true); | |
| 72 | expect(carrier.ult).toBeGreaterThan(0); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a plant off a site does nothing at all", () => { | |
| 76 | const state = live(); | |
| 77 | const carrier = onSite(state); | |
| 78 | carrier.x = 1200; | |
| 79 | carrier.y = 1320; // attacker spawn, not a site | |
| 80 | carrier.input.use = true; | |
| 81 | run(state, PLANT_TIME + 2); | |
| 82 | expect(state.spike.planted).toBe(false); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a defuse killed at the half way mark leaves the checkpoint for the next defender", () => { | |
| 86 | const state = live(); | |
| 87 | const carrier = onSite(state); | |
| 88 | carrier.input.use = true; | |
| 89 | run(state, PLANT_TIME + 0.2); | |
| 90 | expect(state.spike.planted).toBe(true); | |
| 91 | carrier.input.use = false; | |
| 92 | ||
| 93 | const first = def(state)[0]!; | |
| 94 | first.x = state.spike.x; | |
| 95 | first.y = state.spike.y; | |
| 96 | first.input.use = true; | |
| 97 | run(state, DEFUSE_CHECKPOINT + 0.2); | |
| 98 | expect(state.spike.checkpoint).toBe(DEFUSE_CHECKPOINT); | |
| 99 | expect(state.spike.defuse).toBeGreaterThanOrEqual(DEFUSE_CHECKPOINT); | |
| 100 | ||
| 101 | // He dies here. The defuse falls back to the checkpoint, not to zero. | |
| 102 | first.hp = 1; | |
| 103 | first.alive = false; | |
| 104 | first.deaths += 1; | |
| 105 | state.spike.defuse = state.spike.checkpoint; | |
| 106 | expect(state.spike.defuse).toBe(DEFUSE_CHECKPOINT); | |
| 107 | ||
| 108 | const second = def(state)[1]!; | |
| 109 | second.x = state.spike.x; | |
| 110 | second.y = state.spike.y; | |
| 111 | second.input.use = true; | |
| 112 | // Half a defuse is left, so well under the full seven seconds finishes it. | |
| 113 | run(state, DEFUSE_TIME - DEFUSE_CHECKPOINT + 0.3); | |
| 114 | expect(state.phase).toBe("end"); | |
| 115 | expect(state.events.some((e) => e.text.includes("defused"))).toBe(true); | |
| 116 | }); | |
| 117 | ||
| 118 | test("an interrupted defuse before the checkpoint starts again from nothing", () => { | |
| 119 | const state = live(); | |
| 120 | const carrier = onSite(state); | |
| 121 | carrier.input.use = true; | |
| 122 | run(state, PLANT_TIME + 0.2); | |
| 123 | carrier.input.use = false; | |
| 124 | const defender = def(state)[0]!; | |
| 125 | defender.x = state.spike.x; | |
| 126 | defender.y = state.spike.y; | |
| 127 | defender.input.use = true; | |
| 128 | run(state, 2); | |
| 129 | expect(state.spike.defuse).toBeGreaterThan(1.5); | |
| 130 | defender.input.use = false; | |
| 131 | run(state, 0.2); | |
| 132 | expect(state.spike.defuse).toBe(0); | |
| 133 | expect(state.spike.checkpoint).toBe(0); | |
| 134 | }); | |
| 135 | ||
| 136 | test("the spike detonating wins the round for the attackers", () => { | |
| 137 | const state = live(); | |
| 138 | const carrier = onSite(state); | |
| 139 | carrier.input.use = true; | |
| 140 | run(state, PLANT_TIME + 0.2); | |
| 141 | carrier.input.use = false; | |
| 142 | state.spike.fuse = 1; | |
| 143 | run(state, 1.5); | |
| 144 | expect(state.phase).toBe("end"); | |
| 145 | expect(state.score[state.attackers]).toBe(1); | |
| 146 | }); | |
| 147 | ||
| 148 | test("time running out with no plant is a defence win, but the clock stops mattering once it is down", () => { | |
| 149 | const timeout = live(); | |
| 150 | timeout.clock = 0.5; | |
| 151 | run(timeout, 1); | |
| 152 | expect(timeout.phase).toBe("end"); | |
| 153 | expect(timeout.score[timeout.attackers]).toBe(0); | |
| 154 | ||
| 155 | const planted = live(); | |
| 156 | const carrier = onSite(planted); | |
| 157 | carrier.input.use = true; | |
| 158 | run(planted, PLANT_TIME + 0.2); | |
| 159 | carrier.input.use = false; | |
| 160 | planted.clock = 0.2; | |
| 161 | run(planted, 1); | |
| 162 | expect(planted.phase).toBe("live"); | |
| 163 | }); | |
| 164 | ||
| 165 | test("eliminating a team ends the round, and a planted spike still wins it", () => { | |
| 166 | const wipe = live(); | |
| 167 | for (const player of def(wipe)) player.alive = false; | |
| 168 | run(wipe, 0.1); | |
| 169 | expect(wipe.phase).toBe("end"); | |
| 170 | expect(wipe.score[wipe.attackers]).toBe(1); | |
| 171 | ||
| 172 | const traded = live(); | |
| 173 | const carrier = onSite(traded); | |
| 174 | carrier.input.use = true; | |
| 175 | run(traded, PLANT_TIME + 0.2); | |
| 176 | for (const player of atk(traded)) player.alive = false; | |
| 177 | run(traded, 0.1); | |
| 178 | // Everyone is dead but the spike is armed: the round is not over yet. | |
| 179 | expect(traded.phase).toBe("live"); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a buy is refused server-side when the credits are not there", () => { | |
| 183 | const state = newMatch(2); | |
| 184 | const player = state.players[0]!; | |
| 185 | player.agent = "ember"; | |
| 186 | player.credits = 800; | |
| 187 | expect(buyWeapon(state, player, "vandal")).toBe(false); | |
| 188 | expect(player.weapon).toBe("classic"); | |
| 189 | expect(buyWeapon(state, player, "ghost")).toBe(true); | |
| 190 | expect(player.credits).toBe(300); | |
| 191 | expect(buyShield(state, player, "heavy")).toBe(false); | |
| 192 | // 250 for the flash leaves fifty, and fifty buys nothing. | |
| 193 | expect(buyAbility(state, player, "c")).toBe(true); | |
| 194 | expect(player.credits).toBe(50); | |
| 195 | expect(buyAbility(state, player, "c")).toBe(false); | |
| 196 | expect(buyAbility(state, player, "q")).toBe(false); | |
| 197 | // And nothing is for sale once the round is live. | |
| 198 | player.credits = 9000; | |
| 199 | state.phase = "live"; | |
| 200 | expect(buyWeapon(state, player, "vandal")).toBe(false); | |
| 201 | }); | |
| 202 | ||
| 203 | test("a basic is bought by the charge and never past its limit", () => { | |
| 204 | const state = newMatch(2); | |
| 205 | const player = state.players.find((p) => p.agent === "veil")!; | |
| 206 | player.credits = 9000; | |
| 207 | expect(buyAbility(state, player, "c")).toBe(true); | |
| 208 | expect(buyAbility(state, player, "c")).toBe(true); | |
| 209 | expect(player.charges.c).toBe(2); | |
| 210 | expect(buyAbility(state, player, "c")).toBe(false); | |
| 211 | expect(buyAbility(state, player, "e")).toBe(false); | |
| 212 | expect(buyAbility(state, player, "x")).toBe(false); | |
| 213 | }); | |
| 214 | ||
| 215 | test("sides swap after twelve rounds and a thirteenth round wins the match", () => { | |
| 216 | const state = newMatch(4); | |
| 217 | const started = state.attackers; | |
| 218 | state.round = SWAP_AFTER; | |
| 219 | state.phase = "live"; | |
| 220 | state.score[started] = WIN_ROUNDS - 2; | |
| 221 | for (const player of def(state)) player.alive = false; | |
| 222 | run(state, 0.1); | |
| 223 | expect(state.phase).toBe("end"); | |
| 224 | run(state, 6); | |
| 225 | expect(state.round).toBe(SWAP_AFTER + 1); | |
| 226 | expect(state.attackers).not.toBe(started); | |
| 227 | // One more round, taken by whoever now attacks the old defenders' side. | |
| 228 | const winners = state.attackers === started ? started : state.attackers; | |
| 229 | state.phase = "live"; | |
| 230 | state.score[started] = WIN_ROUNDS - 1; | |
| 231 | for (const player of state.players) if (player.team !== started) player.alive = false; | |
| 232 | run(state, 0.1); | |
| 233 | expect(state.score[started]).toBe(WIN_ROUNDS); | |
| 234 | expect(state.phase).toBe("over"); | |
| 235 | expect(state.result).toContain(`${started} win`); | |
| 236 | expect(winners.length).toBeGreaterThan(0); | |
| 237 | }); | |
| 238 | ||
| 239 | test("an ended match never parks: it shows the result and starts a fresh one", () => { | |
| 240 | const state = newMatch(6); | |
| 241 | state.phase = "live"; | |
| 242 | state.score[state.attackers] = WIN_ROUNDS - 1; | |
| 243 | for (const player of def(state)) player.alive = false; | |
| 244 | run(state, 0.1); | |
| 245 | expect(state.phase).toBe("over"); | |
| 246 | expect(state.result).not.toBe(null); | |
| 247 | run(state, 13); | |
| 248 | expect(state.phase).toBe("buy"); | |
| 249 | expect(state.round).toBe(1); | |
| 250 | expect(state.score).toEqual({ red: 0, blue: 0 }); | |
| 251 | expect(state.result).toBe(null); | |
| 252 | expect(state.players.length).toBe(10); | |
| 253 | }); | |
| 254 | ||
| 255 | test("line of sight is the server's answer: a smoke hides an enemy in the open", () => { | |
| 256 | const state = live(); | |
| 257 | const viewer = atk(state)[0]!; | |
| 258 | const enemy = def(state)[0]!; | |
| 259 | viewer.x = 1200; | |
| 260 | viewer.y = 1320; | |
| 261 | enemy.x = 1200; | |
| 262 | enemy.y = 1000; | |
| 263 | expect(visible(state, viewer, enemy)).toBe(true); | |
| 264 | state.zones.push({ | |
| 265 | id: 99, kind: "smoke", owner: "x", side: "def", x: 1200, y: 1160, r: 150, | |
| 266 | life: 8, fuse: 0, beat: 0, pulses: 0, damage: 0, blocks: true, solid: false, | |
| 267 | }); | |
| 268 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 269 | // A revealed enemy is seen through anything; so is a team mate. | |
| 270 | enemy.revealed = 2; | |
| 271 | expect(visible(state, viewer, enemy)).toBe(true); | |
| 272 | }); | |
| 273 | ||
| 274 | test("a blinded player sees nothing", () => { | |
| 275 | const state = live(); | |
| 276 | const viewer = atk(state)[0]!; | |
| 277 | const enemy = def(state)[0]!; | |
| 278 | viewer.x = 1200; | |
| 279 | viewer.y = 1320; | |
| 280 | enemy.x = 1200; | |
| 281 | enemy.y = 1000; | |
| 282 | viewer.blind = 1.4; | |
| 283 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 284 | }); | |
| 285 | ||
| 286 | test("a killed carrier drops the spike where they fell", () => { | |
| 287 | const state = live(); | |
| 288 | const carrier = onSite(state); | |
| 289 | const killer = def(state)[0]!; | |
| 290 | killer.x = carrier.x + 40; | |
| 291 | killer.y = carrier.y; | |
| 292 | killer.angle = Math.PI; | |
| 293 | killer.weapon = "operator"; | |
| 294 | killer.input.fire = true; | |
| 295 | run(state, 1); | |
| 296 | if (!carrier.alive) { | |
| 297 | expect(state.spike.carried).toBe(null); | |
| 298 | expect(Math.hypot(state.spike.x - carrier.x, state.spike.y - carrier.y)).toBeLessThan(80); | |
| 299 | } | |
| 300 | expect(state.shots.length + state.events.length).toBeGreaterThan(0); | |
| 301 | }); | |
| 302 | ||
| 303 | test("bots play the round: they buy, they take a site and they use their kit", () => { | |
| 304 | const state = newMatch(42); | |
| 305 | let casts = 0; | |
| 306 | let rounds = 0; | |
| 307 | for (let i = 0; i < 30 * 60 * 6; i++) { | |
| 308 | runBots(state, 1 / 30); | |
| 309 | tick(state, 1 / 30); | |
| 310 | casts += state.zones.length > 0 ? 1 : 0; | |
| 311 | if (state.round > rounds) rounds = state.round; | |
| 312 | } | |
| 313 | expect(rounds).toBeGreaterThan(1); | |
| 314 | expect(state.score.red + state.score.blue).toBeGreaterThan(1); | |
| 315 | // Something was on the map: smokes, flashes, mollies, walls. | |
| 316 | expect(casts).toBeGreaterThan(100); | |
| 317 | // And somebody bought a real gun with the credits they won. | |
| 318 | expect(state.players.some((p) => p.weapon !== "classic")).toBe(true); | |
| 319 | expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0); | |
| 320 | }); |
src/match.ts added +646
| @@ -0,0 +1,646 @@ | ||
| 1 | /** | |
| 2 | * The match: one round loop, server-authoritative, and the only place a | |
| 3 | * number changes. | |
| 4 | * | |
| 5 | * `tick(state, dt)` is the whole game — call it with a fixed step and it | |
| 6 | * runs without a clock, a socket or a browser, which is how the tests play | |
| 7 | * whole rounds in a millisecond. Inputs arrive as intent (a direction, an | |
| 8 | * angle, a held key); nothing a client says is taken as a position. | |
| 9 | */ | |
| 10 | ||
| 11 | import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; | |
| 12 | import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts"; | |
| 13 | import { | |
| 14 | blockedCircle, | |
| 15 | clearPaths, | |
| 16 | moveCircle, | |
| 17 | orbSpots, | |
| 18 | rayHitWall, | |
| 19 | segmentPoint, | |
| 20 | siteAt, | |
| 21 | spawnCells, | |
| 22 | type Point, | |
| 23 | } from "./map.ts"; | |
| 24 | import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts"; | |
| 25 | import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; | |
| 26 | ||
| 27 | export const PLAYER_R = 16; | |
| 28 | export const RUN_SPEED = 250; | |
| 29 | export const BUY_TIME = 14; | |
| 30 | export const ROUND_TIME = 100; | |
| 31 | export const PLANT_TIME = 4; | |
| 32 | export const FUSE_TIME = 45; | |
| 33 | export const DEFUSE_TIME = 7; | |
| 34 | export const DEFUSE_CHECKPOINT = 3.5; | |
| 35 | export const END_TIME = 5; | |
| 36 | export const OVER_TIME = 12; | |
| 37 | export const SWAP_AFTER = 12; | |
| 38 | export const WIN_ROUNDS = 13; | |
| 39 | export const ROSTER = 5; | |
| 40 | export const SPIKE_REACH = 46; | |
| 41 | ||
| 42 | const TEAMS: Team[] = ["red", "blue"]; | |
| 43 | ||
| 44 | /** Deterministic randomness: the same seed plays the same round. */ | |
| 45 | export function random(state: MatchState): number { | |
| 46 | state.seed = (state.seed + 0x6d2b79f5) | 0; | |
| 47 | let t = state.seed; | |
| 48 | t = Math.imul(t ^ (t >>> 15), t | 1); | |
| 49 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| 50 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| 51 | } | |
| 52 | ||
| 53 | const say = (state: MatchState, text: string): void => { | |
| 54 | state.events.unshift({ text, age: 0 }); | |
| 55 | state.events.length = Math.min(state.events.length, 12); | |
| 56 | }; | |
| 57 | ||
| 58 | export const sideOf = (state: MatchState, team: Team): Side => | |
| 59 | team === state.attackers ? "atk" : "def"; | |
| 60 | ||
| 61 | export const teammates = (state: MatchState, side: Side): Player[] => | |
| 62 | state.players.filter((p) => p.side === side); | |
| 63 | ||
| 64 | export const aliveOn = (state: MatchState, side: Side): Player[] => | |
| 65 | state.players.filter((p) => p.side === side && p.alive); | |
| 66 | ||
| 67 | const BOT_NAMES = [ | |
| 68 | "Ash", "Bex", "Cade", "Dune", "Enzo", "Fay", "Gus", "Hale", "Ivo", "Juno", | |
| 69 | "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove", | |
| 70 | ]; | |
| 71 | ||
| 72 | export function makePlayer(state: MatchState, team: Team, index: number): Player { | |
| 73 | const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!; | |
| 74 | const name = BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]!; | |
| 75 | return { | |
| 76 | id: `bot-${team}-${index}`, | |
| 77 | name: `${name}`, | |
| 78 | bot: true, | |
| 79 | agent, | |
| 80 | team, | |
| 81 | side: sideOf(state, team), | |
| 82 | x: 0, | |
| 83 | y: 0, | |
| 84 | angle: 0, | |
| 85 | hp: 100, | |
| 86 | armour: 0, | |
| 87 | alive: true, | |
| 88 | credits: START_CREDITS, | |
| 89 | weapon: "classic", | |
| 90 | ammo: WEAPONS.classic.magazine, | |
| 91 | reloadIn: 0, | |
| 92 | fireIn: 0, | |
| 93 | charges: { c: 0, q: 0, e: 1, x: 0 }, | |
| 94 | cooldown: { c: 0, q: 0, e: 0, x: 0 }, | |
| 95 | ult: 0, | |
| 96 | blind: 0, | |
| 97 | slow: 0, | |
| 98 | haste: 0, | |
| 99 | revealed: 0, | |
| 100 | spike: false, | |
| 101 | planting: 0, | |
| 102 | defusing: 0, | |
| 103 | kills: 0, | |
| 104 | deaths: 0, | |
| 105 | assists: 0, | |
| 106 | damageDone: 0, | |
| 107 | input: emptyInput(), | |
| 108 | goal: null, | |
| 109 | think: 0, | |
| 110 | spray: 0, | |
| 111 | joinedAt: 0, | |
| 112 | }; | |
| 113 | } | |
| 114 | ||
| 115 | export function newMatch(seed = Date.now() % 100000): MatchState { | |
| 116 | const state: MatchState = { | |
| 117 | id: `m${seed.toString(36)}${Math.floor(seed / 7) % 997}`, | |
| 118 | phase: "buy", | |
| 119 | clock: BUY_TIME, | |
| 120 | round: 1, | |
| 121 | score: { red: 0, blue: 0 }, | |
| 122 | attackers: "red", | |
| 123 | lossStreak: { red: 0, blue: 0 }, | |
| 124 | players: [], | |
| 125 | zones: [], | |
| 126 | nextZone: 1, | |
| 127 | spike: { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }, | |
| 128 | orbs: orbSpots().map((p) => ({ ...p, taken: false })), | |
| 129 | shots: [], | |
| 130 | events: [], | |
| 131 | result: null, | |
| 132 | seed, | |
| 133 | now: 0, | |
| 134 | }; | |
| 135 | for (const team of TEAMS) | |
| 136 | for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i)); | |
| 137 | startRound(state, true); | |
| 138 | say(state, "Round 1 — buy phase"); | |
| 139 | return state; | |
| 140 | } | |
| 141 | ||
| 142 | /** A fresh match on the same seats: an ended match never parks. */ | |
| 143 | export function resetMatch(state: MatchState): void { | |
| 144 | const fresh = newMatch((state.seed + 7919) % 100000); | |
| 145 | fresh.players = state.players; | |
| 146 | fresh.attackers = state.attackers === "red" ? "blue" : "red"; | |
| 147 | for (const player of fresh.players) { | |
| 148 | player.kills = 0; | |
| 149 | player.deaths = 0; | |
| 150 | player.assists = 0; | |
| 151 | player.damageDone = 0; | |
| 152 | player.ult = 0; | |
| 153 | player.credits = START_CREDITS; | |
| 154 | player.weapon = "classic"; | |
| 155 | } | |
| 156 | Object.assign(state, fresh); | |
| 157 | startRound(state, true); | |
| 158 | say(state, "New match — round 1"); | |
| 159 | } | |
| 160 | ||
| 161 | function placeAtSpawn(state: MatchState, player: Player, index: number): void { | |
| 162 | const cells = spawnCells(player.side); | |
| 163 | const spot = cells[index % cells.length]!; | |
| 164 | player.x = spot.x + (random(state) - 0.5) * 20; | |
| 165 | player.y = spot.y + (random(state) - 0.5) * 20; | |
| 166 | const centre = player.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; | |
| 167 | player.angle = Math.atan2(centre.y - player.y, centre.x - player.x); | |
| 168 | } | |
| 169 | ||
| 170 | export function startRound(state: MatchState, first = false): void { | |
| 171 | clearPaths(); | |
| 172 | state.phase = "buy"; | |
| 173 | state.clock = BUY_TIME; | |
| 174 | state.zones = []; | |
| 175 | state.shots = []; | |
| 176 | state.spike = { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; | |
| 177 | state.orbs = orbSpots().map((p) => ({ ...p, taken: false })); | |
| 178 | ||
| 179 | const byTeam: Record<Team, number> = { red: 0, blue: 0 }; | |
| 180 | for (const player of state.players) { | |
| 181 | player.side = sideOf(state, player.team); | |
| 182 | // A gun survives the round its owner does; armour never does. | |
| 183 | if (!player.alive || first) player.weapon = "classic"; | |
| 184 | player.armour = 0; | |
| 185 | player.hp = 100; | |
| 186 | player.alive = true; | |
| 187 | player.ammo = WEAPONS[player.weapon].magazine; | |
| 188 | player.reloadIn = 0; | |
| 189 | player.fireIn = 0; | |
| 190 | player.blind = 0; | |
| 191 | player.slow = 0; | |
| 192 | player.haste = 0; | |
| 193 | player.revealed = 0; | |
| 194 | player.spike = false; | |
| 195 | player.planting = 0; | |
| 196 | player.defusing = 0; | |
| 197 | player.spray = 0; | |
| 198 | player.goal = null; | |
| 199 | player.think = 0; | |
| 200 | player.input = emptyInput(); | |
| 201 | // Basics are bought fresh every round; the signature comes back free. | |
| 202 | player.charges = { c: 0, q: 0, e: 1, x: 0 }; | |
| 203 | player.cooldown = { c: 0, q: 0, e: 0, x: 0 }; | |
| 204 | if (first) player.credits = START_CREDITS; | |
| 205 | placeAtSpawn(state, player, byTeam[player.team]++); | |
| 206 | } | |
| 207 | giveSpike(state); | |
| 208 | } | |
| 209 | ||
| 210 | function giveSpike(state: MatchState): void { | |
| 211 | const attackers = aliveOn(state, "atk"); | |
| 212 | if (attackers.length === 0) return; | |
| 213 | const carrier = attackers[Math.floor(random(state) * attackers.length)]!; | |
| 214 | carrier.spike = true; | |
| 215 | state.spike.carried = carrier.id; | |
| 216 | state.spike.x = carrier.x; | |
| 217 | state.spike.y = carrier.y; | |
| 218 | } | |
| 219 | ||
| 220 | /** Buying is refused server-side: no credits, no gun, no argument. */ | |
| 221 | export function buyWeapon(state: MatchState, player: Player, id: WeaponId): boolean { | |
| 222 | if (state.phase !== "buy") return false; | |
| 223 | const cost = WEAPONS[id].cost; | |
| 224 | const paid = spend(player.credits, cost); | |
| 225 | if (!paid.ok) return false; | |
| 226 | player.credits = paid.credits; | |
| 227 | player.weapon = id; | |
| 228 | player.ammo = WEAPONS[id].magazine; | |
| 229 | return true; | |
| 230 | } | |
| 231 | ||
| 232 | export function buyShield(state: MatchState, player: Player, kind: "light" | "heavy"): boolean { | |
| 233 | if (state.phase !== "buy") return false; | |
| 234 | const shield = SHIELD[kind]; | |
| 235 | if (player.armour >= shield.armour) return false; | |
| 236 | const paid = spend(player.credits, shield.cost); | |
| 237 | if (!paid.ok) return false; | |
| 238 | player.credits = paid.credits; | |
| 239 | player.armour = shield.armour; | |
| 240 | return true; | |
| 241 | } | |
| 242 | ||
| 243 | export function buyAbility(state: MatchState, player: Player, slot: Slot): boolean { | |
| 244 | if (state.phase !== "buy" || !buyableSlots.includes(slot)) return false; | |
| 245 | const ability = agentById(player.agent).abilities[slot]; | |
| 246 | if (player.charges[slot] >= ability.charges) return false; | |
| 247 | const paid = spend(player.credits, ability.cost); | |
| 248 | if (!paid.ok) return false; | |
| 249 | player.credits = paid.credits; | |
| 250 | player.charges[slot] += 1; | |
| 251 | return true; | |
| 252 | } | |
| 253 | ||
| 254 | export function pickAgent(state: MatchState, player: Player, agent: string): boolean { | |
| 255 | if (state.phase !== "buy" || player.charges.c > 0 || player.charges.q > 0) return false; | |
| 256 | player.agent = agent; | |
| 257 | return true; | |
| 258 | } | |
| 259 | ||
| 260 | // ---------------------------------------------------------------- the tick | |
| 261 | ||
| 262 | export function tick(state: MatchState, dt: number): void { | |
| 263 | state.now += dt; | |
| 264 | for (const shot of state.shots) shot.age += dt; | |
| 265 | state.shots = state.shots.filter((s) => s.age < 0.12); | |
| 266 | for (const event of state.events) event.age += dt; | |
| 267 | ||
| 268 | if (state.phase === "over") { | |
| 269 | state.clock -= dt; | |
| 270 | if (state.clock <= 0) resetMatch(state); | |
| 271 | return; | |
| 272 | } | |
| 273 | if (state.phase === "end") { | |
| 274 | state.clock -= dt; | |
| 275 | if (state.clock <= 0) nextRound(state); | |
| 276 | return; | |
| 277 | } | |
| 278 | ||
| 279 | state.clock -= dt; | |
| 280 | if (state.phase === "buy") { | |
| 281 | for (const player of state.players) movePlayer(state, player, dt, true); | |
| 282 | if (state.clock <= 0) { | |
| 283 | state.phase = "live"; | |
| 284 | state.clock = ROUND_TIME; | |
| 285 | say(state, "Round live"); | |
| 286 | } | |
| 287 | return; | |
| 288 | } | |
| 289 | ||
| 290 | for (const player of state.players) { | |
| 291 | if (player.blind > 0) player.blind = Math.max(0, player.blind - dt); | |
| 292 | if (player.slow > 0) player.slow = Math.max(0, player.slow - dt); | |
| 293 | if (player.haste > 0) player.haste = Math.max(0, player.haste - dt); | |
| 294 | if (player.revealed > 0) player.revealed = Math.max(0, player.revealed - dt); | |
| 295 | for (const slot of SLOTS) | |
| 296 | if (player.cooldown[slot] > 0) { | |
| 297 | player.cooldown[slot] = Math.max(0, player.cooldown[slot] - dt); | |
| 298 | // The signature charge comes back on its own when the wait is over. | |
| 299 | if (player.cooldown[slot] === 0) player.charges[slot] = Math.max(player.charges[slot], 1); | |
| 300 | } | |
| 301 | player.spray = Math.max(0, player.spray - dt * 6); | |
| 302 | if (!player.alive) continue; | |
| 303 | movePlayer(state, player, dt, false); | |
| 304 | useAbility(state, player); | |
| 305 | shoot(state, player, dt); | |
| 306 | spikeWork(state, player, dt); | |
| 307 | orbs(state, player); | |
| 308 | } | |
| 309 | ||
| 310 | if (state.spike.planted) state.spike.fuse = Math.max(0, state.spike.fuse - dt); | |
| 311 | zones(state, dt); | |
| 312 | endConditions(state); | |
| 313 | } | |
| 314 | ||
| 315 | function speedOf(player: Player): number { | |
| 316 | let speed = RUN_SPEED; | |
| 317 | if (player.haste > 0) speed *= 1.35; | |
| 318 | if (player.slow > 0) speed *= 0.55; | |
| 319 | if (player.planting > 0 || player.defusing > 0) speed = 0; | |
| 320 | if (player.weapon === "operator") speed *= 0.86; | |
| 321 | return speed; | |
| 322 | } | |
| 323 | ||
| 324 | function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void { | |
| 325 | if (!player.alive) return; | |
| 326 | const input = player.input; | |
| 327 | if (Number.isFinite(input.angle)) player.angle = input.angle; | |
| 328 | let mx = input.mx; | |
| 329 | let my = input.my; | |
| 330 | const len = Math.hypot(mx, my); | |
| 331 | if (len > 1) { | |
| 332 | mx /= len; | |
| 333 | my /= len; | |
| 334 | } | |
| 335 | const speed = speedOf(player) * dt; | |
| 336 | if (speed === 0 || (mx === 0 && my === 0)) return; | |
| 337 | let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R); | |
| 338 | // In the buy phase nobody leaves their own spawn. | |
| 339 | if (penned) { | |
| 340 | const cells = spawnCells(player.side); | |
| 341 | const near = cells.reduce((best, c) => (Math.hypot(c.x - next.x, c.y - next.y) < Math.hypot(best.x - next.x, best.y - next.y) ? c : best)); | |
| 342 | if (Math.hypot(near.x - next.x, near.y - next.y) > 120) return; | |
| 343 | } | |
| 344 | // Walls put down by abilities are as solid as the map's. | |
| 345 | for (const zone of state.zones) { | |
| 346 | if (!zone.solid || zone.fuse > 0) continue; | |
| 347 | const gap = Math.hypot(next.x - zone.x, next.y - zone.y); | |
| 348 | if (gap < zone.r + PLAYER_R) { | |
| 349 | const push = zone.r + PLAYER_R - gap; | |
| 350 | const angle = gap === 0 ? 0 : Math.atan2(next.y - zone.y, next.x - zone.x); | |
| 351 | const shoved = { x: next.x + Math.cos(angle) * push, y: next.y + Math.sin(angle) * push }; | |
| 352 | if (!blockedCircle(shoved.x, shoved.y, PLAYER_R)) next = shoved; | |
| 353 | else next = { x: player.x, y: player.y }; | |
| 354 | } | |
| 355 | } | |
| 356 | player.x = next.x; | |
| 357 | player.y = next.y; | |
| 358 | } | |
| 359 | ||
| 360 | function useAbility(state: MatchState, player: Player): void { | |
| 361 | const slot = player.input.cast; | |
| 362 | if (slot === null) return; | |
| 363 | player.input.cast = null; | |
| 364 | if (state.phase !== "live" || player.blind > 0.8) return; | |
| 365 | if (cast(state, player, slot)) { | |
| 366 | const ability = agentById(player.agent).abilities[slot]; | |
| 367 | if (slot === "x") say(state, `${player.name} used ${ability.name}`); | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | function shoot(state: MatchState, player: Player, dt: number): void { | |
| 372 | player.fireIn = Math.max(0, player.fireIn - dt); | |
| 373 | if (player.reloadIn > 0) { | |
| 374 | player.reloadIn -= dt; | |
| 375 | if (player.reloadIn <= 0) { | |
| 376 | player.ammo = WEAPONS[player.weapon].magazine; | |
| 377 | player.reloadIn = 0; | |
| 378 | } | |
| 379 | return; | |
| 380 | } | |
| 381 | const weapon = WEAPONS[player.weapon]; | |
| 382 | if (player.input.reload && player.ammo < weapon.magazine) { | |
| 383 | player.input.reload = false; | |
| 384 | player.reloadIn = weapon.reload; | |
| 385 | return; | |
| 386 | } | |
| 387 | if (!player.input.fire || player.fireIn > 0) return; | |
| 388 | if (!weapon.auto) player.input.fire = false; | |
| 389 | if (player.ammo <= 0) { | |
| 390 | player.reloadIn = weapon.reload; | |
| 391 | return; | |
| 392 | } | |
| 393 | player.ammo -= 1; | |
| 394 | player.fireIn = 1 / weapon.rate; | |
| 395 | player.spray = Math.min(6, player.spray + 1); | |
| 396 | ||
| 397 | const moving = Math.hypot(player.input.mx, player.input.my) > 0.1 ? 1 : 0; | |
| 398 | // Blind aim is a guess: the shot goes somewhere near where you were looking. | |
| 399 | const wobble = player.blind > 0 ? 0.5 : 0; | |
| 400 | const spread = shotSpread(player.weapon, moving) + player.spray * 0.006 + wobble; | |
| 401 | const angle = player.angle + (random(state) - 0.5) * 2 * spread; | |
| 402 | const from: Point = { x: player.x, y: player.y }; | |
| 403 | const wall = rayHitWall(from, angle, weapon.range); | |
| 404 | let stop = wall; | |
| 405 | // A wall an ability put up stops bullets too; smoke does not. | |
| 406 | for (const zone of state.zones) { | |
| 407 | if (!zone.solid || zone.fuse > 0) continue; | |
| 408 | const near = segmentPoint(from, wall, zone); | |
| 409 | if (near.dist <= zone.r && near.travel < Math.hypot(stop.x - from.x, stop.y - from.y)) | |
| 410 | stop = { x: from.x + Math.cos(angle) * near.travel, y: from.y + Math.sin(angle) * near.travel }; | |
| 411 | } | |
| 412 | ||
| 413 | let best: { target: Player; travel: number; miss: number } | null = null; | |
| 414 | for (const other of state.players) { | |
| 415 | if (!other.alive || other.side === player.side || other.id === player.id) continue; | |
| 416 | const near = segmentPoint(from, stop, other); | |
| 417 | if (near.dist > PLAYER_R || near.travel < 1) continue; | |
| 418 | if (best === null || near.travel < best.travel) best = { target: other, travel: near.travel, miss: near.dist }; | |
| 419 | } | |
| 420 | ||
| 421 | const end = best ? { x: from.x + Math.cos(angle) * best.travel, y: from.y + Math.sin(angle) * best.travel } : stop; | |
| 422 | state.shots.push({ x1: from.x, y1: from.y, x2: end.x, y2: end.y, hit: best !== null, age: 0 }); | |
| 423 | if (!best) return; | |
| 424 | ||
| 425 | const zone = zoneFromMiss(best.miss, PLAYER_R); | |
| 426 | const dealt = damage(player.weapon, zone, best.travel); | |
| 427 | hurt(state, best.target, dealt, player, zone === "head" ? "headshot" : ""); | |
| 428 | } | |
| 429 | ||
| 430 | function hurt(state: MatchState, target: Player, amount: number, from: Player | null, note: string): void { | |
| 431 | if (!target.alive) return; | |
| 432 | const before = target.hp + target.armour; | |
| 433 | const after = applyDamage(target.hp, target.armour, amount); | |
| 434 | target.hp = after.hp; | |
| 435 | target.armour = after.armour; | |
| 436 | if (from) from.damageDone += Math.max(0, before - (target.hp + target.armour)); | |
| 437 | if (target.hp > 0) return; | |
| 438 | target.alive = false; | |
| 439 | target.deaths += 1; | |
| 440 | target.planting = 0; | |
| 441 | // A defuse dies with the defender, back to the half-way checkpoint. | |
| 442 | if (target.defusing > 0) { | |
| 443 | target.defusing = 0; | |
| 444 | state.spike.defuse = state.spike.checkpoint; | |
| 445 | } | |
| 446 | if (target.spike) { | |
| 447 | target.spike = false; | |
| 448 | state.spike.carried = null; | |
| 449 | state.spike.x = target.x; | |
| 450 | state.spike.y = target.y; | |
| 451 | } | |
| 452 | if (from && from.side !== target.side) { | |
| 453 | from.kills += 1; | |
| 454 | from.credits = award(from.credits, 200); | |
| 455 | from.ult = Math.min(10, from.ult + 1); | |
| 456 | say(state, `${from.name} ${note === "headshot" ? "headshot" : "killed"} ${target.name}`); | |
| 457 | } else { | |
| 458 | say(state, `${target.name} died`); | |
| 459 | } | |
| 460 | } | |
| 461 | ||
| 462 | function spikeWork(state: MatchState, player: Player, dt: number): void { | |
| 463 | const spike = state.spike; | |
| 464 | if (player.side === "atk" && !spike.planted) { | |
| 465 | if (!spike.carried && !player.spike && Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH) { | |
| 466 | player.spike = true; | |
| 467 | spike.carried = player.id; | |
| 468 | say(state, `${player.name} picked up the spike`); | |
| 469 | } | |
| 470 | if (player.spike) { | |
| 471 | spike.x = player.x; | |
| 472 | spike.y = player.y; | |
| 473 | const site = siteAt(player.x, player.y); | |
| 474 | if (player.input.use && site) { | |
| 475 | player.planting += dt; | |
| 476 | if (player.planting >= PLANT_TIME) { | |
| 477 | spike.planted = true; | |
| 478 | spike.site = site; | |
| 479 | spike.fuse = FUSE_TIME; | |
| 480 | spike.carried = null; | |
| 481 | player.spike = false; | |
| 482 | player.planting = 0; | |
| 483 | player.ult = Math.min(10, player.ult + 1); | |
| 484 | for (const mate of teammates(state, "atk")) mate.credits = award(mate.credits, 300); | |
| 485 | say(state, `${player.name} planted on ${site}`); | |
| 486 | } | |
| 487 | } else player.planting = 0; | |
| 488 | } | |
| 489 | return; | |
| 490 | } | |
| 491 | if (player.side === "def" && spike.planted) { | |
| 492 | const close = Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH; | |
| 493 | if (player.input.use && close) { | |
| 494 | spike.defuse += dt; | |
| 495 | player.defusing = spike.defuse; | |
| 496 | // Half way is a checkpoint: the next defender starts from there. | |
| 497 | if (spike.defuse >= DEFUSE_CHECKPOINT && spike.checkpoint === 0) { | |
| 498 | spike.checkpoint = DEFUSE_CHECKPOINT; | |
| 499 | say(state, `${player.name} is half way through the defuse`); | |
| 500 | } | |
| 501 | } else if (player.defusing > 0) { | |
| 502 | player.defusing = 0; | |
| 503 | spike.defuse = spike.checkpoint; | |
| 504 | } | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | function orbs(state: MatchState, player: Player): void { | |
| 509 | for (const orb of state.orbs) | |
| 510 | if (!orb.taken && Math.hypot(player.x - orb.x, player.y - orb.y) < 40) { | |
| 511 | orb.taken = true; | |
| 512 | player.ult = Math.min(10, player.ult + 1); | |
| 513 | say(state, `${player.name} took an ult orb`); | |
| 514 | } | |
| 515 | } | |
| 516 | ||
| 517 | function zones(state: MatchState, dt: number): void { | |
| 518 | for (const zone of state.zones) { | |
| 519 | if (zone.fuse > 0) { | |
| 520 | zone.fuse -= dt; | |
| 521 | if (zone.fuse > 0) continue; | |
| 522 | if (zone.kind === "flash") { | |
| 523 | // Pop: everyone with a sight line to it, blinded by how far their | |
| 524 | // back was turned. Smoke between them and it saves them. | |
| 525 | for (const player of state.players) { | |
| 526 | if (!player.alive) continue; | |
| 527 | const blocked = sightBlocked(zone, player, state.zones); | |
| 528 | const within = Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r; | |
| 529 | const blind = within ? flashDuration(zone.damage, player, zone, player.angle, blocked) : 0; | |
| 530 | if (blind > 0) player.blind = Math.max(player.blind, blind); | |
| 531 | } | |
| 532 | } | |
| 533 | continue; | |
| 534 | } | |
| 535 | zone.life -= dt; | |
| 536 | zone.beat -= dt; | |
| 537 | if (zone.kind === "molly" && zone.beat <= 0) { | |
| 538 | zone.beat = 0.5; | |
| 539 | const owner = state.players.find((p) => p.id === zone.owner) ?? null; | |
| 540 | for (const player of state.players) | |
| 541 | if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) | |
| 542 | hurt(state, player, Math.round(zone.damage / 2), owner, ""); | |
| 543 | } | |
| 544 | if (zone.kind === "dart" && zone.beat <= 0 && zone.pulses > 0) { | |
| 545 | zone.beat = 1.6; | |
| 546 | zone.pulses -= 1; | |
| 547 | for (const player of state.players) | |
| 548 | if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r && !sightBlocked(zone, player, state.zones)) | |
| 549 | player.revealed = Math.max(player.revealed, 2); | |
| 550 | } | |
| 551 | if (zone.kind === "heal" && zone.beat <= 0) { | |
| 552 | zone.beat = 0.5; | |
| 553 | const target = state.players.find((p) => p.id === zone.owner); | |
| 554 | if (target && target.alive) { | |
| 555 | target.hp = Math.min(100, target.hp + zone.damage / 6); | |
| 556 | zone.x = target.x; | |
| 557 | zone.y = target.y; | |
| 558 | } | |
| 559 | } | |
| 560 | if (zone.kind === "trip" && zone.life > 0) { | |
| 561 | const caught = state.players.find( | |
| 562 | (p) => p.alive && p.side !== zone.side && Math.hypot(p.x - zone.x, p.y - zone.y) <= zone.r, | |
| 563 | ); | |
| 564 | if (caught) { | |
| 565 | caught.revealed = Math.max(caught.revealed, 4); | |
| 566 | caught.slow = Math.max(caught.slow, 3); | |
| 567 | zone.life = 0; | |
| 568 | say(state, `${caught.name} tripped a wire`); | |
| 569 | } | |
| 570 | } | |
| 571 | // Blackout: a smoke that carries a slow (marked with negative damage). | |
| 572 | if (zone.kind === "smoke" && zone.damage < 0) | |
| 573 | for (const player of state.players) | |
| 574 | if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) | |
| 575 | player.slow = Math.max(player.slow, 0.5); | |
| 576 | } | |
| 577 | state.zones = state.zones.filter((z) => z.life > 0 || z.fuse > 0); | |
| 578 | } | |
| 579 | ||
| 580 | function endConditions(state: MatchState): void { | |
| 581 | const spike = state.spike; | |
| 582 | if (spike.planted && spike.defuse >= DEFUSE_TIME) return finishRound(state, "def", "Spike defused"); | |
| 583 | if (aliveOn(state, "def").length === 0) return finishRound(state, "atk", "Defenders eliminated"); | |
| 584 | if (aliveOn(state, "atk").length === 0 && !spike.planted) | |
| 585 | return finishRound(state, "def", "Attackers eliminated"); | |
| 586 | if (spike.planted) { | |
| 587 | if (spike.fuse <= 0) return finishRound(state, "atk", "Spike detonated"); | |
| 588 | } else if (state.clock <= 0) return finishRound(state, "def", "Time out"); | |
| 589 | } | |
| 590 | ||
| 591 | function finishRound(state: MatchState, winners: Side, why: string): void { | |
| 592 | const winningTeam: Team = winners === "atk" ? state.attackers : state.attackers === "red" ? "blue" : "red"; | |
| 593 | const losingTeam: Team = winningTeam === "red" ? "blue" : "red"; | |
| 594 | state.score[winningTeam] += 1; | |
| 595 | state.lossStreak[losingTeam] += 1; | |
| 596 | state.lossStreak[winningTeam] = 0; | |
| 597 | for (const player of state.players) { | |
| 598 | const won = player.team === winningTeam; | |
| 599 | player.credits = clampCredits( | |
| 600 | player.credits + roundReward(won, state.lossStreak[player.team], state.spike.planted && player.side === "atk"), | |
| 601 | ); | |
| 602 | } | |
| 603 | state.phase = "end"; | |
| 604 | state.clock = END_TIME; | |
| 605 | say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`); | |
| 606 | if (state.score[winningTeam] >= WIN_ROUNDS) { | |
| 607 | state.phase = "over"; | |
| 608 | state.clock = OVER_TIME; | |
| 609 | state.result = `${winningTeam} win ${state.score.red}–${state.score.blue}`; | |
| 610 | say(state, `Match over — ${state.result}. A new one starts in ${OVER_TIME} seconds.`); | |
| 611 | } | |
| 612 | } | |
| 613 | ||
| 614 | function nextRound(state: MatchState): void { | |
| 615 | state.round += 1; | |
| 616 | // Sides swap once twelve rounds are behind us. | |
| 617 | if (state.round === SWAP_AFTER + 1) { | |
| 618 | state.attackers = state.attackers === "red" ? "blue" : "red"; | |
| 619 | say(state, "Sides swap"); | |
| 620 | } | |
| 621 | startRound(state); | |
| 622 | say(state, `Round ${state.round} — buy phase`); | |
| 623 | } | |
| 624 | ||
| 625 | /** What one player is allowed to see: their own side, and what light reaches. */ | |
| 626 | export function visible(state: MatchState, viewer: Player, other: Player): boolean { | |
| 627 | if (other.id === viewer.id || other.side === viewer.side) return true; | |
| 628 | if (!other.alive) return false; | |
| 629 | if (other.revealed > 0) return true; | |
| 630 | if (viewer.blind > 0) return false; | |
| 631 | if (!viewer.alive) return true; // a dead player watches the round out | |
| 632 | return !sightBlocked(viewer, other, state.zones); | |
| 633 | } | |
| 634 | ||
| 635 | export function setInput(player: Player, input: Partial<Input>): void { | |
| 636 | const target = player.input; | |
| 637 | if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx)); | |
| 638 | if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my)); | |
| 639 | if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = input.angle; | |
| 640 | if (typeof input.fire === "boolean") target.fire = input.fire; | |
| 641 | if (typeof input.use === "boolean") target.use = input.use; | |
| 642 | if (typeof input.reload === "boolean") target.reload = input.reload; | |
| 643 | if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast; | |
| 644 | } | |
| 645 | ||
| 646 | export { castable, agentById }; |
src/state.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** | |
| 2 | * The shape of a match. Types only, so the ability module and the match loop | |
| 3 | * can both speak about a player without importing each other. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { Point, Site } from "./map.ts"; | |
| 7 | import type { WeaponId } from "./weapons.ts"; | |
| 8 | ||
| 9 | export type Side = "atk" | "def"; | |
| 10 | export type Slot = "c" | "q" | "e" | "x"; | |
| 11 | export type Team = "red" | "blue"; | |
| 12 | ||
| 13 | export type Phase = "buy" | "live" | "end" | "over"; | |
| 14 | ||
| 15 | export type ZoneKind = "smoke" | "molly" | "wall" | "trip" | "dart" | "flash" | "heal"; | |
| 16 | ||
| 17 | /** Anything an ability leaves on the map, with a clock on it. */ | |
| 18 | export type Zone = { | |
| 19 | id: number; | |
| 20 | kind: ZoneKind; | |
| 21 | owner: string; | |
| 22 | side: Side; | |
| 23 | x: number; | |
| 24 | y: number; | |
| 25 | r: number; | |
| 26 | /** Seconds of life left. */ | |
| 27 | life: number; | |
| 28 | /** Seconds before it goes off (a flash in the air, a trip arming). */ | |
| 29 | fuse: number; | |
| 30 | /** Seconds to the next damage tick or recon pulse. */ | |
| 31 | beat: number; | |
| 32 | pulses: number; | |
| 33 | damage: number; | |
| 34 | /** Blocks line of sight (smoke, wall). */ | |
| 35 | blocks: boolean; | |
| 36 | /** Blocks movement (wall). */ | |
| 37 | solid: boolean; | |
| 38 | }; | |
| 39 | ||
| 40 | export type Input = { | |
| 41 | mx: number; | |
| 42 | my: number; | |
| 43 | angle: number; | |
| 44 | fire: boolean; | |
| 45 | /** Plant, defuse, pick the spike up: one key for all of it. */ | |
| 46 | use: boolean; | |
| 47 | reload: boolean; | |
| 48 | cast: Slot | null; | |
| 49 | }; | |
| 50 | ||
| 51 | export const emptyInput = (): Input => ({ | |
| 52 | mx: 0, | |
| 53 | my: 0, | |
| 54 | angle: 0, | |
| 55 | fire: false, | |
| 56 | use: false, | |
| 57 | reload: false, | |
| 58 | cast: null, | |
| 59 | }); | |
| 60 | ||
| 61 | export type Player = { | |
| 62 | id: string; | |
| 63 | name: string; | |
| 64 | bot: boolean; | |
| 65 | agent: string; | |
| 66 | team: Team; | |
| 67 | side: Side; | |
| 68 | x: number; | |
| 69 | y: number; | |
| 70 | angle: number; | |
| 71 | hp: number; | |
| 72 | armour: number; | |
| 73 | alive: boolean; | |
| 74 | credits: number; | |
| 75 | weapon: WeaponId; | |
| 76 | /** Kept through a round they survived, reset to the Classic when they die. */ | |
| 77 | ammo: number; | |
| 78 | reloadIn: number; | |
| 79 | fireIn: number; | |
| 80 | /** Basic charges bought this round, and signature/ult readiness. */ | |
| 81 | charges: Record<Slot, number>; | |
| 82 | cooldown: Record<Slot, number>; | |
| 83 | ult: number; | |
| 84 | blind: number; | |
| 85 | slow: number; | |
| 86 | haste: number; | |
| 87 | /** Seconds this player stays visible to the other team however dark it is. */ | |
| 88 | revealed: number; | |
| 89 | spike: boolean; | |
| 90 | planting: number; | |
| 91 | defusing: number; | |
| 92 | kills: number; | |
| 93 | deaths: number; | |
| 94 | assists: number; | |
| 95 | damageDone: number; | |
| 96 | input: Input; | |
| 97 | /** Bot scratch space: where it is walking and when it last thought. */ | |
| 98 | goal: Point | null; | |
| 99 | think: number; | |
| 100 | spray: number; | |
| 101 | joinedAt: number; | |
| 102 | }; | |
| 103 | ||
| 104 | export type MatchState = { | |
| 105 | id: string; | |
| 106 | phase: Phase; | |
| 107 | /** Seconds left in the phase. */ | |
| 108 | clock: number; | |
| 109 | round: number; | |
| 110 | score: Record<Team, number>; | |
| 111 | /** Which team attacks this round. */ | |
| 112 | attackers: Team; | |
| 113 | lossStreak: Record<Team, number>; | |
| 114 | players: Player[]; | |
| 115 | zones: Zone[]; | |
| 116 | nextZone: number; | |
| 117 | spike: { carried: string | null; x: number; y: number; planted: boolean; fuse: number; site: Site | null; defuse: number; checkpoint: number }; | |
| 118 | orbs: { x: number; y: number; taken: boolean }[]; | |
| 119 | shots: { x1: number; y1: number; x2: number; y2: number; hit: boolean; age: number }[]; | |
| 120 | events: { text: string; age: number }[]; | |
| 121 | result: string | null; | |
| 122 | seed: number; | |
| 123 | now: number; | |
| 124 | }; |
src/weapons.test.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { applyDamage, damage, isLethal, shotSpread, WEAPONS, zoneFromMiss } from "./weapons.ts"; | |
| 3 | ||
| 4 | test("a Vandal head shot kills through full armour at any range, a Phantom's does not", () => { | |
| 5 | for (const distance of [100, 800, 2000]) expect(isLethal(100, 50, damage("vandal", "head", distance))).toBe(true); | |
| 6 | // The Phantom only manages it inside fifteen metres; past that the falloff | |
| 7 | // leaves the target alive, which is the whole argument between the two guns. | |
| 8 | expect(isLethal(100, 50, damage("phantom", "head", 300))).toBe(true); | |
| 9 | expect(isLethal(100, 50, damage("phantom", "head", 900))).toBe(false); | |
| 10 | expect(isLethal(100, 50, damage("phantom", "head", 2000))).toBe(false); | |
| 11 | // Both still one-tap a head with no armour on. | |
| 12 | expect(isLethal(100, 0, damage("phantom", "head", 2000))).toBe(true); | |
| 13 | }); | |
| 14 | ||
| 15 | test("the Phantom falls off with range and the Vandal does not", () => { | |
| 16 | expect(damage("phantom", "body", 100)).toBe(39); | |
| 17 | expect(damage("phantom", "body", 1600)).toBe(31); | |
| 18 | expect(damage("vandal", "body", 100)).toBe(damage("vandal", "body", 2300)); | |
| 19 | }); | |
| 20 | ||
| 21 | test("armour is absorbed point for point but empties first", () => { | |
| 22 | const hit = applyDamage(100, 50, 40); | |
| 23 | expect(hit.armour).toBe(50 - 27); | |
| 24 | expect(hit.hp).toBe(87); | |
| 25 | // A hundred health behind fifty armour is a hundred and fifty to chew through. | |
| 26 | let hp = 100; | |
| 27 | let armour = 50; | |
| 28 | let shots = 0; | |
| 29 | while (hp > 0) { | |
| 30 | ({ hp, armour } = applyDamage(hp, armour, 40)); | |
| 31 | shots++; | |
| 32 | } | |
| 33 | expect(shots).toBe(4); | |
| 34 | }); | |
| 35 | ||
| 36 | test("body shots never kill in one, whatever the gun, except the Operator", () => { | |
| 37 | expect(isLethal(100, 0, damage("operator", "body", 500))).toBe(true); | |
| 38 | for (const id of ["classic", "ghost", "sheriff", "spectre", "phantom", "vandal"] as const) | |
| 39 | expect(isLethal(100, 0, damage(id, "body", 500))).toBe(false); | |
| 40 | }); | |
| 41 | ||
| 42 | test("where the ray passes decides the hit location", () => { | |
| 43 | expect(zoneFromMiss(0, 16)).toBe("head"); | |
| 44 | expect(zoneFromMiss(8, 16)).toBe("body"); | |
| 45 | expect(zoneFromMiss(15, 16)).toBe("legs"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("running widens the cone and standing still does not", () => { | |
| 49 | expect(shotSpread("vandal", 0)).toBe(WEAPONS.vandal.spread); | |
| 50 | expect(shotSpread("vandal", 1)).toBeGreaterThan(shotSpread("vandal", 0)); | |
| 51 | // The Operator is the accurate one and the Spectre is not. | |
| 52 | expect(shotSpread("operator", 0)).toBeLessThan(shotSpread("spectre", 0)); | |
| 53 | }); |
src/weapons.ts added +194
| @@ -0,0 +1,194 @@ | ||
| 1 | /** | |
| 2 | * Guns: the damage table and what a shot does with it. Pure functions — | |
| 3 | * nothing here knows about a match. | |
| 4 | * | |
| 5 | * Distance is in world units; fifty units is a metre, so the map is about | |
| 6 | * forty-eight metres across and the Phantom's fifteen-metre falloff lands | |
| 7 | * where it should. The rule that matters: a Vandal head shot kills through | |
| 8 | * full armour at any range, a Phantom head shot does not. | |
| 9 | */ | |
| 10 | ||
| 11 | export type WeaponId = | |
| 12 | | "classic" | |
| 13 | | "ghost" | |
| 14 | | "sheriff" | |
| 15 | | "spectre" | |
| 16 | | "phantom" | |
| 17 | | "vandal" | |
| 18 | | "operator"; | |
| 19 | ||
| 20 | export type Zone = "head" | "body" | "legs"; | |
| 21 | ||
| 22 | export type Band = { upTo: number; head: number; body: number; legs: number }; | |
| 23 | ||
| 24 | export type Weapon = { | |
| 25 | id: WeaponId; | |
| 26 | name: string; | |
| 27 | cost: number; | |
| 28 | /** Shots a second. */ | |
| 29 | rate: number; | |
| 30 | magazine: number; | |
| 31 | reload: number; | |
| 32 | /** Cone half-angle in radians when standing still, and the walking penalty. */ | |
| 33 | spread: number; | |
| 34 | movePenalty: number; | |
| 35 | range: number; | |
| 36 | auto: boolean; | |
| 37 | bands: Band[]; | |
| 38 | }; | |
| 39 | ||
| 40 | const METRE = 50; | |
| 41 | ||
| 42 | export const WEAPONS: Record<WeaponId, Weapon> = { | |
| 43 | classic: { | |
| 44 | id: "classic", | |
| 45 | name: "Classic", | |
| 46 | cost: 0, | |
| 47 | rate: 6.75, | |
| 48 | magazine: 12, | |
| 49 | reload: 1.75, | |
| 50 | spread: 0.022, | |
| 51 | movePenalty: 0.05, | |
| 52 | range: 1600, | |
| 53 | auto: false, | |
| 54 | bands: [{ upTo: Infinity, head: 78, body: 26, legs: 22 }], | |
| 55 | }, | |
| 56 | ghost: { | |
| 57 | id: "ghost", | |
| 58 | name: "Ghost", | |
| 59 | cost: 500, | |
| 60 | rate: 6.75, | |
| 61 | magazine: 15, | |
| 62 | reload: 1.5, | |
| 63 | spread: 0.016, | |
| 64 | movePenalty: 0.05, | |
| 65 | range: 1800, | |
| 66 | auto: false, | |
| 67 | bands: [ | |
| 68 | { upTo: 30 * METRE, head: 105, body: 30, legs: 26 }, | |
| 69 | { upTo: Infinity, head: 88, body: 25, legs: 21 }, | |
| 70 | ], | |
| 71 | }, | |
| 72 | sheriff: { | |
| 73 | id: "sheriff", | |
| 74 | name: "Sheriff", | |
| 75 | cost: 800, | |
| 76 | rate: 4, | |
| 77 | magazine: 6, | |
| 78 | reload: 2.25, | |
| 79 | spread: 0.02, | |
| 80 | movePenalty: 0.09, | |
| 81 | range: 2000, | |
| 82 | auto: false, | |
| 83 | bands: [ | |
| 84 | { upTo: 30 * METRE, head: 159, body: 55, legs: 46 }, | |
| 85 | { upTo: Infinity, head: 145, body: 50, legs: 42 }, | |
| 86 | ], | |
| 87 | }, | |
| 88 | spectre: { | |
| 89 | id: "spectre", | |
| 90 | name: "Spectre", | |
| 91 | cost: 1600, | |
| 92 | rate: 13.33, | |
| 93 | magazine: 30, | |
| 94 | reload: 2.5, | |
| 95 | spread: 0.03, | |
| 96 | movePenalty: 0.03, | |
| 97 | range: 1600, | |
| 98 | auto: true, | |
| 99 | bands: [ | |
| 100 | { upTo: 20 * METRE, head: 78, body: 26, legs: 22 }, | |
| 101 | { upTo: Infinity, head: 66, body: 22, legs: 18 }, | |
| 102 | ], | |
| 103 | }, | |
| 104 | phantom: { | |
| 105 | id: "phantom", | |
| 106 | name: "Phantom", | |
| 107 | cost: 2900, | |
| 108 | rate: 11, | |
| 109 | magazine: 30, | |
| 110 | reload: 2.5, | |
| 111 | spread: 0.021, | |
| 112 | movePenalty: 0.06, | |
| 113 | range: 2400, | |
| 114 | auto: true, | |
| 115 | bands: [ | |
| 116 | { upTo: 15 * METRE, head: 156, body: 39, legs: 33 }, | |
| 117 | { upTo: 30 * METRE, head: 140, body: 35, legs: 29 }, | |
| 118 | { upTo: Infinity, head: 124, body: 31, legs: 26 }, | |
| 119 | ], | |
| 120 | }, | |
| 121 | vandal: { | |
| 122 | id: "vandal", | |
| 123 | name: "Vandal", | |
| 124 | cost: 2900, | |
| 125 | rate: 9.75, | |
| 126 | magazine: 25, | |
| 127 | reload: 2.5, | |
| 128 | spread: 0.024, | |
| 129 | movePenalty: 0.07, | |
| 130 | range: 2400, | |
| 131 | auto: true, | |
| 132 | bands: [{ upTo: Infinity, head: 160, body: 40, legs: 34 }], | |
| 133 | }, | |
| 134 | operator: { | |
| 135 | id: "operator", | |
| 136 | name: "Operator", | |
| 137 | cost: 4700, | |
| 138 | rate: 0.6, | |
| 139 | magazine: 5, | |
| 140 | reload: 3.7, | |
| 141 | spread: 0.004, | |
| 142 | movePenalty: 0.2, | |
| 143 | range: 2800, | |
| 144 | auto: false, | |
| 145 | bands: [{ upTo: Infinity, head: 255, body: 150, legs: 120 }], | |
| 146 | }, | |
| 147 | }; | |
| 148 | ||
| 149 | export const weaponIds = (): WeaponId[] => Object.keys(WEAPONS) as WeaponId[]; | |
| 150 | ||
| 151 | export const isWeaponId = (value: unknown): value is WeaponId => | |
| 152 | typeof value === "string" && value in WEAPONS; | |
| 153 | ||
| 154 | /** Raw damage for a hit: the band the distance falls in, by zone. */ | |
| 155 | export function damage(id: WeaponId, zone: Zone, distance: number): number { | |
| 156 | const weapon = WEAPONS[id]; | |
| 157 | const band = weapon.bands.find((b) => distance <= b.upTo) ?? weapon.bands[weapon.bands.length - 1]!; | |
| 158 | return band[zone]; | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Armour absorbs point for point — a hundred health behind fifty armour is a | |
| 163 | * hundred and fifty to chew through — but it takes two thirds of each hit, | |
| 164 | * so it runs out before health does. | |
| 165 | */ | |
| 166 | export function applyDamage( | |
| 167 | hp: number, | |
| 168 | armour: number, | |
| 169 | amount: number, | |
| 170 | ): { hp: number; armour: number } { | |
| 171 | const toArmour = Math.min(armour, Math.ceil(amount * (2 / 3))); | |
| 172 | return { hp: Math.max(0, hp - (amount - toArmour)), armour: armour - toArmour }; | |
| 173 | } | |
| 174 | ||
| 175 | /** True when this hit kills outright: the test the Vandal exists to pass. */ | |
| 176 | export const isLethal = (hp: number, armour: number, amount: number): boolean => | |
| 177 | applyDamage(hp, armour, amount).hp <= 0; | |
| 178 | ||
| 179 | /** | |
| 180 | * Where a shot lands, from how close the ray passes the target's centre. | |
| 181 | * Top-down has no vertical, so precision stands in for it: dead centre is a | |
| 182 | * head shot, the outer edge is legs. | |
| 183 | */ | |
| 184 | export function zoneFromMiss(miss: number, radius: number): Zone { | |
| 185 | if (miss <= radius * 0.33) return "head"; | |
| 186 | if (miss <= radius * 0.75) return "body"; | |
| 187 | return "legs"; | |
| 188 | } | |
| 189 | ||
| 190 | /** Cone half-angle for a shot: standing still is accurate, running is not. */ | |
| 191 | export const shotSpread = (id: WeaponId, speedFraction: number): number => | |
| 192 | WEAPONS[id].spread + WEAPONS[id].movePenalty * Math.min(1, Math.max(0, speedFraction)); | |
| 193 | ||
| 194 | export const SHIELD = { light: { cost: 400, armour: 25 }, heavy: { cost: 1000, armour: 50 } }; |