quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge pull request #2 from wren/1-build-val-valorant-honestly-scaled-to-th
Build val — Valorant, honestly scaled to this runtime
19 files
changed +3805
−163
README.md +72 −13
| @@ -1,23 +1,82 @@ | ||
| 1 | 1 | # val |
| 2 | 2 | |
| 3 | Five attackers with the spike, five defenders, and abilities on cooldown. First to 13. | |
| 3 | Five attackers with the spike, five defenders, abilities on cooldown. First to 13. | |
| 4 | 4 | |
| 5 | This is an app: the forge runs it. Production follows the default branch; | |
| 6 | every open pull gets a preview at its own address with its own copy of the | |
| 7 | data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what | |
| 8 | makes "production data" and "preview data" two different things. | |
| 5 | A top-down 5v5 played in a browser. One match is always running on the | |
| 6 | server: open the page and you are given a seat in the round that is | |
| 7 | happening, taken from a bot where it stands. When a match ends it shows the | |
| 8 | result for a few seconds and starts a new one, so nobody ever arrives at a | |
| 9 | frozen board. | |
| 10 | ||
| 11 | ## What is real | |
| 12 | ||
| 13 | Everything that decides a round is computed on the server and only the | |
| 14 | server: | |
| 15 | ||
| 16 | - **Movement, shooting and hit detection.** The client sends intent — a | |
| 17 | direction, an angle, a held key — never a position. Damage is by hit | |
| 18 | location (how close the ray passes the target) with falloff and armour: | |
| 19 | a Vandal head shot kills through full armour at any range, a Phantom's | |
| 20 | does not past fifteen metres. | |
| 21 | - **Line of sight.** A snapshot only carries the enemies that viewer can | |
| 22 | see. A wall of the map blocks the sight line, a smoke blocks it, a flash | |
| 23 | blinds by the angle you were facing, and a blinded player is sent no | |
| 24 | enemies at all. A dead player watches through their living team mates' | |
| 25 | eyes rather than through walls, so dying is not a wallhack to call out. | |
| 26 | No spawn can see the other spawn: mid is staggered on purpose. A patched | |
| 27 | client learns nothing a plain one does not. | |
| 28 | - **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a | |
| 29 | checkpoint at 3.5s — a defender killed past half way leaves the defuse | |
| 30 | half done for the next one. | |
| 31 | - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a | |
| 32 | plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is | |
| 33 | refused server-side. | |
| 34 | - **Bots** fill every empty seat and use their kit: smokes on the way in, | |
| 35 | flashes before the entry, a recon dart, a molly on the planted spike. | |
| 36 | ||
| 37 | Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits | |
| 38 | (charges last the round), a free signature on a cooldown, and an ultimate | |
| 39 | paid for with ult points from kills, plants and the two orbs on the map. | |
| 40 | ||
| 41 | Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | |
| 42 | ||
| 43 | ## What is not | |
| 44 | ||
| 45 | - One map, one match at a time, ten seats. There is no matchmaking, no | |
| 46 | ranking and no account: your seat belongs to a session cookie. | |
| 47 | - No overtime — first to 13 ends it at 12–12 too. | |
| 48 | - Sight is a full circle rather than a view cone; top-down has no vertical, | |
| 49 | so the hit location comes from how precisely the shot passes the target. | |
| 50 | - Assists are counted as a field but never awarded. | |
| 51 | - The live match lives in memory and is gone if the process restarts; only | |
| 52 | finished match results are written to SQLite under `FORGE_APP_DATA`. | |
| 53 | ||
| 54 | ## Playing | |
| 55 | ||
| 56 | WASD move, the mouse aims, click fires, **R** reloads, **F** plants and | |
| 57 | defuses (hold it), **C / Q / E / X** cast, **B** opens the buy. On a phone | |
| 58 | the left thumb walks and the right thumb aims and fires. | |
| 9 | 59 | |
| 10 | 60 | sh |
| 11 | 61 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| 12 | 62 | bun test # the check every pull runs |
| 13 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | |
| 63 | VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts # the terminal interface | |
| 14 | 64 | |
| 15 | 65 | |
| 16 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app | |
| 17 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is | |
| 18 | the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on | |
| 19 | a real pty over the same data. | |
| 66 | The terminal plays the same match over the same socket: it draws the grid, | |
| 67 | the scoreboard and the round, and takes a seat. The exact command that | |
| 68 | reaches it is in `FORGE_APP_SSH` when the app runs, and the page prints it. | |
| 20 | 69 | |
| 21 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` | |
| 22 | (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT` | |
| 23 | when it starts the app. `forge.json` says how. | |
| 70 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`, `FORGE_APP_URL`, | |
| 71 | `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it starts the app; `forge.json` | |
| 72 | says how. | |
| 73 | ||
| 74 | ## Shape | |
| 75 | ||
| 76 | `src/map.ts` the grid and its geometry · `src/weapons.ts` the damage table · | |
| 77 | `src/economy.ts` credits · `src/abilities.ts` the agent kits · | |
| 78 | `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes | |
| 79 | no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one | |
| 80 | live match and its sockets · `src/server.ts` the door · `static/val.js` the | |
| 81 | browser. The rules are pure functions and the tests play whole matches | |
| 82 | headlessly. |
src/abilities.test.ts added +142
| @@ -0,0 +1,142 @@ | ||
| 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 | // Along the back corridor, which is open from one side of the map to the other. | |
| 123 | player.x = 400; | |
| 124 | player.y = 1400; | |
| 125 | player.angle = 0; | |
| 126 | const behind = { x: 1000, y: 1400 }; | |
| 127 | const openBefore = !sightBlocked(player, behind, state.zones); | |
| 128 | expect(cast(state, player, "e")).toBe(true); | |
| 129 | expect(openBefore).toBe(true); | |
| 130 | expect(sightBlocked(player, behind, state.zones)).toBe(true); | |
| 131 | expect(state.zones.every((z) => z.solid)).toBe(true); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a round starts with the signature ready and no bought charges", () => { | |
| 135 | const state = newMatch(3); | |
| 136 | startRound(state, true); | |
| 137 | for (const player of state.players) { | |
| 138 | expect(player.charges.c).toBe(0); | |
| 139 | expect(player.charges.q).toBe(0); | |
| 140 | expect(player.charges.e).toBe(1); | |
| 141 | } | |
| 142 | }); |
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/arena.ts added +256
| @@ -0,0 +1,256 @@ | ||
| 1 | /** | |
| 2 | * The arena: one match, always running, and the seats people sit in. | |
| 3 | * | |
| 4 | * The match ticks whether anybody is watching or not, so a visitor never | |
| 5 | * arrives at a frozen board — they get the round that is happening. Taking a | |
| 6 | * seat replaces a bot in place: same body, same credits, same position, so | |
| 7 | * joining mid-round hands you a live player rather than a lobby. | |
| 8 | * | |
| 9 | * Every snapshot is built for one viewer: enemies they cannot see are not in | |
| 10 | * it. The client never learns a position it has no line of sight to. | |
| 11 | */ | |
| 12 | ||
| 13 | import { agentById, sightBlocked } from "./abilities.ts"; | |
| 14 | import { runBots } from "./bots.ts"; | |
| 15 | import { recordResult } from "./db.ts"; | |
| 16 | import type { Point } from "./map.ts"; | |
| 17 | import { | |
| 18 | buyAbility, | |
| 19 | buyShield, | |
| 20 | buyWeapon, | |
| 21 | newMatch, | |
| 22 | pickAgent, | |
| 23 | setInput, | |
| 24 | tick, | |
| 25 | visible, | |
| 26 | } from "./match.ts"; | |
| 27 | import type { MatchState, Player } from "./state.ts"; | |
| 28 | import { isWeaponId } from "./weapons.ts"; | |
| 29 | ||
| 30 | export const TICK = 1 / 30; | |
| 31 | const BROADCAST_EVERY = 2; | |
| 32 | ||
| 33 | export type Seat = { playerId: string; send: (data: string) => void }; | |
| 34 | ||
| 35 | let state: MatchState = newMatch(); | |
| 36 | const seats = new Map<string, Seat>(); | |
| 37 | let ticks = 0; | |
| 38 | let lastResult: string | null = null; | |
| 39 | ||
| 40 | export const match = (): MatchState => state; | |
| 41 | ||
| 42 | export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id); | |
| 43 | ||
| 44 | /** Sit somebody down in the emptiest team, replacing a bot where it stands. */ | |
| 45 | export function takeSeat(session: string, name: string): Player | null { | |
| 46 | const existing = seats.get(session); | |
| 47 | const held = existing ? findPlayer(existing.playerId) : undefined; | |
| 48 | if (held) { | |
| 49 | held.name = name; | |
| 50 | return held; | |
| 51 | } | |
| 52 | const humans = { red: 0, blue: 0 }; | |
| 53 | for (const player of state.players) if (!player.bot) humans[player.team] += 1; | |
| 54 | const wanted = humans.red <= humans.blue ? "red" : "blue"; | |
| 55 | const seat = | |
| 56 | state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); | |
| 57 | if (!seat) return null; | |
| 58 | seat.bot = false; | |
| 59 | seat.name = name; | |
| 60 | seat.think = 0; | |
| 61 | seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; | |
| 62 | return seat; | |
| 63 | } | |
| 64 | ||
| 65 | export function attach(session: string, playerId: string, send: (data: string) => void): void { | |
| 66 | seats.set(session, { playerId, send }); | |
| 67 | } | |
| 68 | ||
| 69 | /** A person leaving gives the seat back to a bot, mid-round and all. */ | |
| 70 | export function release(session: string): void { | |
| 71 | const seat = seats.get(session); | |
| 72 | seats.delete(session); | |
| 73 | if (!seat) return; | |
| 74 | const player = findPlayer(seat.playerId); | |
| 75 | if (player && !stillHeld(seat.playerId)) { | |
| 76 | player.bot = true; | |
| 77 | player.think = 0; | |
| 78 | player.input.fire = false; | |
| 79 | player.input.use = false; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | const stillHeld = (playerId: string): boolean => | |
| 84 | [...seats.values()].some((s) => s.playerId === playerId); | |
| 85 | ||
| 86 | export const seatCount = (): number => seats.size; | |
| 87 | ||
| 88 | // ------------------------------------------------------------- the commands | |
| 89 | ||
| 90 | export type Command = { t: string; [key: string]: unknown }; | |
| 91 | ||
| 92 | const num = (value: unknown): number | undefined => | |
| 93 | typeof value === "number" && Number.isFinite(value) ? value : undefined; | |
| 94 | ||
| 95 | /** Everything a client may ask for, validated here and nowhere else. */ | |
| 96 | export function command(playerId: string, message: Command): void { | |
| 97 | const player = findPlayer(playerId); | |
| 98 | if (!player || player.bot) return; | |
| 99 | switch (message.t) { | |
| 100 | case "i": | |
| 101 | setInput(player, { | |
| 102 | mx: num(message.mx), | |
| 103 | my: num(message.my), | |
| 104 | angle: num(message.a), | |
| 105 | fire: typeof message.f === "boolean" ? message.f : undefined, | |
| 106 | use: typeof message.u === "boolean" ? message.u : undefined, | |
| 107 | reload: typeof message.r === "boolean" ? message.r : undefined, | |
| 108 | cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined, | |
| 109 | }); | |
| 110 | break; | |
| 111 | case "buy": { | |
| 112 | const what = String(message.what ?? ""); | |
| 113 | if (isWeaponId(what)) buyWeapon(state, player, what); | |
| 114 | else if (what === "light" || what === "heavy") buyShield(state, player, what); | |
| 115 | else if (what === "c" || what === "q") buyAbility(state, player, what); | |
| 116 | break; | |
| 117 | } | |
| 118 | case "agent": | |
| 119 | if (typeof message.id === "string") pickAgent(state, player, message.id); | |
| 120 | break; | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | // ------------------------------------------------------------- the snapshot | |
| 125 | ||
| 126 | const round2 = (n: number): number => Math.round(n * 10) / 10; | |
| 127 | ||
| 128 | export function snapshot(viewerId: string): string { | |
| 129 | const me = findPlayer(viewerId) ?? state.players[0]!; | |
| 130 | const agent = agentById(me.agent); | |
| 131 | const seen = state.players.filter((p) => visible(state, me, p)); | |
| 132 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); | |
| 133 | ||
| 134 | return JSON.stringify({ | |
| 135 | t: "s", | |
| 136 | phase: state.phase, | |
| 137 | clock: round2(Math.max(0, state.clock)), | |
| 138 | round: state.round, | |
| 139 | score: state.score, | |
| 140 | attackers: state.attackers, | |
| 141 | result: state.result, | |
| 142 | me: { | |
| 143 | id: me.id, | |
| 144 | side: me.side, | |
| 145 | team: me.team, | |
| 146 | agent: me.agent, | |
| 147 | alive: me.alive, | |
| 148 | x: round2(me.x), | |
| 149 | y: round2(me.y), | |
| 150 | angle: round2(me.angle), | |
| 151 | hp: Math.round(me.hp), | |
| 152 | armour: Math.round(me.armour), | |
| 153 | credits: me.credits, | |
| 154 | weapon: me.weapon, | |
| 155 | ammo: me.ammo, | |
| 156 | reloading: me.reloadIn > 0, | |
| 157 | charges: me.charges, | |
| 158 | cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, | |
| 159 | ult: me.ult, | |
| 160 | ultNeeds: agent.abilities.x.points, | |
| 161 | blind: round2(me.blind), | |
| 162 | slow: round2(me.slow), | |
| 163 | haste: round2(me.haste), | |
| 164 | spike: me.spike, | |
| 165 | planting: round2(me.planting), | |
| 166 | bot: me.bot, | |
| 167 | }, | |
| 168 | players: seen.map((p) => ({ | |
| 169 | id: p.id, | |
| 170 | name: p.name, | |
| 171 | agent: p.agent, | |
| 172 | x: round2(p.x), | |
| 173 | y: round2(p.y), | |
| 174 | angle: round2(p.angle), | |
| 175 | hp: Math.round(p.hp), | |
| 176 | armour: Math.round(p.armour), | |
| 177 | alive: p.alive, | |
| 178 | mate: p.side === me.side, | |
| 179 | spike: p.spike, | |
| 180 | blind: p.blind > 0, | |
| 181 | revealed: p.revealed > 0, | |
| 182 | })), | |
| 183 | zones: state.zones | |
| 184 | .filter((z) => (z.kind === "trip" ? z.side === me.side : true)) | |
| 185 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), | |
| 186 | spike: { | |
| 187 | planted: state.spike.planted, | |
| 188 | carried: state.spike.carried !== null, | |
| 189 | fuse: round2(state.spike.fuse), | |
| 190 | defuse: round2(state.spike.defuse), | |
| 191 | checkpoint: state.spike.checkpoint, | |
| 192 | // A loose spike is the attackers' business until it is armed. | |
| 193 | x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, | |
| 194 | y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, | |
| 195 | }, | |
| 196 | orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), | |
| 197 | shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), | |
| 198 | board: state.players.map((p) => ({ | |
| 199 | id: p.id, | |
| 200 | name: p.name, | |
| 201 | team: p.team, | |
| 202 | side: p.side, | |
| 203 | agent: p.agent, | |
| 204 | bot: p.bot, | |
| 205 | alive: p.alive, | |
| 206 | kills: p.kills, | |
| 207 | deaths: p.deaths, | |
| 208 | damage: Math.round(p.damageDone), | |
| 209 | credits: p.team === me.team ? p.credits : null, | |
| 210 | ult: p.ult, | |
| 211 | ultNeeds: agentById(p.agent).abilities.x.points, | |
| 212 | })), | |
| 213 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 214 | }); | |
| 215 | } | |
| 216 | ||
| 217 | // ----------------------------------------------------------------- the loop | |
| 218 | ||
| 219 | function step(): void { | |
| 220 | runBots(state, TICK); | |
| 221 | tick(state, TICK); | |
| 222 | if (state.result && state.result !== lastResult) { | |
| 223 | lastResult = state.result; | |
| 224 | recordResult(state.result, state.score.red, state.score.blue, state.round); | |
| 225 | } | |
| 226 | if (state.result === null) lastResult = null; | |
| 227 | ticks += 1; | |
| 228 | if (ticks % BROADCAST_EVERY !== 0) return; | |
| 229 | for (const seat of seats.values()) { | |
| 230 | try { | |
| 231 | seat.send(snapshot(seat.playerId)); | |
| 232 | } catch { | |
| 233 | // A socket that will not take a frame is closed already; the close | |
| 234 | // handler gives the seat back. | |
| 235 | } | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | let timer: ReturnType<typeof setInterval> | null = null; | |
| 240 | ||
| 241 | export function startArena(): void { | |
| 242 | if (timer) return; | |
| 243 | timer = setInterval(step, TICK * 1000); | |
| 244 | // The clock must never hold the process open on its own. | |
| 245 | (timer as unknown as { unref?: () => void }).unref?.(); | |
| 246 | } | |
| 247 | ||
| 248 | export function stopArena(): void { | |
| 249 | if (timer) clearInterval(timer); | |
| 250 | timer = null; | |
| 251 | } | |
| 252 | ||
| 253 | /** Used by the tests: run the arena forward without waiting for real time. */ | |
| 254 | export function advance(seconds: number): void { | |
| 255 | for (let i = 0; i < Math.round(seconds / TICK); i++) step(); | |
| 256 | } |
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/db.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * What survives a restart: finished matches. The live match lives in memory | |
| 3 | * — it is gone the moment the process is — but its result is written here, | |
| 4 | * under FORGE_APP_DATA, so the page can say what happened before you arrived. | |
| 5 | */ | |
| 6 | ||
| 7 | import { Database } from "bun:sqlite"; | |
| 8 | import { mkdirSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | ||
| 11 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 12 | mkdirSync(dataDir, { recursive: true }); | |
| 13 | ||
| 14 | export const db = new Database(join(dataDir, "app.db")); | |
| 15 | db.exec("PRAGMA journal_mode = WAL"); | |
| 16 | db.exec( | |
| 17 | `CREATE TABLE IF NOT EXISTS result ( | |
| 18 | id INTEGER PRIMARY KEY, | |
| 19 | summary TEXT NOT NULL, | |
| 20 | red INTEGER NOT NULL, | |
| 21 | blue INTEGER NOT NULL, | |
| 22 | rounds INTEGER NOT NULL, | |
| 23 | ended_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 24 | )`, | |
| 25 | ); | |
| 26 | ||
| 27 | export type Result = { summary: string; red: number; blue: number; rounds: number; ended_at: string }; | |
| 28 | ||
| 29 | export const recordResult = (summary: string, red: number, blue: number, rounds: number): void => { | |
| 30 | db.query("INSERT INTO result (summary, red, blue, rounds) VALUES (?1, ?2, ?3, ?4)").run( | |
| 31 | summary.slice(0, 120), | |
| 32 | red, | |
| 33 | blue, | |
| 34 | rounds, | |
| 35 | ); | |
| 36 | }; | |
| 37 | ||
| 38 | export const recentResults = (limit = 5): Result[] => | |
| 39 | db | |
| 40 | .query<Result, [number]>( | |
| 41 | "SELECT summary, red, blue, rounds, ended_at FROM result ORDER BY id DESC LIMIT ?1", | |
| 42 | ) | |
| 43 | .all(Math.max(1, Math.min(50, limit))); |
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 +373
| @@ -0,0 +1,373 @@ | ||
| 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, spawnCells, wallBetween } 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 wall hides two players from each other, and open floor does not", () => { | |
| 275 | const state = live(); | |
| 276 | const viewer = atk(state)[0]!; | |
| 277 | const enemy = def(state)[0]!; | |
| 278 | viewer.blind = 0; | |
| 279 | enemy.revealed = 0; | |
| 280 | // Either side of the block between A site and the middle of the map. | |
| 281 | viewer.x = 400; | |
| 282 | viewer.y = 400; | |
| 283 | enemy.x = 1200; | |
| 284 | enemy.y = 400; | |
| 285 | expect(wallBetween(viewer, enemy)).toBe(true); | |
| 286 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 287 | // The same two down the open back corridor, with nothing in between. | |
| 288 | viewer.x = 400; | |
| 289 | viewer.y = 1400; | |
| 290 | enemy.x = 1000; | |
| 291 | enemy.y = 1400; | |
| 292 | expect(wallBetween(viewer, enemy)).toBe(false); | |
| 293 | expect(visible(state, viewer, enemy)).toBe(true); | |
| 294 | }); | |
| 295 | ||
| 296 | test("no spawn can see a spawn: mid is staggered, not a corridor", () => { | |
| 297 | for (const from of spawnCells("atk")) | |
| 298 | for (const to of spawnCells("def")) expect(wallBetween(from, to)).toBe(true); | |
| 299 | }); | |
| 300 | ||
| 301 | test("a dead player watches through the team, never through walls", () => { | |
| 302 | const state = live(); | |
| 303 | const viewer = atk(state)[0]!; | |
| 304 | const mate = atk(state)[1]!; | |
| 305 | const enemy = def(state)[0]!; | |
| 306 | for (const player of atk(state)) { | |
| 307 | player.alive = false; | |
| 308 | player.x = 400; | |
| 309 | player.y = 400; | |
| 310 | } | |
| 311 | enemy.revealed = 0; | |
| 312 | enemy.x = 1000; | |
| 313 | enemy.y = 1400; | |
| 314 | // Every attacker is dead: the enemy across the map is nobody's to see. | |
| 315 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 316 | // One team mate alive with a sight line, and the dead player sees it too. | |
| 317 | mate.alive = true; | |
| 318 | mate.x = 400; | |
| 319 | mate.y = 1400; | |
| 320 | expect(visible(state, viewer, enemy)).toBe(true); | |
| 321 | // That team mate behind a wall takes the vision away again. | |
| 322 | mate.x = 400; | |
| 323 | mate.y = 400; | |
| 324 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 325 | }); | |
| 326 | ||
| 327 | test("a blinded player sees nothing", () => { | |
| 328 | const state = live(); | |
| 329 | const viewer = atk(state)[0]!; | |
| 330 | const enemy = def(state)[0]!; | |
| 331 | viewer.x = 1200; | |
| 332 | viewer.y = 1320; | |
| 333 | enemy.x = 1200; | |
| 334 | enemy.y = 1000; | |
| 335 | viewer.blind = 1.4; | |
| 336 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 337 | }); | |
| 338 | ||
| 339 | test("a killed carrier drops the spike where they fell", () => { | |
| 340 | const state = live(); | |
| 341 | const carrier = onSite(state); | |
| 342 | const killer = def(state)[0]!; | |
| 343 | killer.x = carrier.x + 40; | |
| 344 | killer.y = carrier.y; | |
| 345 | killer.angle = Math.PI; | |
| 346 | killer.weapon = "operator"; | |
| 347 | killer.input.fire = true; | |
| 348 | run(state, 1); | |
| 349 | if (!carrier.alive) { | |
| 350 | expect(state.spike.carried).toBe(null); | |
| 351 | expect(Math.hypot(state.spike.x - carrier.x, state.spike.y - carrier.y)).toBeLessThan(80); | |
| 352 | } | |
| 353 | expect(state.shots.length + state.events.length).toBeGreaterThan(0); | |
| 354 | }); | |
| 355 | ||
| 356 | test("bots play the round: they buy, they take a site and they use their kit", () => { | |
| 357 | const state = newMatch(42); | |
| 358 | let casts = 0; | |
| 359 | let rounds = 0; | |
| 360 | for (let i = 0; i < 30 * 60 * 6; i++) { | |
| 361 | runBots(state, 1 / 30); | |
| 362 | tick(state, 1 / 30); | |
| 363 | casts += state.zones.length > 0 ? 1 : 0; | |
| 364 | if (state.round > rounds) rounds = state.round; | |
| 365 | } | |
| 366 | expect(rounds).toBeGreaterThan(1); | |
| 367 | expect(state.score.red + state.score.blue).toBeGreaterThan(1); | |
| 368 | // Something was on the map: smokes, flashes, mollies, walls. | |
| 369 | expect(casts).toBeGreaterThan(100); | |
| 370 | // And somebody bought a real gun with the credits they won. | |
| 371 | expect(state.players.some((p) => p.weapon !== "classic")).toBe(true); | |
| 372 | expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0); | |
| 373 | }); |
src/match.ts added +652
| @@ -0,0 +1,652 @@ | ||
| 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 | // A dead player watches the round out through their team's eyes, not | |
| 632 | // through the walls: what a living team mate can see, and nothing more. | |
| 633 | // Otherwise dying and staying connected would be a wallhack to call out. | |
| 634 | if (!viewer.alive) | |
| 635 | return state.players.some( | |
| 636 | (mate) => mate.side === viewer.side && mate.alive && mate.blind <= 0 && !sightBlocked(mate, other, state.zones), | |
| 637 | ); | |
| 638 | return !sightBlocked(viewer, other, state.zones); | |
| 639 | } | |
| 640 | ||
| 641 | export function setInput(player: Player, input: Partial<Input>): void { | |
| 642 | const target = player.input; | |
| 643 | if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx)); | |
| 644 | if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my)); | |
| 645 | if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = input.angle; | |
| 646 | if (typeof input.fire === "boolean") target.fire = input.fire; | |
| 647 | if (typeof input.use === "boolean") target.use = input.use; | |
| 648 | if (typeof input.reload === "boolean") target.reload = input.reload; | |
| 649 | if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast; | |
| 650 | } | |
| 651 | ||
| 652 | export { castable, agentById }; |
src/pages/game.ts added +178
| @@ -0,0 +1,178 @@ | ||
| 1 | /** | |
| 2 | * The page. The first paint is the match as it stands right now — score, | |
| 3 | * round, phase, scoreboard — server-rendered HTML, so somebody arriving with | |
| 4 | * a cold cache or no JavaScript still sees a live match. The client script | |
| 5 | * then takes the same state over a socket and draws it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { AGENTS } from "../abilities.ts"; | |
| 9 | import type { Result } from "../db.ts"; | |
| 10 | import type { MatchState } from "../state.ts"; | |
| 11 | import { WEAPONS, weaponIds } from "../weapons.ts"; | |
| 12 | ||
| 13 | export const escape = (s: string): string => | |
| 14 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 15 | ||
| 16 | const phaseWord = (state: MatchState): string => | |
| 17 | state.phase === "buy" | |
| 18 | ? "buy phase" | |
| 19 | : state.phase === "live" | |
| 20 | ? state.spike.planted | |
| 21 | ? `spike down · ${Math.ceil(state.spike.fuse)}s` | |
| 22 | : "live" | |
| 23 | : state.phase === "end" | |
| 24 | ? "round over" | |
| 25 | : "match over"; | |
| 26 | ||
| 27 | export function gamePage(opts: { | |
| 28 | state: MatchState; | |
| 29 | script: string; | |
| 30 | name: string; | |
| 31 | env: string; | |
| 32 | commit: string; | |
| 33 | ssh: string | undefined; | |
| 34 | results: Result[]; | |
| 35 | }): string { | |
| 36 | const { state } = opts; | |
| 37 | const board = [...state.players].sort((a, b) => b.kills - a.kills); | |
| 38 | const row = (side: "atk" | "def"): string => | |
| 39 | board | |
| 40 | .filter((p) => p.side === side) | |
| 41 | .map( | |
| 42 | (p) => | |
| 43 | `<tr><td>${escape(p.name)}${p.bot ? " <small>bot</small>" : ""}</td><td>${escape(p.agent)}</td><td>${p.kills}</td><td>${p.deaths}</td></tr>`, | |
| 44 | ) | |
| 45 | .join(""); | |
| 46 | ||
| 47 | return `<!doctype html> | |
| 48 | <html lang="en"> | |
| 49 | <meta charset="utf-8"> | |
| 50 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 51 | <title>val — attack, defend, plant the spike</title> | |
| 52 | <link rel="icon" href="data:,"> | |
| 53 | <style> | |
| 54 | :root { color-scheme: dark; --atk: #e35d5b; --def: #4fb3d9; --ink: #e8e6e3; } | |
| 55 | * { box-sizing: border-box; } | |
| 56 | body { margin: 0; background: #0e0f12; color: var(--ink); | |
| 57 | font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } | |
| 58 | .wrap { max-width: 1100px; margin: 0 auto; padding: .6rem; } | |
| 59 | h1 { font-size: 1.1rem; margin: 0; letter-spacing: .12em; text-transform: uppercase; } | |
| 60 | header.top { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; } | |
| 61 | .env { font-size: .7rem; padding: .1rem .5rem; border-radius: 99px; background: #2a2d34; } | |
| 62 | #scoreline { display: flex; align-items: center; justify-content: center; gap: .6rem; | |
| 63 | font-variant-numeric: tabular-nums; margin: .4rem 0; font-size: 1.05rem; } | |
| 64 | .red { color: var(--atk); font-weight: 700; } .blue { color: var(--def); font-weight: 700; } | |
| 65 | #clock { font-size: 1.5rem; font-weight: 700; min-width: 3ch; text-align: center; } | |
| 66 | #phase { opacity: .75; font-size: .85rem; } | |
| 67 | #stage { position: relative; width: 100%; aspect-ratio: 30 / 19; background: #14161b; | |
| 68 | border: 1px solid #262a33; border-radius: .6rem; overflow: hidden; touch-action: none; } | |
| 69 | #view { width: 100%; height: 100%; display: block; } | |
| 70 | #flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; } | |
| 71 | #dead { position: absolute; inset: 0; display: none; place-items: center; background: #0008; | |
| 72 | font-size: 1.1rem; letter-spacing: .1em; text-transform: uppercase; } | |
| 73 | #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; | |
| 74 | font-variant-numeric: tabular-nums; } | |
| 75 | .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem; | |
| 76 | font-size: .85rem; } | |
| 77 | .kit { display: flex; gap: .4rem; flex-wrap: wrap; } | |
| 78 | .kit button, .buys button, .agents button { min-height: 44px; min-width: 44px; padding: .4rem .6rem; | |
| 79 | background: #1b1e25; color: var(--ink); border: 1px solid #333a47; border-radius: .45rem; | |
| 80 | font: inherit; font-size: .8rem; cursor: pointer; } | |
| 81 | .kit button[disabled], .buys button[disabled] { opacity: .35; cursor: not-allowed; } | |
| 82 | .kit b { display: block; font-size: .7rem; opacity: .7; } | |
| 83 | #buy { display: none; margin: .5rem 0; padding: .6rem; background: #151820; border: 1px solid #2a2f3a; | |
| 84 | border-radius: .6rem; } | |
| 85 | #buy.on { display: block; } | |
| 86 | .buys, .agents { display: flex; gap: .4rem; flex-wrap: wrap; margin: .35rem 0; } | |
| 87 | #log { margin: .5rem 0; font-size: .82rem; opacity: .8; min-height: 3.2em; } | |
| 88 | #log div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 89 | table { width: 100%; border-collapse: collapse; font-size: .85rem; } | |
| 90 | th, td { text-align: left; padding: .25rem .4rem; border-bottom: 1px solid #23262e; } | |
| 91 | td small { opacity: .5; } | |
| 92 | .boards { display: grid; gap: .8rem; grid-template-columns: 1fr; } | |
| 93 | @media (min-width: 720px) { .boards { grid-template-columns: 1fr 1fr; } } | |
| 94 | footer { margin: 1.2rem 0 3rem; font-size: .82rem; opacity: .72; } | |
| 95 | code { background: #1b1e25; padding: .1rem .35rem; border-radius: .3rem; } | |
| 96 | form.name { display: flex; gap: .4rem; margin: .5rem 0; } | |
| 97 | form.name input { flex: 1; min-height: 44px; padding: .4rem .6rem; background: #1b1e25; color: var(--ink); | |
| 98 | border: 1px solid #333a47; border-radius: .45rem; font: inherit; } | |
| 99 | form.name button { min-height: 44px; padding: 0 1rem; background: #3a4356; color: var(--ink); | |
| 100 | border: 0; border-radius: .45rem; font: inherit; cursor: pointer; } | |
| 101 | #touch { display: none; } | |
| 102 | @media (pointer: coarse) { #touch { display: block; } } | |
| 103 | </style> | |
| 104 | <div class="wrap"> | |
| 105 | <header class="top"> | |
| 106 | <h1>val</h1> | |
| 107 | <span class="env">${escape(opts.env)}${opts.commit ? " @ " + escape(opts.commit) : ""}</span> | |
| 108 | <span class="env" id="conn">connecting…</span> | |
| 109 | </header> | |
| 110 | ||
| 111 | <div id="scoreline"> | |
| 112 | <span class="red">red ${state.score.red}</span> | |
| 113 | <span id="clock">${Math.max(0, Math.ceil(state.clock))}</span> | |
| 114 | <span class="blue">${state.score.blue} blue</span> | |
| 115 | </div> | |
| 116 | <div id="phase">round ${state.round} · ${escape(phaseWord(state))} · ${escape(state.attackers)} attacking${state.result ? ` · ${escape(state.result)}` : ""}</div> | |
| 117 | ||
| 118 | <div id="stage"> | |
| 119 | <canvas id="view" width="900" height="570"></canvas> | |
| 120 | <div id="flash"></div> | |
| 121 | <div id="dead">waiting for the next round</div> | |
| 122 | </div> | |
| 123 | ||
| 124 | <div id="hud"> | |
| 125 | <span class="chip" id="hp">100 hp</span> | |
| 126 | <span class="chip" id="gun">Classic 12</span> | |
| 127 | <span class="chip" id="creds">800 cr</span> | |
| 128 | <span class="chip" id="ultmeter">ult 0/6</span> | |
| 129 | <span class="kit" id="kit"></span> | |
| 130 | <button id="buytoggle" class="chip" type="button">buy (B)</button> | |
| 131 | </div> | |
| 132 | ||
| 133 | <div id="buy"> | |
| 134 | <div><strong>Buy phase</strong> — credits are refused server-side if you cannot afford them.</div> | |
| 135 | <div class="agents" id="agents">${AGENTS.map( | |
| 136 | (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`, | |
| 137 | ).join("")}</div> | |
| 138 | <div class="buys" id="guns">${weaponIds() | |
| 139 | .map((id) => `<button type="button" data-buy="${id}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`) | |
| 140 | .join("")}</div> | |
| 141 | <div class="buys" id="gear"> | |
| 142 | <button type="button" data-buy="light">Light shield <b>400</b></button> | |
| 143 | <button type="button" data-buy="heavy">Heavy shield <b>1000</b></button> | |
| 144 | <button type="button" data-buy="c">Ability C</button> | |
| 145 | <button type="button" data-buy="q">Ability Q</button> | |
| 146 | </div> | |
| 147 | </div> | |
| 148 | ||
| 149 | <div id="log">${state.events | |
| 150 | .slice(0, 3) | |
| 151 | .map((e) => `<div>${escape(e.text)}</div>`) | |
| 152 | .join("")}</div> | |
| 153 | ||
| 154 | <form class="name" method="post" action="/join"> | |
| 155 | <input name="name" maxlength="16" placeholder="Your name" value="${escape(opts.name)}" aria-label="Your name"> | |
| 156 | <button>Take a seat</button> | |
| 157 | </form> | |
| 158 | ||
| 159 | <div class="boards"> | |
| 160 | <div><h2 class="env">attackers</h2><table><thead><tr><th>player</th><th>agent</th><th>k</th><th>d</th></tr></thead><tbody id="atkboard">${row("atk")}</tbody></table></div> | |
| 161 | <div><h2 class="env">defenders</h2><table><thead><tr><th>player</th><th>agent</th><th>k</th><th>d</th></tr></thead><tbody id="defboard">${row("def")}</tbody></table></div> | |
| 162 | </div> | |
| 163 | ||
| 164 | <footer> | |
| 165 | <p><strong>Controls.</strong> WASD move · mouse aims · click fires · R reloads · | |
| 166 | <strong>F</strong> plants and defuses (hold it) · C / Q / E / X cast · B opens the buy. | |
| 167 | On a phone: left thumb walks, right thumb aims and fires, the buttons under the map do the rest.</p> | |
| 168 | <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse, | |
| 169 | 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat | |
| 170 | nobody is in, and they use their kit.</p> | |
| 171 | <p>Terminal: ${opts.ssh ? `<code>${escape(opts.ssh)}</code>` : "off in this environment."}</p> | |
| 172 | ${opts.results.length ? `<p>Last matches: ${opts.results.map((r) => `${escape(r.summary)} <small>(${escape(r.ended_at)})</small>`).join(" · ")}</p>` : ""} | |
| 173 | </footer> | |
| 174 | </div> | |
| 175 | <script src="${escape(opts.script)}" defer></script> | |
| 176 | </html> | |
| 177 | `; | |
| 178 | } |
src/server.test.ts +133 −25
| @@ -6,46 +6,90 @@ import { join } from "node:path"; | ||
| 6 | 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | const { match, snapshot, advance } = await import("./arena.ts"); | |
| 9 | 10 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | 11 | |
| 11 | test("serves the page", async () => { | |
| 12 | test("serves a live match on the first paint, with no script needed", async () => { | |
| 12 | 13 | const response = await fetch(base + "/"); |
| 13 | 14 | expect(response.status).toBe(200); |
| 14 | expect(await response.text()).toContain("<h1>"); | |
| 15 | const body = await response.text(); | |
| 16 | expect(body).toContain("<h1>val</h1>"); | |
| 17 | // The score, the round and both team sheets are in the HTML itself. | |
| 18 | expect(body).toContain("red " + match().score.red); | |
| 19 | expect(body).toContain("round " + match().round); | |
| 20 | for (const player of match().players) expect(body).toContain(player.name); | |
| 15 | 21 | }); |
| 16 | 22 | |
| 17 | test("keeps a note", async () => { | |
| 23 | test("the page hands out a session cookie and the script is named by content", async () => { | |
| 24 | const response = await fetch(base + "/", { redirect: "manual" }); | |
| 25 | const cookie = response.headers.get("set-cookie") ?? ""; | |
| 26 | expect(cookie).toContain("val_session="); | |
| 27 | expect(cookie).toContain("HttpOnly"); | |
| 28 | const body = await response.text(); | |
| 29 | const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(body); | |
| 30 | expect(src).not.toBe(null); | |
| 31 | const script = await fetch(base + src![1]!); | |
| 32 | expect(script.status).toBe(200); | |
| 33 | expect(script.headers.get("cache-control")).toContain("immutable"); | |
| 34 | expect(await script.text()).toContain("WebSocket"); | |
| 35 | }); | |
| 36 | ||
| 37 | test("a name is taken from a same-origin form and sanitised", async () => { | |
| 18 | 38 | const form = new FormData(); |
| 19 | form.set("body", "hello from a test"); | |
| 20 | const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 39 | form.set("name", ' Wren<script>" '); | |
| 40 | const posted = await fetch(base + "/join", { method: "POST", body: form, redirect: "manual" }); | |
| 21 | 41 | expect(posted.status).toBe(303); |
| 22 | expect(await (await fetch(base + "/")).text()).toContain("hello from a test"); | |
| 42 | const cookie = posted.headers.getSetCookie().join(" "); | |
| 43 | expect(cookie).toContain("val_name="); | |
| 44 | // The angle brackets and the quote never reach the cookie or the match. | |
| 45 | expect(cookie).not.toContain("<"); | |
| 46 | expect(cookie).not.toContain("%3C"); | |
| 47 | expect(match().players.every((p) => !p.name.includes("<"))).toBe(true); | |
| 48 | // The seat that name took is a real player in the running match. | |
| 49 | expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true); | |
| 50 | }); | |
| 51 | ||
| 52 | test("refuses a cross-site post", async () => { | |
| 53 | const form = new FormData(); | |
| 54 | form.set("name", "Evil"); | |
| 55 | const posted = await fetch(base + "/join", { | |
| 56 | method: "POST", | |
| 57 | body: form, | |
| 58 | redirect: "manual", | |
| 59 | headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" }, | |
| 60 | }); | |
| 61 | expect(posted.status).toBe(403); | |
| 62 | expect(match().players.some((p) => p.name === "Evil")).toBe(false); | |
| 23 | 63 | }); |
| 24 | 64 | |
| 25 | 65 | test("stands on the security floor", async () => { |
| 26 | 66 | const response = await fetch(base + "/"); |
| 27 | 67 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 28 | 68 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 69 | const missing = await fetch(base + "/nope"); | |
| 70 | expect(missing.status).toBe(404); | |
| 71 | expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 29 | 72 | }); |
| 30 | 73 | |
| 31 | 74 | test("a bug answers with the floor, not the runtime's page", async () => { |
| 32 | // A percent-escape that is not one makes the search params throw. | |
| 33 | const response = await fetch(base + "/notes", { | |
| 75 | // A percent-escape that is not one: whether the runtime decodes it or | |
| 76 | // throws, the answer carries the floor and never a stack trace. | |
| 77 | const response = await fetch(base + "/join", { | |
| 34 | 78 | method: "POST", |
| 35 | 79 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 36 | body: "body=%E0%A4%A", | |
| 80 | body: "name=%E0%A4%A", | |
| 81 | redirect: "manual", | |
| 37 | 82 | }); |
| 38 | expect(response.status).toBe(200); | |
| 83 | expect([303, 500]).toContain(response.status); | |
| 84 | expect(await response.text()).not.toContain("src/server.ts"); | |
| 39 | 85 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 40 | 86 | }); |
| 41 | 87 | |
| 42 | 88 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 43 | // The cap counts bytes as they arrive and never reads Content-Length, so | |
| 44 | // a chunked body with no length is refused the same way this one is. | |
| 45 | const response = await fetch(base + "/notes", { | |
| 89 | const response = await fetch(base + "/join", { | |
| 46 | 90 | method: "POST", |
| 47 | 91 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 48 | body: "body=" + "x".repeat(1024 * 1024), | |
| 92 | body: "name=" + "x".repeat(1024 * 1024), | |
| 49 | 93 | }); |
| 50 | 94 | expect(response.status).toBe(413); |
| 51 | 95 | }); |
| @@ -56,15 +100,79 @@ test("HEAD sees the app up", async () => { | ||
| 56 | 100 | expect(await response.text()).toBe(""); |
| 57 | 101 | }); |
| 58 | 102 | |
| 59 | test("refuses a cross-site post", async () => { | |
| 60 | const form = new FormData(); | |
| 61 | form.set("body", "from another site"); | |
| 62 | const posted = await fetch(base + "/notes", { | |
| 63 | method: "POST", | |
| 64 | body: form, | |
| 65 | redirect: "manual", | |
| 66 | headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" }, | |
| 67 | }); | |
| 68 | expect(posted.status).toBe(403); | |
| 69 | expect(await (await fetch(base + "/")).text()).not.toContain("from another site"); | |
| 103 | test("the map is served as data, once, for the client to draw", async () => { | |
| 104 | const response = await fetch(base + "/map.json"); | |
| 105 | const body = (await response.json()) as { rows: string[]; tile: number }; | |
| 106 | expect(body.tile).toBe(80); | |
| 107 | expect(body.rows.length).toBeGreaterThan(10); | |
| 108 | expect(body.rows.some((r) => r.includes("A"))).toBe(true); | |
| 109 | expect(body.rows.some((r) => r.includes("B"))).toBe(true); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a websocket is refused to another site", async () => { | |
| 113 | const response = await server.fetch( | |
| 114 | new Request(base + "/ws", { headers: { "sec-fetch-site": "cross-site", origin: "https://evil.example" } }), | |
| 115 | ); | |
| 116 | expect(response.status).toBe(403); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a snapshot only carries the enemies that viewer can see", () => { | |
| 120 | const state = match(); | |
| 121 | const viewer = state.players.find((p) => p.side === "atk")!; | |
| 122 | const enemy = state.players.find((p) => p.side === "def")!; | |
| 123 | viewer.x = 1200; | |
| 124 | viewer.y = 1320; | |
| 125 | viewer.blind = 0; | |
| 126 | enemy.x = 1200; | |
| 127 | enemy.y = 1240; | |
| 128 | enemy.revealed = 0; | |
| 129 | const near = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[] }; | |
| 130 | expect(near.players.some((p) => p.id === enemy.id)).toBe(true); | |
| 131 | // Behind the wall on the far side of the map, the same enemy is gone. | |
| 132 | enemy.x = 200; | |
| 133 | enemy.y = 200; | |
| 134 | const far = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[]; board: unknown[] }; | |
| 135 | expect(far.players.some((p) => p.id === enemy.id)).toBe(false); | |
| 136 | // The scoreboard still lists everyone; it carries no position. | |
| 137 | expect(far.board.length).toBe(10); | |
| 138 | expect(JSON.stringify(far.board)).not.toContain('"x"'); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => { | |
| 142 | const { command } = await import("./arena.ts"); | |
| 143 | const { siteCentre } = await import("./map.ts"); | |
| 144 | const state = match(); | |
| 145 | state.phase = "live"; | |
| 146 | state.clock = 100; | |
| 147 | const spot = siteCentre("B"); | |
| 148 | const carrier = state.players.find((p) => p.side === "atk")!; | |
| 149 | // Nobody is a bot for these five seconds: no fight can end the round | |
| 150 | // underneath the plant and make this depend on who shot whom. | |
| 151 | for (const player of state.players) { | |
| 152 | player.bot = false; | |
| 153 | player.alive = true; | |
| 154 | player.hp = 100; | |
| 155 | player.input.fire = false; | |
| 156 | } | |
| 157 | carrier.x = spot.x; | |
| 158 | carrier.y = spot.y; | |
| 159 | carrier.spike = true; | |
| 160 | state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; | |
| 161 | // Garbage is ignored, and a seat nobody holds cannot be driven. | |
| 162 | command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" }); | |
| 163 | command("nobody", { t: "i", u: true }); | |
| 164 | expect(carrier.input.use).toBe(false); | |
| 165 | command(carrier.id, { t: "i", u: true, mx: 0, my: 0 }); | |
| 166 | advance(5); | |
| 167 | expect(state.spike.planted).toBe(true); | |
| 168 | expect(state.spike.site).toBe("B"); | |
| 169 | for (const player of state.players) player.bot = true; | |
| 170 | }); | |
| 171 | ||
| 172 | test("the match keeps running and is never parked", () => { | |
| 173 | const state = match(); | |
| 174 | const before = state.now; | |
| 175 | advance(30); | |
| 176 | expect(state.now).toBeGreaterThan(before); | |
| 177 | expect(["buy", "live", "end", "over"]).toContain(state.phase); | |
| 70 | 178 | }); |
src/server.ts +145 −71
| @@ -1,29 +1,23 @@ | ||
| 1 | 1 | /** |
| 2 | * val — a small app with real data. | |
| 2 | * val — the door. Routing, sessions, sockets and the security floor; the | |
| 3 | * game itself is in src/match.ts and the live one in src/arena.ts. | |
| 3 | 4 | * |
| 4 | * Notes live in a SQLite file under FORGE_APP_DATA. Production and each | |
| 5 | * preview get their own directory, so a preview starts with production's | |
| 6 | * notes and never writes back to them. | |
| 5 | * A seat belongs to a session cookie, so the same browser gets the same | |
| 6 | * player back across a reload, and a socket is only ever given a seat the | |
| 7 | * server chose. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { mkdirSync } from "node:fs"; | |
| 10 | import { createHash, randomUUID } from "node:crypto"; | |
| 11 | 11 | import { join } from "node:path"; |
| 12 | ||
| 13 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 14 | mkdirSync(dataDir, { recursive: true }); | |
| 15 | const db = new Database(join(dataDir, "app.db")); | |
| 16 | db.exec("PRAGMA journal_mode = WAL"); | |
| 17 | db.exec( | |
| 18 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 19 | ); | |
| 12 | import { attach, command, findPlayer, match, release, snapshot, startArena, takeSeat, type Command } from "./arena.ts"; | |
| 13 | import { recentResults } from "./db.ts"; | |
| 14 | import { gridRows, TILE } from "./map.ts"; | |
| 15 | import { gamePage } from "./pages/game.ts"; | |
| 20 | 16 | |
| 21 | 17 | const env = process.env.FORGE_APP_ENV ?? "local"; |
| 22 | 18 | const pull = process.env.FORGE_APP_PULL; |
| 23 | 19 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); |
| 24 | ||
| 25 | const escape = (s: string): string => | |
| 26 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 20 | const where = env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 27 | 21 | |
| 28 | 22 | // The security floor every response stands on. Scripts and styles come from |
| 29 | 23 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| @@ -42,17 +36,20 @@ const SECURITY = { | ||
| 42 | 36 | ? { "strict-transport-security": "max-age=31536000" } |
| 43 | 37 | : {}), |
| 44 | 38 | }; |
| 45 | const html = (body: string, status = 200): Response => | |
| 46 | new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } }); | |
| 39 | const html = (body: string, status = 200, extra: Record<string, string> = {}): Response => | |
| 40 | new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY, ...extra } }); | |
| 47 | 41 | |
| 48 | 42 | // A browser on another site can make a person's browser POST here; it cannot |
| 49 | 43 | // make that request look same-origin. Browsers say where a request came from |
| 50 | 44 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. |
| 45 | // The address the forge published is the one that counts, but a browser | |
| 46 | // reaching the app by another name (localhost, an IP, a tunnel) sends that | |
| 47 | // host in both the URL and the Origin — a cross-site request never can. | |
| 51 | 48 | const sameOrigin = (request: Request): boolean => { |
| 52 | 49 | const site = request.headers.get("sec-fetch-site"); |
| 53 | 50 | if (site !== null) return site === "same-origin" || site === "none"; |
| 54 | 51 | const origin = request.headers.get("origin"); |
| 55 | return origin === null || origin === publicOrigin; | |
| 52 | return origin === null || origin === publicOrigin || origin === new URL(request.url).origin; | |
| 56 | 53 | }; |
| 57 | 54 | |
| 58 | 55 | // A request body is read up to a cap and no further, counting the bytes as |
| @@ -104,68 +101,116 @@ async function readForm(request: Request): Promise<FormData | undefined> { | ||
| 104 | 101 | }).formData(); |
| 105 | 102 | } |
| 106 | 103 | |
| 107 | function page(): string { | |
| 108 | const notes = db | |
| 109 | .query<{ id: number; body: string; created_at: string }, []>( | |
| 110 | "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100", | |
| 111 | ) | |
| 112 | .all(); | |
| 113 | const where = | |
| 114 | env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 115 | return `<!doctype html> | |
| 116 | <meta charset="utf-8"> | |
| 117 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 118 | <title>val</title> | |
| 119 | <style> | |
| 120 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 121 | body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; } | |
| 122 | .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem; | |
| 123 | background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; } | |
| 124 | form { display: flex; gap: .5rem; margin: 1.5rem 0; } | |
| 125 | input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; } | |
| 126 | button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; } | |
| 127 | li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; } | |
| 128 | small { opacity: .6; } | |
| 129 | </style> | |
| 130 | <h1>val</h1> | |
| 131 | <p>Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.</p> | |
| 132 | <p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p> | |
| 133 | <form method="post" action="/notes"> | |
| 134 | <input name="body" placeholder="Write a note" required maxlength="500" autofocus> | |
| 135 | <button>Add</button> | |
| 136 | </form> | |
| 137 | <ul>${notes | |
| 138 | .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`) | |
| 139 | .join("")}</ul> | |
| 140 | <p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p> | |
| 141 | `; | |
| 142 | } | |
| 104 | // ------------------------------------------------------------- the client | |
| 143 | 105 | |
| 144 | export const server = Bun.serve({ | |
| 106 | // The client script is named by its content: a change is a new URL, so a | |
| 107 | // year-long cache is never stale. | |
| 108 | const scriptPath = join(import.meta.dir, "..", "static", "val.js"); | |
| 109 | const scriptBody = await Bun.file(scriptPath).text(); | |
| 110 | const scriptHash = createHash("sha256").update(scriptBody).digest("hex").slice(0, 10); | |
| 111 | const scriptUrl = `/static/val.${scriptHash}.js`; | |
| 112 | ||
| 113 | // ------------------------------------------------------------- sessions | |
| 114 | ||
| 115 | const cookies = (request: Request): Record<string, string> => { | |
| 116 | const out: Record<string, string> = {}; | |
| 117 | for (const part of (request.headers.get("cookie") ?? "").split(";")) { | |
| 118 | const at = part.indexOf("="); | |
| 119 | if (at < 1) continue; | |
| 120 | out[part.slice(0, at).trim()] = decodeURIComponent(part.slice(at + 1).trim()); | |
| 121 | } | |
| 122 | return out; | |
| 123 | }; | |
| 124 | ||
| 125 | const SESSION = "val_session"; | |
| 126 | const NAME = "val_name"; | |
| 127 | ||
| 128 | /** A name is one line of plain text, sixteen characters, nothing clever. */ | |
| 129 | const cleanName = (raw: unknown): string => { | |
| 130 | const text = String(raw ?? "") | |
| 131 | .replace(/[\u0000-\u001f\u007f<>"'\\]/g, "") | |
| 132 | .trim() | |
| 133 | .slice(0, 16); | |
| 134 | return text.length >= 1 ? text : ""; | |
| 135 | }; | |
| 136 | ||
| 137 | const setCookie = (name: string, value: string, httpOnly: boolean): string => | |
| 138 | `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`; | |
| 139 | ||
| 140 | type SocketData = { session: string; playerId: string }; | |
| 141 | ||
| 142 | // ---------------------------------------------------------------- routing | |
| 143 | ||
| 144 | export const server = Bun.serve<SocketData, Record<string, never>>({ | |
| 145 | 145 | port: Number(process.env.PORT ?? 8080), |
| 146 | 146 | hostname: process.env.HOST ?? "127.0.0.1", |
| 147 | async fetch(request) { | |
| 147 | async fetch(request, self) { | |
| 148 | 148 | const url = new URL(request.url); |
| 149 | const jar = cookies(request); | |
| 150 | const session = jar[SESSION] ?? randomUUID(); | |
| 151 | const fresh = jar[SESSION] === undefined; | |
| 152 | ||
| 149 | 153 | if (request.method === "POST" && !sameOrigin(request)) |
| 150 | return new Response("forbidden", { status: 403 }); | |
| 151 | if (request.method === "POST" && url.pathname === "/notes") { | |
| 154 | return new Response("forbidden", { status: 403, headers: SECURITY }); | |
| 155 | ||
| 156 | if (request.method === "POST" && url.pathname === "/join") { | |
| 152 | 157 | const form = await readForm(request); |
| 153 | if (form === undefined) | |
| 154 | return new Response("too large", { status: 413, headers: SECURITY }); | |
| 155 | const body = String(form.get("body") ?? "").trim().slice(0, 500); | |
| 156 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body); | |
| 157 | return Response.redirect("/", 303); | |
| 158 | if (form === undefined) return new Response("too large", { status: 413, headers: SECURITY }); | |
| 159 | const name = cleanName(form.get("name")); | |
| 160 | const headers = new Headers({ location: "/", ...SECURITY }); | |
| 161 | headers.append("set-cookie", setCookie(SESSION, session, true)); | |
| 162 | if (name) { | |
| 163 | headers.append("set-cookie", setCookie(NAME, name, false)); | |
| 164 | takeSeat(session, name); | |
| 165 | } | |
| 166 | return new Response(null, { status: 303, headers }); | |
| 158 | 167 | } |
| 159 | // HEAD is GET without the body: a health check sees the app up. | |
| 168 | ||
| 169 | // The socket: one seat per session, taken from a bot where it stands. | |
| 170 | if (url.pathname === "/ws") { | |
| 171 | if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY }); | |
| 172 | const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`; | |
| 173 | const player = takeSeat(session, name); | |
| 174 | if (!player) return new Response("no seats", { status: 503, headers: SECURITY }); | |
| 175 | const upgraded = self.upgrade(request, { data: { session, playerId: player.id } }); | |
| 176 | return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY }); | |
| 177 | } | |
| 178 | ||
| 160 | 179 | if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { |
| 161 | const full = html(page()); | |
| 180 | const body = gamePage({ | |
| 181 | state: match(), | |
| 182 | script: scriptUrl, | |
| 183 | name: cleanName(jar[NAME]), | |
| 184 | env: where, | |
| 185 | commit, | |
| 186 | ssh: process.env.FORGE_APP_SSH, | |
| 187 | results: recentResults(3), | |
| 188 | }); | |
| 189 | const response = html(body, 200, fresh ? { "set-cookie": setCookie(SESSION, session, true) } : {}); | |
| 162 | 190 | return request.method === "HEAD" |
| 163 | ? new Response(null, { status: full.status, headers: full.headers }) | |
| 164 | : full; | |
| 191 | ? new Response(null, { status: response.status, headers: response.headers }) | |
| 192 | : response; | |
| 165 | 193 | } |
| 194 | ||
| 195 | // The map is the same for everybody and changes only when the code does. | |
| 196 | if (url.pathname === "/map.json" && (request.method === "GET" || request.method === "HEAD")) | |
| 197 | return Response.json( | |
| 198 | { rows: gridRows(), tile: TILE }, | |
| 199 | { headers: { "cache-control": "public, max-age=300", ...SECURITY } }, | |
| 200 | ); | |
| 201 | ||
| 202 | if (url.pathname === scriptUrl) | |
| 203 | return new Response(scriptBody, { | |
| 204 | headers: { | |
| 205 | "content-type": "text/javascript; charset=utf-8", | |
| 206 | "cache-control": "public, max-age=31536000, immutable", | |
| 207 | ...SECURITY, | |
| 208 | }, | |
| 209 | }); | |
| 210 | ||
| 166 | 211 | // Files under ./static are served as they are, cached for a year: name |
| 167 | // them by content (app.3f2a9c.js) and a change is a new name, never a | |
| 168 | // stale cache. The path is confined to the directory. | |
| 212 | // them by content and a change is a new name, never a stale cache. The | |
| 213 | // path is confined to the directory. | |
| 169 | 214 | if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) { |
| 170 | 215 | const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1))); |
| 171 | 216 | if (await file.exists()) |
| @@ -175,6 +220,32 @@ export const server = Bun.serve({ | ||
| 175 | 220 | } |
| 176 | 221 | return new Response("not found", { status: 404, headers: SECURITY }); |
| 177 | 222 | }, |
| 223 | ||
| 224 | websocket: { | |
| 225 | maxPayloadLength: 4 * 1024, | |
| 226 | open(socket) { | |
| 227 | const { session, playerId } = socket.data; | |
| 228 | attach(session, playerId, (data) => socket.send(data)); | |
| 229 | socket.send(snapshot(playerId)); | |
| 230 | }, | |
| 231 | message(socket, raw) { | |
| 232 | // Anything a client sends is a claim: it is parsed, bounded and given | |
| 233 | // to the arena, which decides what it is allowed to mean. | |
| 234 | if (typeof raw !== "string" || raw.length > 4096) return; | |
| 235 | let parsed: unknown; | |
| 236 | try { | |
| 237 | parsed = JSON.parse(raw); | |
| 238 | } catch { | |
| 239 | return; | |
| 240 | } | |
| 241 | if (typeof parsed !== "object" || parsed === null) return; | |
| 242 | command(socket.data.playerId, parsed as Command); | |
| 243 | }, | |
| 244 | close(socket) { | |
| 245 | release(socket.data.session); | |
| 246 | }, | |
| 247 | }, | |
| 248 | ||
| 178 | 249 | // A bug answers with the floor and nothing else: never the runtime's own |
| 179 | 250 | // error page, which carries source and paths, and never without the |
| 180 | 251 | // headers. The detail goes to the log, where it belongs. |
| @@ -184,4 +255,7 @@ export const server = Bun.serve({ | ||
| 184 | 255 | }, |
| 185 | 256 | }); |
| 186 | 257 | |
| 258 | startArena(); | |
| 259 | ||
| 187 | 260 | console.log(`val listening on http://${server.hostname}:${server.port} (${env})`); |
| 261 | export { findPlayer }; |
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/tty.ts +154 −54
| @@ -1,74 +1,174 @@ | ||
| 1 | 1 | /** |
| 2 | * val, in a terminal. The forge runs this on a pty when someone | |
| 3 | * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the | |
| 4 | * web page uses. j/k move, a adds a note, d deletes the one under the | |
| 5 | * cursor, q leaves. Plain ANSI, no dependencies. | |
| 2 | * val, in a terminal. The forge runs this on a pty when somebody ssh-es to | |
| 3 | * the app; it takes a seat over the same socket the browser uses, so what a | |
| 4 | * person can do on the page they can do here: walk, shoot, cast, plant, | |
| 5 | * defuse and buy. | |
| 6 | * | |
| 7 | * The map is the same grid, one character a tile. Plain ANSI, no dependencies. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { mkdirSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 10 | const base = | |
| 11 | process.env.VAL_URL ?? | |
| 12 | (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ?? | |
| 13 | process.env.FORGE_APP_URL ?? | |
| 14 | "http://127.0.0.1:8080"; | |
| 11 | 15 | |
| 12 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 13 | mkdirSync(dataDir, { recursive: true }); | |
| 14 | const db = new Database(join(dataDir, "app.db")); | |
| 15 | db.exec("PRAGMA journal_mode = WAL"); | |
| 16 | db.exec( | |
| 17 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 18 | ); | |
| 19 | const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local"); | |
| 20 | ||
| 21 | type Note = { id: number; body: string; created_at: string }; | |
| 22 | const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all(); | |
| 23 | ||
| 24 | let cursor = 0; | |
| 25 | let typing: string | undefined; | |
| 26 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 27 | 16 | const esc = "\x1b["; |
| 17 | const out = (s: string): void => void process.stdout.write(s); | |
| 18 | ||
| 19 | type Snapshot = { | |
| 20 | phase: string; | |
| 21 | clock: number; | |
| 22 | round: number; | |
| 23 | score: { red: number; blue: number }; | |
| 24 | attackers: string; | |
| 25 | result: string | null; | |
| 26 | me: Record<string, any>; | |
| 27 | players: Record<string, any>[]; | |
| 28 | zones: { k: string; x: number; y: number; r: number }[]; | |
| 29 | spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null }; | |
| 30 | board: Record<string, any>[]; | |
| 31 | events: string[]; | |
| 32 | }; | |
| 33 | ||
| 34 | let map: { rows: string[]; tile: number } | null = null; | |
| 35 | let snap: Snapshot | null = null; | |
| 36 | let socket: WebSocket | null = null; | |
| 37 | let status = "connecting"; | |
| 38 | let aim = -Math.PI / 2; | |
| 39 | let move = { mx: 0, my: 0, until: 0 }; | |
| 40 | let firing = 0; | |
| 41 | let using = 0; | |
| 42 | let buying = false; | |
| 43 | ||
| 44 | const send = (message: unknown): void => { | |
| 45 | if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); | |
| 46 | }; | |
| 47 | ||
| 48 | async function connect(): Promise<void> { | |
| 49 | try { | |
| 50 | const page = await fetch(base + "/"); | |
| 51 | const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 52 | map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number }; | |
| 53 | const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", { | |
| 54 | headers: { cookie, origin: new URL(base).origin }, | |
| 55 | } as unknown as string[]); | |
| 56 | socket = ws; | |
| 57 | ws.onopen = () => (status = "live"); | |
| 58 | ws.onclose = () => { | |
| 59 | status = "reconnecting"; | |
| 60 | socket = null; | |
| 61 | setTimeout(connect, 2000); | |
| 62 | }; | |
| 63 | ws.onmessage = (event) => { | |
| 64 | snap = JSON.parse(String(event.data)) as Snapshot; | |
| 65 | }; | |
| 66 | } catch (error) { | |
| 67 | status = `cannot reach ${base}`; | |
| 68 | setTimeout(connect, 3000); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; | |
| 28 | 73 | |
| 29 | 74 | function draw(): void { |
| 30 | const rows = notes(); | |
| 75 | if (!map) { | |
| 76 | out(`${esc}2J${esc}Hval — ${status}\r\n`); | |
| 77 | return; | |
| 78 | } | |
| 79 | const rows = map.rows.map((row) => row.split("").map((c) => (c === "#" ? colour("▓", 90) : c === "A" || c === "B" ? colour(c, 33) : c === "a" || c === "d" ? colour("·", 90) : " "))); | |
| 80 | const put = (x: number, y: number, ch: string): void => { | |
| 81 | const col = Math.floor(x / map!.tile); | |
| 82 | const row = Math.floor(y / map!.tile); | |
| 83 | if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch; | |
| 84 | }; | |
| 85 | ||
| 31 | 86 | const lines: string[] = []; |
| 32 | lines.push(`${esc}1mval${esc}0m ${esc}2m${where}${esc}0m`); | |
| 87 | if (snap) { | |
| 88 | for (const zone of snap.zones) put(zone.x, zone.y, colour(zone.k === "smoke" ? "▒" : zone.k === "molly" ? "≈" : zone.k === "wall" ? "█" : "*", zone.k === "molly" ? 31 : 37)); | |
| 89 | if (snap.spike.x !== null && snap.spike.y !== null) | |
| 90 | put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33)); | |
| 91 | for (const player of snap.players) { | |
| 92 | if (!player.alive) continue; | |
| 93 | const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31); | |
| 94 | put(player.x, player.y, mark); | |
| 95 | } | |
| 96 | const me = snap.me; | |
| 97 | const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase; | |
| 98 | lines.push( | |
| 99 | `${colour("val", 1)} ${colour("red " + snap.score.red, 31)} — ${colour(snap.score.blue + " blue", 36)} round ${snap.round} ${phase} ${Math.ceil(snap.clock)}s ${snap.attackers} attacking [${status}]`, | |
| 100 | ); | |
| 101 | lines.push( | |
| 102 | `${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`, | |
| 103 | ); | |
| 104 | } else lines.push(`val — ${status}`); | |
| 33 | 105 | lines.push(""); |
| 34 | if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`); | |
| 35 | rows.forEach((n, i) => { | |
| 36 | const mark = i === cursor ? "▸" : " "; | |
| 37 | const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body; | |
| 38 | lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`); | |
| 39 | }); | |
| 106 | for (const row of rows) lines.push(" " + row.join("")); | |
| 40 | 107 | lines.push(""); |
| 41 | lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`); | |
| 42 | out(`${esc}2J${esc}H` + lines.join("\r\n")); | |
| 108 | if (snap) { | |
| 109 | for (const side of ["atk", "def"] as const) { | |
| 110 | const team = snap.board | |
| 111 | .filter((p) => p.side === side) | |
| 112 | .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`) | |
| 113 | .join(" "); | |
| 114 | lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team); | |
| 115 | } | |
| 116 | lines.push(""); | |
| 117 | for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); | |
| 118 | } | |
| 119 | lines.push(""); | |
| 120 | lines.push( | |
| 121 | buying | |
| 122 | ? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33) | |
| 123 | : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90), | |
| 124 | ); | |
| 125 | out(`${esc}H${esc}J` + lines.join("\r\n")); | |
| 43 | 126 | } |
| 44 | 127 | |
| 128 | const BUYS: Record<string, string> = { | |
| 129 | "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator", | |
| 130 | "8": "light", "9": "heavy", | |
| 131 | }; | |
| 132 | ||
| 45 | 133 | process.stdin.setRawMode?.(true); |
| 46 | 134 | process.stdin.resume(); |
| 47 | 135 | process.stdin.setEncoding("utf8"); |
| 48 | draw(); | |
| 49 | 136 | process.stdin.on("data", (key: string) => { |
| 50 | if (typing !== undefined) { | |
| 51 | if (key === "\r") { | |
| 52 | const body = typing.trim(); | |
| 53 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500)); | |
| 54 | typing = undefined; | |
| 55 | cursor = 0; | |
| 56 | } else if (key === "\x1b") typing = undefined; | |
| 57 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 58 | else if (key >= " ") typing += key; | |
| 59 | draw(); | |
| 137 | const now = Date.now(); | |
| 138 | if (key === "\x03" || (key === "q" && !buying)) { | |
| 139 | out(`${esc}2J${esc}H`); | |
| 140 | process.exit(0); | |
| 141 | } | |
| 142 | if (buying) { | |
| 143 | if (BUYS[key]) send({ t: "buy", what: BUYS[key] }); | |
| 144 | else if (key === "c" || key === "q") send({ t: "buy", what: key }); | |
| 145 | else if (key === "b" || key === "\x1b") buying = false; | |
| 60 | 146 | return; |
| 61 | 147 | } |
| 62 | if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } | |
| 63 | const count = notes().length; | |
| 64 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); | |
| 65 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 66 | if (key === "a") typing = ""; | |
| 67 | if (key === "d") { | |
| 68 | const n = notes()[cursor]; | |
| 69 | if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id); | |
| 70 | cursor = Math.min(cursor, Math.max(0, notes().length - 1)); | |
| 71 | } | |
| 72 | draw(); | |
| 148 | if (key === "b") return void (buying = true); | |
| 149 | if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; | |
| 150 | if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; | |
| 151 | if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; | |
| 152 | if (key === "d") move = { mx: 1, my: move.my, until: now + 400 }; | |
| 153 | if (key === "\x1b[D") aim -= Math.PI / 12; | |
| 154 | if (key === "\x1b[C") aim += Math.PI / 12; | |
| 155 | if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 }; | |
| 156 | if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 }; | |
| 157 | if (key === " ") firing = now + 220; | |
| 158 | if (key === "f") using = now + 700; | |
| 159 | if (key === "r") send({ t: "i", r: true }); | |
| 160 | if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key }); | |
| 73 | 161 | }); |
| 162 | ||
| 163 | // Twenty times a second, the same intent the browser sends. | |
| 164 | setInterval(() => { | |
| 165 | const now = Date.now(); | |
| 166 | if (now > move.until) move = { mx: 0, my: 0, until: 0 }; | |
| 167 | send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false }); | |
| 168 | }, 50); | |
| 169 | setInterval(draw, 120); | |
| 74 | 170 | process.stdout.on("resize", draw); |
| 171 | ||
| 172 | out(`${esc}2J`); | |
| 173 | draw(); | |
| 174 | void connect(); |
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 } }; |
static/val.js added +429
| @@ -0,0 +1,429 @@ | ||
| 1 | /** | |
| 2 | * val, in a browser. It draws what the server sends and sends what the | |
| 3 | * person does — nothing else. There is no position here the server did not | |
| 4 | * hand over, and no enemy in a snapshot the server decided you cannot see, | |
| 5 | * so a patched client learns nothing a plain one does not. | |
| 6 | */ | |
| 7 | "use strict"; | |
| 8 | ||
| 9 | const $ = (id) => document.getElementById(id); | |
| 10 | const view = $("view"); | |
| 11 | const ctx = view.getContext("2d"); | |
| 12 | ||
| 13 | const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" }; | |
| 14 | const AGENT_COLOUR = { ember: "#f08a4b", veil: "#8f7fe8", sable: "#4fd6a0", mender: "#e0c060", rook: "#d96a8f" }; | |
| 15 | ||
| 16 | let map = null; | |
| 17 | let snap = null; | |
| 18 | let socket = null; | |
| 19 | const shown = new Map(); // smoothed positions, so 15 frames a second look like 60 | |
| 20 | const keys = new Set(); | |
| 21 | let pointer = null; // aim target in canvas pixels | |
| 22 | let firing = false; | |
| 23 | let stick = null; // { id, ox, oy, mx, my } | |
| 24 | let aimTouch = null; | |
| 25 | ||
| 26 | // ------------------------------------------------------------------ socket | |
| 27 | ||
| 28 | function connect() { | |
| 29 | const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws"; | |
| 30 | socket = new WebSocket(url); | |
| 31 | socket.onopen = () => ($("conn").textContent = "live"); | |
| 32 | socket.onclose = () => { | |
| 33 | $("conn").textContent = "reconnecting…"; | |
| 34 | setTimeout(connect, 1500); | |
| 35 | }; | |
| 36 | socket.onmessage = (event) => { | |
| 37 | const data = JSON.parse(event.data); | |
| 38 | if (data.t === "s") { | |
| 39 | snap = data; | |
| 40 | paintHud(data); | |
| 41 | } | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | const send = (message) => { | |
| 46 | if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); | |
| 47 | }; | |
| 48 | ||
| 49 | // ------------------------------------------------------------------- input | |
| 50 | ||
| 51 | addEventListener("keydown", (event) => { | |
| 52 | if (event.target instanceof HTMLInputElement) return; | |
| 53 | if (event.code === "KeyB") { | |
| 54 | toggleBuy(); | |
| 55 | event.preventDefault(); | |
| 56 | return; | |
| 57 | } | |
| 58 | if (KEY_SLOT[event.code]) { | |
| 59 | send({ t: "i", c: KEY_SLOT[event.code] }); | |
| 60 | event.preventDefault(); | |
| 61 | return; | |
| 62 | } | |
| 63 | keys.add(event.code); | |
| 64 | if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault(); | |
| 65 | }); | |
| 66 | addEventListener("keyup", (event) => keys.delete(event.code)); | |
| 67 | addEventListener("blur", () => keys.clear()); | |
| 68 | ||
| 69 | view.addEventListener("pointermove", (event) => { | |
| 70 | if (event.pointerType === "touch") return; | |
| 71 | const box = view.getBoundingClientRect(); | |
| 72 | pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 73 | }); | |
| 74 | view.addEventListener("pointerdown", (event) => { | |
| 75 | view.setPointerCapture(event.pointerId); | |
| 76 | const box = view.getBoundingClientRect(); | |
| 77 | const x = event.clientX - box.left; | |
| 78 | if (event.pointerType === "touch") { | |
| 79 | if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 }; | |
| 80 | else { | |
| 81 | aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 82 | firing = true; | |
| 83 | } | |
| 84 | event.preventDefault(); | |
| 85 | return; | |
| 86 | } | |
| 87 | pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height }; | |
| 88 | firing = true; | |
| 89 | }); | |
| 90 | view.addEventListener("pointermove", (event) => { | |
| 91 | if (stick && event.pointerId === stick.id) { | |
| 92 | const dx = event.clientX - stick.ox; | |
| 93 | const dy = event.clientY - stick.oy; | |
| 94 | const len = Math.hypot(dx, dy) || 1; | |
| 95 | const reach = Math.min(len, 48) / 48; | |
| 96 | stick.mx = (dx / len) * reach; | |
| 97 | stick.my = (dy / len) * reach; | |
| 98 | } | |
| 99 | if (aimTouch && event.pointerId === aimTouch.id) { | |
| 100 | const box = view.getBoundingClientRect(); | |
| 101 | aimTouch.x = ((event.clientX - box.left) / box.width) * view.width; | |
| 102 | aimTouch.y = ((event.clientY - box.top) / box.height) * view.height; | |
| 103 | } | |
| 104 | }); | |
| 105 | const endPointer = (event) => { | |
| 106 | if (stick && event.pointerId === stick.id) stick = null; | |
| 107 | else if (aimTouch && event.pointerId === aimTouch.id) { | |
| 108 | aimTouch = null; | |
| 109 | firing = false; | |
| 110 | } else firing = false; | |
| 111 | }; | |
| 112 | view.addEventListener("pointerup", endPointer); | |
| 113 | view.addEventListener("pointercancel", endPointer); | |
| 114 | view.addEventListener("contextmenu", (event) => event.preventDefault()); | |
| 115 | ||
| 116 | // The buy opens itself every buy phase; closing it is remembered until the | |
| 117 | // next one, so nobody has to fight the panel to watch the round start. | |
| 118 | function toggleBuy() { | |
| 119 | const open = $("buy").classList.toggle("on"); | |
| 120 | $("buy").dataset.closed = open ? "" : "1"; | |
| 121 | } | |
| 122 | $("buytoggle").addEventListener("click", toggleBuy); | |
| 123 | for (const button of document.querySelectorAll("[data-buy]")) | |
| 124 | button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy })); | |
| 125 | for (const button of document.querySelectorAll("[data-agent]")) | |
| 126 | button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent })); | |
| 127 | ||
| 128 | // Twenty times a second the server hears what this person is doing. | |
| 129 | setInterval(() => { | |
| 130 | if (!snap || !map) return; | |
| 131 | let mx = 0; | |
| 132 | let my = 0; | |
| 133 | if (keys.has("KeyW")) my -= 1; | |
| 134 | if (keys.has("KeyS")) my += 1; | |
| 135 | if (keys.has("KeyA")) mx -= 1; | |
| 136 | if (keys.has("KeyD")) mx += 1; | |
| 137 | if (stick) { | |
| 138 | mx = stick.mx; | |
| 139 | my = stick.my; | |
| 140 | } | |
| 141 | const aim = aimTouch || pointer; | |
| 142 | const me = worldToScreen(snap.me); | |
| 143 | const angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 144 | send({ | |
| 145 | t: "i", | |
| 146 | mx, | |
| 147 | my, | |
| 148 | a: angle, | |
| 149 | f: firing || keys.has("Space"), | |
| 150 | u: keys.has("KeyF"), | |
| 151 | r: keys.has("KeyR"), | |
| 152 | }); | |
| 153 | }, 50); | |
| 154 | ||
| 155 | // ------------------------------------------------------------------ drawing | |
| 156 | ||
| 157 | function resize() { | |
| 158 | const box = view.getBoundingClientRect(); | |
| 159 | const dpr = Math.min(devicePixelRatio || 1, 2); | |
| 160 | view.width = Math.round(box.width * dpr); | |
| 161 | view.height = Math.round(box.height * dpr); | |
| 162 | } | |
| 163 | addEventListener("resize", resize); | |
| 164 | ||
| 165 | const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1); | |
| 166 | const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); | |
| 167 | ||
| 168 | function smooth(id, x, y, angle) { | |
| 169 | const now = shown.get(id); | |
| 170 | if (!now) { | |
| 171 | shown.set(id, { x, y, angle }); | |
| 172 | return { x, y, angle }; | |
| 173 | } | |
| 174 | now.x += (x - now.x) * 0.35; | |
| 175 | now.y += (y - now.y) * 0.35; | |
| 176 | let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 177 | now.angle += turn * 0.4; | |
| 178 | return now; | |
| 179 | } | |
| 180 | ||
| 181 | function drawMap(s) { | |
| 182 | const t = map.tile * s; | |
| 183 | for (let row = 0; row < map.rows.length; row++) | |
| 184 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 185 | const cell = map.rows[row][col]; | |
| 186 | ctx.fillStyle = | |
| 187 | cell === "#" ? "#20242d" : cell === "A" || cell === "B" ? "#2c2a22" : cell === "a" ? "#2a2024" : cell === "d" ? "#1e2630" : "#171a21"; | |
| 188 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 189 | if (cell === "A" || cell === "B") { | |
| 190 | ctx.fillStyle = "#6b5f3a"; | |
| 191 | ctx.font = `${Math.round(t * 0.5)}px system-ui`; | |
| 192 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 193 | } | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | function drawZone(z, s) { | |
| 198 | const x = z.x * s; | |
| 199 | const y = z.y * s; | |
| 200 | const r = z.r * s; | |
| 201 | ctx.save(); | |
| 202 | if (z.k === "smoke") { | |
| 203 | ctx.fillStyle = "rgba(190,195,205,0.72)"; | |
| 204 | ctx.beginPath(); | |
| 205 | ctx.arc(x, y, r, 0, 7); | |
| 206 | ctx.fill(); | |
| 207 | } else if (z.k === "molly") { | |
| 208 | ctx.fillStyle = "rgba(230,110,50,0.45)"; | |
| 209 | ctx.strokeStyle = "#f08a4b"; | |
| 210 | ctx.beginPath(); | |
| 211 | ctx.arc(x, y, r, 0, 7); | |
| 212 | ctx.fill(); | |
| 213 | ctx.stroke(); | |
| 214 | } else if (z.k === "wall") { | |
| 215 | ctx.fillStyle = "rgba(120,200,255,0.55)"; | |
| 216 | ctx.beginPath(); | |
| 217 | ctx.arc(x, y, r, 0, 7); | |
| 218 | ctx.fill(); | |
| 219 | } else if (z.k === "flash") { | |
| 220 | ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; | |
| 221 | ctx.beginPath(); | |
| 222 | ctx.arc(x, y, z.f > 0 ? 5 * s : 30 * s, 0, 7); | |
| 223 | ctx.fill(); | |
| 224 | } else if (z.k === "dart") { | |
| 225 | ctx.strokeStyle = "rgba(90,220,170,0.7)"; | |
| 226 | ctx.beginPath(); | |
| 227 | ctx.arc(x, y, r, 0, 7); | |
| 228 | ctx.stroke(); | |
| 229 | } else if (z.k === "trip") { | |
| 230 | ctx.strokeStyle = "rgba(230,200,90,0.8)"; | |
| 231 | ctx.beginPath(); | |
| 232 | ctx.arc(x, y, r, 0, 7); | |
| 233 | ctx.stroke(); | |
| 234 | } else if (z.k === "heal") { | |
| 235 | ctx.fillStyle = "rgba(120,230,140,0.5)"; | |
| 236 | ctx.beginPath(); | |
| 237 | ctx.arc(x, y, 14 * s, 0, 7); | |
| 238 | ctx.fill(); | |
| 239 | } | |
| 240 | ctx.restore(); | |
| 241 | } | |
| 242 | ||
| 243 | function drawPlayer(p, s, me) { | |
| 244 | const pos = smooth(p.id, p.x, p.y, p.angle); | |
| 245 | const x = pos.x * s; | |
| 246 | const y = pos.y * s; | |
| 247 | const r = 16 * s; | |
| 248 | const mine = p.id === me.id; | |
| 249 | ctx.fillStyle = p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; | |
| 250 | if (!p.alive) ctx.fillStyle = "#555a63"; | |
| 251 | ctx.beginPath(); | |
| 252 | ctx.arc(x, y, r, 0, 7); | |
| 253 | ctx.fill(); | |
| 254 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; | |
| 255 | ctx.lineWidth = Math.max(1, 3 * s); | |
| 256 | ctx.stroke(); | |
| 257 | if (p.alive) { | |
| 258 | ctx.strokeStyle = mine ? "#fff" : ctx.fillStyle; | |
| 259 | ctx.beginPath(); | |
| 260 | ctx.moveTo(x, y); | |
| 261 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 262 | ctx.stroke(); | |
| 263 | } | |
| 264 | if (p.spike) { | |
| 265 | ctx.fillStyle = "#f0d060"; | |
| 266 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); | |
| 267 | } | |
| 268 | if (p.blind) { | |
| 269 | ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 270 | ctx.beginPath(); | |
| 271 | ctx.arc(x, y - r * 1.6, r * 0.35, 0, 7); | |
| 272 | ctx.fill(); | |
| 273 | } | |
| 274 | if (!mine) { | |
| 275 | ctx.fillStyle = "rgba(235,235,235,0.75)"; | |
| 276 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui`; | |
| 277 | ctx.textAlign = "center"; | |
| 278 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 279 | ctx.textAlign = "left"; | |
| 280 | } | |
| 281 | if (p.alive && p.hp < 100) { | |
| 282 | ctx.fillStyle = "#0008"; | |
| 283 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); | |
| 284 | ctx.fillStyle = "#6fcf6f"; | |
| 285 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | function frame() { | |
| 290 | requestAnimationFrame(frame); | |
| 291 | if (!map) return; | |
| 292 | const s = scale(); | |
| 293 | ctx.clearRect(0, 0, view.width, view.height); | |
| 294 | drawMap(s); | |
| 295 | if (!snap) return; | |
| 296 | ||
| 297 | for (const orb of snap.orbs) { | |
| 298 | ctx.fillStyle = "#c9a0ff"; | |
| 299 | ctx.beginPath(); | |
| 300 | ctx.arc(orb.x * s, orb.y * s, 9 * s, 0, 7); | |
| 301 | ctx.fill(); | |
| 302 | } | |
| 303 | for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s); | |
| 304 | ||
| 305 | if (snap.spike.x !== null) { | |
| 306 | const x = snap.spike.x * s; | |
| 307 | const y = snap.spike.y * s; | |
| 308 | ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; | |
| 309 | ctx.fillRect(x - 7 * s, y - 7 * s, 14 * s, 14 * s); | |
| 310 | if (snap.spike.planted && snap.spike.defuse > 0) { | |
| 311 | ctx.fillStyle = "#4fd6a0"; | |
| 312 | ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 4); | |
| 313 | } | |
| 314 | } | |
| 315 | ||
| 316 | for (const shot of snap.shots) { | |
| 317 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.55)"; | |
| 318 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 319 | ctx.beginPath(); | |
| 320 | ctx.moveTo(shot.x1 * s, shot.y1 * s); | |
| 321 | ctx.lineTo(shot.x2 * s, shot.y2 * s); | |
| 322 | ctx.stroke(); | |
| 323 | } | |
| 324 | ||
| 325 | for (const player of snap.players) drawPlayer(player, s, snap.me); | |
| 326 | // Smoke goes on last: it hides what is behind it on screen too. | |
| 327 | for (const zone of snap.zones) if (zone.k === "smoke") drawZone(zone, s); | |
| 328 | ||
| 329 | if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); | |
| 330 | else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") bar(snap.spike.defuse / 7, "#4fd6a0", "defusing"); | |
| 331 | $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8); | |
| 332 | $("dead").style.display = snap.me.alive ? "none" : "grid"; | |
| 333 | } | |
| 334 | ||
| 335 | function bar(fraction, colour, label) { | |
| 336 | const w = view.width * 0.4; | |
| 337 | const x = view.width / 2 - w / 2; | |
| 338 | const y = view.height * 0.86; | |
| 339 | ctx.fillStyle = "#0009"; | |
| 340 | ctx.fillRect(x, y, w, 16); | |
| 341 | ctx.fillStyle = colour; | |
| 342 | ctx.fillRect(x, y, w * Math.min(1, fraction), 16); | |
| 343 | ctx.fillStyle = "#fff"; | |
| 344 | ctx.font = "12px system-ui"; | |
| 345 | ctx.textAlign = "center"; | |
| 346 | ctx.fillText(label, view.width / 2, y + 12); | |
| 347 | ctx.textAlign = "left"; | |
| 348 | } | |
| 349 | ||
| 350 | // --------------------------------------------------------------------- hud | |
| 351 | ||
| 352 | const KITS = {}; | |
| 353 | ||
| 354 | function paintHud(s) { | |
| 355 | $("clock").textContent = Math.ceil(s.phase === "live" && s.spike.planted ? s.spike.fuse : s.clock); | |
| 356 | document.querySelector("#scoreline .red").textContent = "red " + s.score.red; | |
| 357 | document.querySelector("#scoreline .blue").textContent = s.score.blue + " blue"; | |
| 358 | $("phase").textContent = | |
| 359 | `round ${s.round} · ${s.phase === "buy" ? "buy phase" : s.phase === "live" ? (s.spike.planted ? "spike down" : "live") : s.phase === "end" ? "round over" : "match over"} · ${s.attackers} attacking · you are ${s.me.side === "atk" ? "attacking" : "defending"}${s.result ? " · " + s.result : ""}`; | |
| 360 | $("hp").textContent = `${s.me.hp} hp${s.me.armour ? " +" + s.me.armour : ""}`; | |
| 361 | $("gun").textContent = `${s.me.weapon}${s.me.reloading ? " reloading" : " " + s.me.ammo}`; | |
| 362 | $("creds").textContent = `${s.me.credits} cr`; | |
| 363 | $("ultmeter").textContent = `ult ${s.me.ult}/${s.me.ultNeeds}`; | |
| 364 | $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1"); | |
| 365 | if (s.phase !== "buy") $("buy").dataset.closed = ""; | |
| 366 | ||
| 367 | const kit = $("kit"); | |
| 368 | const shape = ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") + s.me.agent + s.me.ult; | |
| 369 | if (KITS.shape !== shape) { | |
| 370 | KITS.shape = shape; | |
| 371 | kit.innerHTML = ""; | |
| 372 | for (const slot of ["c", "q", "e", "x"]) { | |
| 373 | const button = document.createElement("button"); | |
| 374 | const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0; | |
| 375 | button.disabled = !ready; | |
| 376 | const count = slot === "x" ? `${s.me.ult}/${s.me.ultNeeds}` : s.me.cooldown[slot] > 0 ? `${Math.ceil(s.me.cooldown[slot])}s` : s.me.charges[slot]; | |
| 377 | button.innerHTML = `${slot.toUpperCase()} <b>${count}</b>`; | |
| 378 | button.type = "button"; | |
| 379 | button.addEventListener("click", () => send({ t: "i", c: slot })); | |
| 380 | kit.append(button); | |
| 381 | } | |
| 382 | } | |
| 383 | ||
| 384 | const log = $("log"); | |
| 385 | const text = s.events.slice(0, 3).join("\n"); | |
| 386 | if (log.dataset.text !== text) { | |
| 387 | log.dataset.text = text; | |
| 388 | log.innerHTML = ""; | |
| 389 | for (const line of s.events.slice(0, 3)) { | |
| 390 | const div = document.createElement("div"); | |
| 391 | div.textContent = line; | |
| 392 | log.append(div); | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | for (const side of ["atk", "def"]) { | |
| 397 | const body = $(side + "board"); | |
| 398 | const rows = s.board.filter((p) => p.side === side).sort((a, b) => b.kills - a.kills); | |
| 399 | const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join(); | |
| 400 | if (body.dataset.key === key) continue; | |
| 401 | body.dataset.key = key; | |
| 402 | body.innerHTML = ""; | |
| 403 | for (const player of rows) { | |
| 404 | const tr = document.createElement("tr"); | |
| 405 | const cells = [ | |
| 406 | player.name + (player.bot ? " (bot)" : "") + (player.alive ? "" : " ✕"), | |
| 407 | player.agent + (player.credits !== null ? ` · ${player.credits}cr` : ""), | |
| 408 | String(player.kills), | |
| 409 | String(player.deaths), | |
| 410 | ]; | |
| 411 | for (const value of cells) { | |
| 412 | const td = document.createElement("td"); | |
| 413 | td.textContent = value; | |
| 414 | tr.append(td); | |
| 415 | } | |
| 416 | if (player.id === s.me.id) tr.style.fontWeight = "700"; | |
| 417 | body.append(tr); | |
| 418 | } | |
| 419 | } | |
| 420 | } | |
| 421 | ||
| 422 | fetch("/map.json") | |
| 423 | .then((r) => r.json()) | |
| 424 | .then((data) => { | |
| 425 | map = data; | |
| 426 | resize(); | |
| 427 | connect(); | |
| 428 | }); | |
| 429 | requestAnimationFrame(frame); |