benjivers/val Public Public: anyone can read this; the crew can change it.
Merge pull request #31 from fable/29-above-two-players-every-enemy-is-drawn-4
Above two players, every enemy is drawn 42ms further in the past than it needs to be
14 files
changed +481
−228
README.md +3 −2
| @@ -96,8 +96,9 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 96 | 96 | body drawn slow, then fast. The moment being drawn advances at real time |
| 97 | 97 | and creeps toward its delay at no more than two percent of it, so the |
| 98 | 98 | delay adapting never shows as a short frame. Snapshots go out every tick |
| 99 | (30Hz, some 2 KiB a seat) while two or fewer seats are filled, and every | |
| 100 | other tick above that. The scoreboard — ten names, agents and tallies, | |
| 99 | (30Hz, some 1.6 KiB a seat in a live round) to every seat the room can | |
| 100 | hold; the cadence is a function of the room in `src/arena.ts`, with the | |
| 101 | measured cost in its comment. The scoreboard — ten names, agents and tallies, | |
| 101 | 102 | almost half of every snapshot — is sent only when it differs from what |
| 102 | 103 | that seat last received, and the page keeps the one it has; everything |
| 103 | 104 | else is in every snapshot. |
src/arena.test.ts +15 −13
| @@ -14,12 +14,13 @@ import { join } from "node:path"; | ||
| 14 | 14 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); |
| 15 | 15 | const { advance, attach, broadcastEvery, match, release, seatCount, snapshot } = await import("./arena.ts"); |
| 16 | 16 | |
| 17 | test("a small room hears every tick, a full one every other", () => { | |
| 18 | expect(broadcastEvery(0)).toBe(1); | |
| 19 | expect(broadcastEvery(1)).toBe(1); | |
| 20 | expect(broadcastEvery(2)).toBe(1); | |
| 21 | expect(broadcastEvery(3)).toBe(2); | |
| 22 | expect(broadcastEvery(10)).toBe(2); | |
| 17 | test("every room this match can hold hears every tick", () => { | |
| 18 | // The third seat used to halve the rate, which drew every other body 42ms | |
| 19 | // further in the past for everyone. What it saved is in the comment on the | |
| 20 | // rule; the ten-seat room is the whole of this game. | |
| 21 | for (let filled = 0; filled <= 10; filled++) expect(broadcastEvery(filled)).toBe(1); | |
| 22 | // Past the measured room the old cadence stays, unmeasured rather than right. | |
| 23 | expect(broadcastEvery(11)).toBe(2); | |
| 23 | 24 | }); |
| 24 | 25 | |
| 25 | 26 | /** A seat that only counts what it was sent. */ |
| @@ -44,21 +45,22 @@ test("the loop sends at the cadence the seat count asks for", () => { | ||
| 44 | 45 | release("rate-b", "rate-b"); |
| 45 | 46 | }); |
| 46 | 47 | |
| 47 | test("a full room halves the cadence rather than multiplying the traffic", () => { | |
| 48 | test("a third seat no longer halves what everybody is sent", () => { | |
| 48 | 49 | const seats = ["rate-c", "rate-d", "rate-e"].map(listen); |
| 49 | 50 | expect(seatCount()).toBeGreaterThan(2); |
| 50 | 51 | advance(1); |
| 51 | for (const got of seats) expect(got.length).toBe(15); | |
| 52 | for (const got of seats) expect(got.length).toBe(30); | |
| 52 | 53 | for (const session of ["rate-c", "rate-d", "rate-e"]) release(session, session); |
| 53 | 54 | }); |
| 54 | 55 | |
| 55 | 56 | test("a snapshot stays small enough to send thirty times a second", () => { |
| 56 | 57 | // The trade is only worth making if the payload is small. Measured over a |
| 57 | // live round it is 3.2-4.5 KiB a seat, mean 3.4 — a hundred KiB a second | |
| 58 | // at 30Hz, and at most two seats are sent that much. This is the number | |
| 59 | // the pull quotes, so it is checked here rather than assumed; the ceiling | |
| 60 | // is loose enough not to fail on a busy tick and tight enough that a | |
| 61 | // snapshot which doubled in size would say so. | |
| 58 | // live round it is ~1.6 KiB a seat since the board went sticky and the | |
| 59 | // vision filter came in — 48 KiB a second at 30Hz, and every seat is sent | |
| 60 | // that much now. This is the number the cadence's comment quotes, so it | |
| 61 | // is checked here rather than assumed; the ceiling is loose enough not to | |
| 62 | // fail on a busy tick and tight enough that a snapshot which doubled in | |
| 63 | // size would say so. | |
| 62 | 64 | for (let i = 0; i < 40 && match().phase !== "live"; i++) advance(1); |
| 63 | 65 | const bytes = Buffer.byteLength(snapshot(match().players[0]!.id)); |
| 64 | 66 | expect(bytes).toBeGreaterThan(200); |
src/arena.ts +234 −181
| @@ -14,22 +14,51 @@ | ||
| 14 | 14 | import { agentById, sightBlocked } from "./abilities.ts"; |
| 15 | 15 | import { runBots } from "./bots.ts"; |
| 16 | 16 | import { recordResult } from "./db.ts"; |
| 17 | import { makeArena, type Command, type Why } from "./engine/arena.ts"; | |
| 17 | import { makeArena, type Command, type Game, type Why } from "./engine/arena.ts"; | |
| 18 | 18 | import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; |
| 19 | 19 | import type { Point } from "./map.ts"; |
| 20 | 20 | import { wallBetween } from "./map.ts"; |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, ROSTER, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 22 | 22 | import type { MatchState, Player, Zone } from "./state.ts"; |
| 23 | 23 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 24 | 24 | |
| 25 | export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; | |
| 25 | export { TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; | |
| 26 | 26 | |
| 27 | const state: MatchState = newMatch(); | |
| 28 | let lastResult: string | null = null; | |
| 29 | // The kill feed is read off the match a tick at a time; the rules keep no | |
| 30 | // list of their own, and this is a view, not a rule. | |
| 31 | let marks: Mark[] = marksOf(state.players); | |
| 32 | let feed: Fall[] = []; | |
| 27 | /** | |
| 28 | * How many ticks apart this game's snapshots go out, given the seats filled: | |
| 29 | * every tick, for every room this match can hold. | |
| 30 | * | |
| 31 | * The rule used to halve the rate at the third seat. The browser's render | |
| 32 | * delay follows the rate it measures — 1.3 × the interval, floored at 45ms | |
| 33 | * (`static/engine/interp.js`) — so that third seat moved every other body | |
| 34 | * everybody saw from 45ms in the past to 87ms. In a game about peeking a | |
| 35 | * corner first that was the wrong thing to save, and what it saved is small | |
| 36 | * enough to write down. | |
| 37 | * | |
| 38 | * Measured 2026-09 on a 6-vCPU EPYC 9555P under bun 1.3: medians of seven | |
| 39 | * 20-second runs of a seeded match with idle humans in the seats, the | |
| 40 | * never-sending loop subtracted to leave the snapshots alone. | |
| 41 | * | |
| 42 | * one world tick 27µs | |
| 43 | * one snapshot, vision filter and all 6µs | |
| 44 | * | |
| 45 | * seats loop, every tick of which snapshots bytes out: live round / whole match | |
| 46 | * 4 1.3ms a second 0.8ms 210 kB/s / 340 kB/s | |
| 47 | * 8 2.0ms a second 1.7ms 385 kB/s / 645 kB/s | |
| 48 | * 10 2.1ms a second 2.0ms 465 kB/s / 790 kB/s | |
| 49 | * | |
| 50 | * That is 0.2% of one core with the room full; a box five times slower is | |
| 51 | * at 1%. The wire is the larger number — 790 kB/s (6 Mbit/s) out of the box | |
| 52 | * at ten seats, 80 kB/s to each of them — which a small droplet's port | |
| 53 | * carries without noticing and a phone on 4G has ten times the headroom | |
| 54 | * for. A live-round snapshot is ~1.6 KiB a seat; a buy phase is larger, | |
| 55 | * which is the gap between the two bytes columns. | |
| 56 | * | |
| 57 | * The threshold is the room: ten seats is every seat there is, so the every | |
| 58 | * tick case is the only one a person ever sits in. Past the measured room | |
| 59 | * the old cadence stays because it is unmeasured, not because it is right. | |
| 60 | */ | |
| 61 | export const broadcastEvery = (filled: number): number => (filled <= ROSTER * 2 ? 1 : 2); | |
| 33 | 62 | |
| 34 | 63 | const round2 = (n: number): number => Math.round(n * 10) / 10; |
| 35 | 64 | |
| @@ -84,184 +113,208 @@ function runCommand(state: MatchState, player: Player, message: Command): void { | ||
| 84 | 113 | } |
| 85 | 114 | } |
| 86 | 115 | |
| 87 | // Names and a gun, never a position: the feed says what happened, not | |
| 88 | // where, so it can never be read as a call-out. | |
| 89 | const feedFor = (state: MatchState) => | |
| 90 | feed.map((f) => ({ | |
| 91 | k: f.killer, | |
| 92 | v: f.victim, | |
| 93 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 94 | h: f.headshot, | |
| 95 | t: round2(Math.max(0, state.now - f.at)), | |
| 96 | })); | |
| 116 | /** What this game is to the arena: one match, and the answers `makeArena` asks for. */ | |
| 117 | export function valGame( | |
| 118 | state: MatchState, | |
| 119 | record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult, | |
| 120 | cadence: (filled: number) => number = broadcastEvery, | |
| 121 | ): Game<MatchState, Player> { | |
| 122 | let lastResult: string | null = null; | |
| 123 | // The kill feed is read off the match a tick at a time; the rules keep no | |
| 124 | // list of their own, and this is a view, not a rule. | |
| 125 | let marks: Mark[] = marksOf(state.players); | |
| 126 | let feed: Fall[] = []; | |
| 127 | ||
| 128 | // Names and a gun, never a position: the feed says what happened, not | |
| 129 | // where, so it can never be read as a call-out. | |
| 130 | const feedFor = (state: MatchState) => | |
| 131 | feed.map((f) => ({ | |
| 132 | k: f.killer, | |
| 133 | v: f.victim, | |
| 134 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 135 | h: f.headshot, | |
| 136 | t: round2(Math.max(0, state.now - f.at)), | |
| 137 | })); | |
| 138 | ||
| 139 | /** The public state everyone already sees on the page itself. */ | |
| 140 | function spectatorShape(state: MatchState, _why: Why) { | |
| 141 | return { | |
| 142 | phase: state.phase, | |
| 143 | clock: round2(Math.max(0, state.clock)), | |
| 144 | round: state.round, | |
| 145 | score: state.score, | |
| 146 | attackers: state.attackers, | |
| 147 | result: state.result, | |
| 148 | last: state.last, | |
| 149 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 150 | feed: feedFor(state), | |
| 151 | }; | |
| 152 | } | |
| 153 | ||
| 154 | /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */ | |
| 155 | function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | |
| 156 | const agent = agentById(me.agent); | |
| 157 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); | |
| 158 | // A zone is known to the viewer if they are standing in it (so your own | |
| 159 | // smoke never vanishes from under you) or, failing that, if no wall sits | |
| 160 | // between the two — the wall test only, not `sightBlocked`, which also | |
| 161 | // counts zones and would let a smoke hide the wall behind it. | |
| 162 | const canSeeZone = (z: Zone): boolean => | |
| 163 | Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z); | |
| 164 | ||
| 165 | return { | |
| 166 | phase: state.phase, | |
| 167 | clock: round2(Math.max(0, state.clock)), | |
| 168 | round: state.round, | |
| 169 | score: state.score, | |
| 170 | attackers: state.attackers, | |
| 171 | result: state.result, | |
| 172 | last: state.last, | |
| 173 | me: { | |
| 174 | id: me.id, | |
| 175 | side: me.side, | |
| 176 | team: me.team, | |
| 177 | agent: me.agent, | |
| 178 | alive: me.alive, | |
| 179 | x: round2(me.x), | |
| 180 | y: round2(me.y), | |
| 181 | angle: round2(me.angle), | |
| 182 | hp: Math.round(me.hp), | |
| 183 | armour: Math.round(me.armour), | |
| 184 | credits: me.credits, | |
| 185 | weapon: me.weapon, | |
| 186 | ammo: me.ammo, | |
| 187 | reloading: me.reloadIn > 0, | |
| 188 | charges: me.charges, | |
| 189 | cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, | |
| 190 | ult: me.ult, | |
| 191 | ultNeeds: agent.abilities.x.points, | |
| 192 | blind: round2(me.blind), | |
| 193 | spike: me.spike, | |
| 194 | planting: round2(me.planting), | |
| 195 | // How fast the server is about to move you, in units a second, and not | |
| 196 | // the reasons behind it: the browser's prediction runs at this number | |
| 197 | // and re-derives nothing. A rule that holds you still — planting, | |
| 198 | // defusing, a decided round — arrives as a zero. To a tenth, like every | |
| 199 | // other number here: rounded to a whole unit instead, a hasted walk | |
| 200 | // (337.5) is drawn a half-unit a second fast and clipped back by the | |
| 201 | // cap every frame, which measured 0.2 points of per-frame spread. A | |
| 202 | // tenth is a no-op for feel and costs at most two bytes. | |
| 203 | speed: round2(speedOf(state, me)), | |
| 204 | bot: me.bot, | |
| 205 | }, | |
| 206 | players: seen.map((p) => ({ | |
| 207 | id: p.id, | |
| 208 | name: p.name, | |
| 209 | agent: p.agent, | |
| 210 | x: round2(p.x), | |
| 211 | y: round2(p.y), | |
| 212 | angle: round2(p.angle), | |
| 213 | hp: Math.round(p.hp), | |
| 214 | armour: Math.round(p.armour), | |
| 215 | alive: p.alive, | |
| 216 | mate: p.side === me.side, | |
| 217 | spike: p.spike, | |
| 218 | blind: p.blind > 0, | |
| 219 | revealed: p.revealed > 0, | |
| 220 | })), | |
| 221 | zones: state.zones | |
| 222 | .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z))) | |
| 223 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), | |
| 224 | spike: { | |
| 225 | planted: state.spike.planted, | |
| 226 | carried: state.spike.carried !== null, | |
| 227 | fuse: round2(state.spike.fuse), | |
| 228 | defuse: round2(state.spike.defuse), | |
| 229 | checkpoint: state.spike.checkpoint, | |
| 230 | // A loose spike is the attackers' business until it is armed. | |
| 231 | x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, | |
| 232 | y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, | |
| 233 | }, | |
| 234 | orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), | |
| 235 | shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), | |
| 236 | // The scoreboard — ten names, agents and tallies — was almost half of | |
| 237 | // every snapshot and changes a few times a round, so the arena sends it | |
| 238 | // only when it differs from what the seat last received. | |
| 239 | board: state.players.map((p) => ({ | |
| 240 | id: p.id, | |
| 241 | name: p.name, | |
| 242 | team: p.team, | |
| 243 | side: p.side, | |
| 244 | agent: p.agent, | |
| 245 | bot: p.bot, | |
| 246 | alive: p.alive, | |
| 247 | kills: p.kills, | |
| 248 | deaths: p.deaths, | |
| 249 | damage: Math.round(p.damageDone), | |
| 250 | credits: p.team === me.team ? p.credits : null, | |
| 251 | ult: p.ult, | |
| 252 | ultNeeds: agentById(p.agent).abilities.x.points, | |
| 253 | })), | |
| 254 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 255 | feed: feedFor(state), | |
| 256 | }; | |
| 257 | } | |
| 258 | ||
| 259 | /** | |
| 260 | * Who fell on this tick. A shot that landed this tick and started where the | |
| 261 | * killer stands is what tells a gun kill from an ability kill — the position | |
| 262 | * is used here and goes no further. | |
| 263 | */ | |
| 264 | function readFalls(state: MatchState): void { | |
| 265 | const after = marksOf(state.players); | |
| 266 | const shooters = new Set<string>(); | |
| 267 | for (const shot of state.shots) { | |
| 268 | if (!shot.hit || shot.age > 0) continue; | |
| 269 | for (const player of state.players) | |
| 270 | if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); | |
| 271 | } | |
| 272 | const falls = fallsBetween(marks, after, { | |
| 273 | at: state.now, | |
| 274 | events: state.events.slice(0, 6).map((e) => e.text), | |
| 275 | shooters, | |
| 276 | }); | |
| 277 | marks = after; | |
| 278 | if (falls.length) feed = [...feed, ...falls]; | |
| 279 | feed = trimFeed(feed, state.now); | |
| 280 | } | |
| 281 | ||
| 282 | /** One tick of this game: the bots, the rules, the feed, and the record. */ | |
| 283 | function step(state: MatchState, dt: number): void { | |
| 284 | runBots(state, dt); | |
| 285 | tick(state, dt); | |
| 286 | readFalls(state); | |
| 287 | if (state.result && state.result !== lastResult) { | |
| 288 | lastResult = state.result; | |
| 289 | record(state.result, state.score.red, state.score.blue, state.round); | |
| 290 | } | |
| 291 | if (state.result === null) lastResult = null; | |
| 292 | } | |
| 97 | 293 | |
| 98 | /** The public state everyone already sees on the page itself. */ | |
| 99 | function spectatorShape(state: MatchState, _why: Why) { | |
| 100 | 294 | return { |
| 101 | phase: state.phase, | |
| 102 | clock: round2(Math.max(0, state.clock)), | |
| 103 | round: state.round, | |
| 104 | score: state.score, | |
| 105 | attackers: state.attackers, | |
| 106 | result: state.result, | |
| 107 | last: state.last, | |
| 108 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 109 | feed: feedFor(state), | |
| 295 | state, | |
| 296 | players: (s) => s.players, | |
| 297 | step, | |
| 298 | sit, | |
| 299 | stand, | |
| 300 | visible, | |
| 301 | snapshot: snapshotShape, | |
| 302 | sticky: "board", | |
| 303 | cadence, | |
| 304 | spectatorSnapshot: spectatorShape, | |
| 305 | command: runCommand, | |
| 110 | 306 | }; |
| 111 | 307 | } |
| 112 | 308 | |
| 113 | /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */ | |
| 114 | function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | |
| 115 | const agent = agentById(me.agent); | |
| 116 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); | |
| 117 | // A zone is known to the viewer if they are standing in it (so your own | |
| 118 | // smoke never vanishes from under you) or, failing that, if no wall sits | |
| 119 | // between the two — the wall test only, not `sightBlocked`, which also | |
| 120 | // counts zones and would let a smoke hide the wall behind it. | |
| 121 | const canSeeZone = (z: Zone): boolean => | |
| 122 | Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z); | |
| 309 | /** A served match of this game, on a fresh world unless one is handed over. */ | |
| 310 | export const makeValArena = ( | |
| 311 | state: MatchState = newMatch(), | |
| 312 | record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult, | |
| 313 | cadence: (filled: number) => number = broadcastEvery, | |
| 314 | ) => makeArena<MatchState, Player>(valGame(state, record, cadence)); | |
| 123 | 315 | |
| 124 | return { | |
| 125 | phase: state.phase, | |
| 126 | clock: round2(Math.max(0, state.clock)), | |
| 127 | round: state.round, | |
| 128 | score: state.score, | |
| 129 | attackers: state.attackers, | |
| 130 | result: state.result, | |
| 131 | last: state.last, | |
| 132 | me: { | |
| 133 | id: me.id, | |
| 134 | side: me.side, | |
| 135 | team: me.team, | |
| 136 | agent: me.agent, | |
| 137 | alive: me.alive, | |
| 138 | x: round2(me.x), | |
| 139 | y: round2(me.y), | |
| 140 | angle: round2(me.angle), | |
| 141 | hp: Math.round(me.hp), | |
| 142 | armour: Math.round(me.armour), | |
| 143 | credits: me.credits, | |
| 144 | weapon: me.weapon, | |
| 145 | ammo: me.ammo, | |
| 146 | reloading: me.reloadIn > 0, | |
| 147 | charges: me.charges, | |
| 148 | cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, | |
| 149 | ult: me.ult, | |
| 150 | ultNeeds: agent.abilities.x.points, | |
| 151 | blind: round2(me.blind), | |
| 152 | spike: me.spike, | |
| 153 | planting: round2(me.planting), | |
| 154 | // How fast the server is about to move you, in units a second, and not | |
| 155 | // the reasons behind it: the browser's prediction runs at this number | |
| 156 | // and re-derives nothing. A rule that holds you still — planting, | |
| 157 | // defusing, a decided round — arrives as a zero. To a tenth, like every | |
| 158 | // other number here: rounded to a whole unit instead, a hasted walk | |
| 159 | // (337.5) is drawn a half-unit a second fast and clipped back by the | |
| 160 | // cap every frame, which measured 0.2 points of per-frame spread. A | |
| 161 | // tenth is a no-op for feel and costs at most two bytes. | |
| 162 | speed: round2(speedOf(state, me)), | |
| 163 | bot: me.bot, | |
| 164 | }, | |
| 165 | players: seen.map((p) => ({ | |
| 166 | id: p.id, | |
| 167 | name: p.name, | |
| 168 | agent: p.agent, | |
| 169 | x: round2(p.x), | |
| 170 | y: round2(p.y), | |
| 171 | angle: round2(p.angle), | |
| 172 | hp: Math.round(p.hp), | |
| 173 | armour: Math.round(p.armour), | |
| 174 | alive: p.alive, | |
| 175 | mate: p.side === me.side, | |
| 176 | spike: p.spike, | |
| 177 | blind: p.blind > 0, | |
| 178 | revealed: p.revealed > 0, | |
| 179 | })), | |
| 180 | zones: state.zones | |
| 181 | .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z))) | |
| 182 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), | |
| 183 | spike: { | |
| 184 | planted: state.spike.planted, | |
| 185 | carried: state.spike.carried !== null, | |
| 186 | fuse: round2(state.spike.fuse), | |
| 187 | defuse: round2(state.spike.defuse), | |
| 188 | checkpoint: state.spike.checkpoint, | |
| 189 | // A loose spike is the attackers' business until it is armed. | |
| 190 | x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, | |
| 191 | y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, | |
| 192 | }, | |
| 193 | orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), | |
| 194 | shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), | |
| 195 | // The scoreboard — ten names, agents and tallies — was almost half of | |
| 196 | // every snapshot and changes a few times a round, so the arena sends it | |
| 197 | // only when it differs from what the seat last received. | |
| 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 | feed: feedFor(state), | |
| 215 | }; | |
| 216 | } | |
| 217 | ||
| 218 | /** | |
| 219 | * Who fell on this tick. A shot that landed this tick and started where the | |
| 220 | * killer stands is what tells a gun kill from an ability kill — the position | |
| 221 | * is used here and goes no further. | |
| 222 | */ | |
| 223 | function readFalls(state: MatchState): void { | |
| 224 | const after = marksOf(state.players); | |
| 225 | const shooters = new Set<string>(); | |
| 226 | for (const shot of state.shots) { | |
| 227 | if (!shot.hit || shot.age > 0) continue; | |
| 228 | for (const player of state.players) | |
| 229 | if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); | |
| 230 | } | |
| 231 | const falls = fallsBetween(marks, after, { | |
| 232 | at: state.now, | |
| 233 | events: state.events.slice(0, 6).map((e) => e.text), | |
| 234 | shooters, | |
| 235 | }); | |
| 236 | marks = after; | |
| 237 | if (falls.length) feed = [...feed, ...falls]; | |
| 238 | feed = trimFeed(feed, state.now); | |
| 239 | } | |
| 240 | ||
| 241 | /** One tick of this game: the bots, the rules, the feed, and the record. */ | |
| 242 | function step(state: MatchState, dt: number): void { | |
| 243 | runBots(state, dt); | |
| 244 | tick(state, dt); | |
| 245 | readFalls(state); | |
| 246 | if (state.result && state.result !== lastResult) { | |
| 247 | lastResult = state.result; | |
| 248 | recordResult(state.result, state.score.red, state.score.blue, state.round); | |
| 249 | } | |
| 250 | if (state.result === null) lastResult = null; | |
| 251 | } | |
| 252 | ||
| 253 | const arena = makeArena<MatchState, Player>({ | |
| 254 | state, | |
| 255 | players: (s) => s.players, | |
| 256 | step, | |
| 257 | sit, | |
| 258 | stand, | |
| 259 | visible, | |
| 260 | snapshot: snapshotShape, | |
| 261 | sticky: "board", | |
| 262 | spectatorSnapshot: spectatorShape, | |
| 263 | command: runCommand, | |
| 264 | }); | |
| 316 | // The one live match the server serves. | |
| 317 | const arena = makeValArena(); | |
| 265 | 318 | |
| 266 | 319 | export const { |
| 267 | 320 | match, |
src/engine/arena.test.ts +19 −1
| @@ -12,7 +12,7 @@ type Toy = { id: string; name: string; bot: boolean; side: "a" | "b"; x: number; | ||
| 12 | 12 | type World = { ticks: number; players: Toy[]; heard: Command[] }; |
| 13 | 13 | |
| 14 | 14 | /** Two sides of two; everybody on side "b" is out of side "a"'s sight unless marked seen. */ |
| 15 | function toyGame() { | |
| 15 | function toyGame(cadence?: (filled: number) => number) { | |
| 16 | 16 | const world: World = { |
| 17 | 17 | ticks: 0, |
| 18 | 18 | heard: [], |
| @@ -40,6 +40,7 @@ function toyGame() { | ||
| 40 | 40 | board: s.players.map((p) => ({ id: p.id, name: p.name, bot: p.bot })), |
| 41 | 41 | }), |
| 42 | 42 | sticky: "board", |
| 43 | cadence, | |
| 43 | 44 | spectatorSnapshot: (s) => ({ ticks: s.ticks, names: s.players.map((p) => p.name) }), |
| 44 | 45 | command: (s, _p, message) => void s.heard.push(message), |
| 45 | 46 | }); |
| @@ -150,3 +151,20 @@ test("the loop sends at the cadence the room asks for, and a bot's commands are | ||
| 150 | 151 | arena.command("b2", { t: "i" }); // a bot: nobody may drive it from outside |
| 151 | 152 | expect(arena.match().heard.length).toBe(1); |
| 152 | 153 | }); |
| 154 | ||
| 155 | test("a game that names its own cadence is sent at it, whatever the default says", () => { | |
| 156 | // Where the rate halves is a fact about one game's snapshot and one game's | |
| 157 | // room, so the game says; the engine's default is only for a game that has | |
| 158 | // not measured. Three ticks apart here, to be sure it is this and not the default. | |
| 159 | const arena = toyGame((filled) => (filled >= 2 ? 3 : 1)); | |
| 160 | const got: string[] = []; | |
| 161 | const first = arena.takeSeat("10.0.0.1", "s1", "Kit")!; | |
| 162 | arena.attach("s1", first.id, "10.0.0.1", "t1", (data) => got.push(data)); | |
| 163 | arena.advance(1); | |
| 164 | expect(got.length).toBe(Math.round(1 / TICK)); | |
| 165 | const second = arena.takeSeat("10.0.0.2", "s2", "Kat")!; | |
| 166 | arena.attach("s2", second.id, "10.0.0.2", "t2", () => {}); | |
| 167 | got.length = 0; | |
| 168 | arena.advance(1); | |
| 169 | expect(got.length).toBe(Math.round(1 / TICK) / 3); | |
| 170 | }); |
src/engine/arena.ts +11 −7
| @@ -22,13 +22,13 @@ | ||
| 22 | 22 | export const TICK = 1 / 30; |
| 23 | 23 | |
| 24 | 24 | /** |
| 25 | * How many ticks apart the snapshots go out. | |
| 25 | * How many ticks apart the snapshots go out when the game does not say. | |
| 26 | 26 | * |
| 27 | * The simulation runs at 30Hz. A small room gets every tick: it is the case | |
| 28 | * that matters (one or two people playing each other), the payload is a | |
| 29 | * couple of kilobytes of JSON a seat, and the arena only runs at all while | |
| 30 | * somebody is connected. Above that the cost multiplies by seats and the | |
| 31 | * older cadence — every other tick — comes back. | |
| 27 | * The simulation runs at 30Hz. A small room gets every tick; above that the | |
| 28 | * cost multiplies by seats and the older cadence — every other tick — comes | |
| 29 | * back. Where the line sits is a fact about one game's snapshot and one | |
| 30 | * game's room, so a game that has measured its own passes `cadence` and this | |
| 31 | * default is never consulted. | |
| 32 | 32 | */ |
| 33 | 33 | export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); |
| 34 | 34 | |
| @@ -73,6 +73,8 @@ export type Game<S, P extends Body> = { | ||
| 73 | 73 | * last received; the client keeps the one it has. Null sends everything. |
| 74 | 74 | */ |
| 75 | 75 | sticky: string | null; |
| 76 | /** How many ticks apart this game's snapshots go out, given the seats filled. `broadcastEvery` when unsaid. */ | |
| 77 | cadence?: (filled: number) => number; | |
| 76 | 78 | /** The public view for a connection with no seat. Must carry no position. */ |
| 77 | 79 | spectatorSnapshot: (state: S, why: Why) => Record<string, unknown>; |
| 78 | 80 | /** Everything a client may ask for, validated by the game and nowhere else. */ |
| @@ -261,10 +263,12 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) { | ||
| 261 | 263 | /** How many connections the arena is playing for: seats plus watchers. */ |
| 262 | 264 | const attendance = (): number => seats.size + spectators.size; |
| 263 | 265 | |
| 266 | const cadence = game.cadence ?? broadcastEvery; | |
| 267 | ||
| 264 | 268 | function step(): void { |
| 265 | 269 | game.step(state, TICK); |
| 266 | 270 | ticks += 1; |
| 267 | if (ticks % broadcastEvery(seats.size) !== 0) return; | |
| 271 | if (ticks % cadence(seats.size) !== 0) return; | |
| 268 | 272 | for (const seat of seats.values()) { |
| 269 | 273 | try { |
| 270 | 274 | seat.send(snapshotForSeat(seat)); |
src/feel.test.ts +149
| @@ -11,6 +11,9 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | import { mkdtempSync } from "node:fs"; | |
| 15 | import { tmpdir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 14 | 17 | import { BODY_R, RUN } from "./engine/glide.ts"; |
| 15 | 18 | import { solidTile, TILE } from "./map.ts"; |
| 16 | 19 | import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts"; |
| @@ -19,6 +22,10 @@ import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static | ||
| 19 | 22 | import { makeLocal, stepLocal } from "../static/engine/predict.js"; |
| 20 | 23 | import { makeYaw, settle, told } from "../static/engine/yaw.js"; |
| 21 | 24 | |
| 25 | // The arena records finished matches under FORGE_APP_DATA the moment it is imported. | |
| 26 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); | |
| 27 | const { broadcastEvery, makeValArena } = await import("./arena.ts"); | |
| 28 | ||
| 22 | 29 | const round2 = (n: number) => Math.round(n * 10) / 10; |
| 23 | 30 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; |
| 24 | 31 | |
| @@ -459,3 +466,145 @@ test("the simulation's own motion is drawn at an even speed", async () => { | ||
| 459 | 466 | expect(mean * 60).toBeGreaterThan(150); |
| 460 | 467 | expect(spread).toBeLessThan(0.15); |
| 461 | 468 | }); |
| 469 | ||
| 470 | /** | |
| 471 | * Everybody else, as the real arena sends them to a real seat in a room of | |
| 472 | * `seats` people: the cadence the arena picks for that room, the vision | |
| 473 | * filter, the sticky board, all of it — over the same jittery wire as | |
| 474 | * `watchBots`, drawn the same way. The room is what is being measured, so | |
| 475 | * the seats sit idle and the bots do the walking. | |
| 476 | * | |
| 477 | * The wire opens three seconds before the round goes live and the first | |
| 478 | * three seconds of the rush are measured: the render delay creeps toward | |
| 479 | * its mark at two percent of real time, so a timeline that has just met a | |
| 480 | * 15Hz stream spends two seconds drawing bodies past the newest sample | |
| 481 | * while it gets there. That is what a room felt at the moment its third | |
| 482 | * seat filled; here each cadence is measured settled, and `cold` says how | |
| 483 | * many frames the buy phase spent guessing ahead. | |
| 484 | */ | |
| 485 | function watchRoom(seats: number, cadence: (filled: number) => number) { | |
| 486 | const random = rng(5); | |
| 487 | const arena = makeValArena(newMatch(23), () => {}, cadence); | |
| 488 | const packets: { at: number; bodies: Body[] }[] = []; | |
| 489 | let t = 0; | |
| 490 | let viewer = ""; | |
| 491 | for (let i = 0; i < seats; i++) { | |
| 492 | const seat = arena.takeSeat(`10.9.0.${i + 1}`, `feel-${i}`, `Seat ${i}`)!; | |
| 493 | if (i === 0) viewer = seat.id; | |
| 494 | arena.attach(`feel-${i}`, seat.id, `10.9.0.${i + 1}`, `t${i}`, (data) => { | |
| 495 | if (i !== 0) return; | |
| 496 | const snap = JSON.parse(data) as { players: Body[] }; | |
| 497 | packets.push({ at: t + 0.02 + jitter(random), bodies: snap.players.filter((p) => p.id !== viewer) }); | |
| 498 | }); | |
| 499 | } | |
| 500 | const tickOnce = () => { | |
| 501 | t += 1 / 30; | |
| 502 | arena.advance(1 / 30); | |
| 503 | }; | |
| 504 | // The last three seconds of the buy phase, then the first three of the round. | |
| 505 | const state = arena.match(); | |
| 506 | while (!(state.phase === "buy" && state.clock <= 3)) tickOnce(); | |
| 507 | packets.length = 0; | |
| 508 | t = 0; | |
| 509 | let warm = 0; | |
| 510 | while (state.phase !== "live") { | |
| 511 | tickOnce(); | |
| 512 | warm = t; | |
| 513 | } | |
| 514 | for (let k = 0; k < 90; k++) tickOnce(); | |
| 515 | packets.sort((a, b) => a.at - b.at); | |
| 516 | ||
| 517 | const timeline = makeTimeline(); | |
| 518 | const paths = new Map<string, { x: number; y: number }[]>(); | |
| 519 | const delays: number[] = []; | |
| 520 | let ahead = 0; | |
| 521 | let cold = 0; | |
| 522 | let frames = 0; | |
| 523 | let next = 0; | |
| 524 | let restartsWarm = 0; | |
| 525 | for (let f = 0; f / 60 < warm + 2.8; f++) { | |
| 526 | const now = f / 60; | |
| 527 | while (next < packets.length && packets[next]!.at <= now) { | |
| 528 | const stamp = arrive(timeline, packets[next]!.at); | |
| 529 | for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); | |
| 530 | next += 1; | |
| 531 | } | |
| 532 | if (next < 4) continue; | |
| 533 | const drawAt = renderTime(timeline, now); | |
| 534 | if (now < warm) { | |
| 535 | if (drawAt > timeline.stamp) cold += 1; | |
| 536 | restartsWarm = timeline.restarts; | |
| 537 | continue; | |
| 538 | } | |
| 539 | delays.push(now - drawAt); | |
| 540 | frames += 1; | |
| 541 | if (drawAt > timeline.stamp) ahead += 1; | |
| 542 | for (const p of packets[next - 1]!.bodies) { | |
| 543 | const at = bodyAt(timeline, p, drawAt); | |
| 544 | paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); | |
| 545 | } | |
| 546 | } | |
| 547 | const walked = [...paths.values()] | |
| 548 | .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) | |
| 549 | .map((steps) => steps.filter((s) => s > 0.001)) | |
| 550 | .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; | |
| 551 | const { mean, spread } = spreadOf(walked); | |
| 552 | return { | |
| 553 | packets: packets.filter((p) => p.at >= warm).length, | |
| 554 | steps: walked.length, | |
| 555 | mean, | |
| 556 | spread, | |
| 557 | restarts: timeline.restarts - restartsWarm, | |
| 558 | delay: { min: Math.min(...delays), max: Math.max(...delays), now: delay(timeline) }, | |
| 559 | ahead, | |
| 560 | cold, | |
| 561 | frames, | |
| 562 | }; | |
| 563 | } | |
| 564 | ||
| 565 | /** The rule as it stood before: every other tick from the third seat. */ | |
| 566 | const halvedAtThree = (filled: number): number => (filled <= 2 ? 1 : 2); | |
| 567 | ||
| 568 | test("in a room of four or eight, everybody else is drawn 45ms behind, not 87, with no new jitter", () => { | |
| 569 | for (const seats of [4, 8]) { | |
| 570 | const before = watchRoom(seats, halvedAtThree); | |
| 571 | const after = watchRoom(seats, broadcastEvery); | |
| 572 | for (const [label, r] of [ | |
| 573 | ["before", before], | |
| 574 | [" after", after], | |
| 575 | ] as const) { | |
| 576 | console.log( | |
| 577 | `room of ${seats}, ${label}: ${r.packets} snapshots in 3s, ${r.steps} steps, mean ${r.mean.toFixed(2)}/frame, ` + | |
| 578 | `spread ${(r.spread * 100).toFixed(1)}%, restarts ${r.restarts}, ` + | |
| 579 | `delay ${(r.delay.min * 1000).toFixed(1)}–${(r.delay.max * 1000).toFixed(1)}ms (${(r.delay.now * 1000).toFixed(1)} in force), ` + | |
| 580 | `drawn past the newest sample ${r.ahead}/${r.frames} frames, ${r.cold} while the delay settled`, | |
| 581 | ); | |
| 582 | } | |
| 583 | // Thirty a second reach this seat, not fifteen. | |
| 584 | expect(after.packets).toBeGreaterThanOrEqual(85); | |
| 585 | expect(before.packets).toBeLessThanOrEqual(46); | |
| 586 | // And so every other body is drawn at the floor of the render delay, not | |
| 587 | // 1.3 intervals of a halved rate: 45ms behind the present, not 87. | |
| 588 | expect(after.delay.max).toBeLessThan(0.05); | |
| 589 | expect(before.delay.max).toBeGreaterThan(0.08); | |
| 590 | // Closer to the present buys no jitter: the steps are never less even | |
| 591 | // than they were at the halved rate, and no body in sight all along | |
| 592 | // started again. In the room of four the body measured runs clean and | |
| 593 | // holds the small room's 8% (`watchBots`); in the room of eight the two | |
| 594 | // bots left thread past the idle bodies at spawn, and those stops are the | |
| 595 | // server's, the same in both columns, so only the comparison is held. | |
| 596 | expect(after.steps).toBeGreaterThan(40); | |
| 597 | expect(after.mean * 60).toBeGreaterThan(150); | |
| 598 | if (seats === 4) expect(after.spread).toBeLessThan(0.08); | |
| 599 | expect(after.spread).toBeLessThanOrEqual(before.spread + 0.01); | |
| 600 | expect(after.restarts).toBeLessThanOrEqual(before.restarts); | |
| 601 | // A 45ms delay over a 33ms beat leaves 12ms for a packet to be late, and | |
| 602 | // the wire holds one in thirty by about that: the odd frame is drawn on | |
| 603 | // the newest pair's velocity, one interval at most, which a room of two | |
| 604 | // gets today. What must not appear is the run of guessed frames a | |
| 605 | // timeline spends settling on a halved rate. | |
| 606 | expect(after.ahead).toBeLessThanOrEqual(Math.ceil(after.frames * 0.03)); | |
| 607 | expect(before.cold).toBeGreaterThan(10); | |
| 608 | expect(after.cold).toBeLessThanOrEqual(2); | |
| 609 | } | |
| 610 | }); |
src/engine/aim.ts +5 −4
| @@ -2,9 +2,10 @@ | ||
| 2 | 2 | * How a bot points a gun. |
| 3 | 3 | * |
| 4 | 4 | * The shot itself is shared with people — weapon spread, the move penalty, |
| 5 | * the spray, the blind wobble all live in `weapons.ts` and apply to everyone | |
| 6 | * the same way. What lives here is the part a person is bad at and a bot was | |
| 7 | * not: seeing somebody, and getting the barrel onto them. | |
| 5 | * the spray, the blind wobble all live in the game's own weapon rules and | |
| 6 | * apply to everyone the same way. What lives here is the part a person is | |
| 7 | * bad at and a bot was not: seeing somebody, and getting the barrel onto | |
| 8 | * them. | |
| 8 | 9 | * |
| 9 | 10 | * A hold on a target has three honest costs. It takes a moment to notice |
| 10 | 11 | * (`reaction`), the barrel starts somewhere wrong and only settles the longer |
| @@ -44,7 +45,7 @@ export const REACTION_MAX = 0.35; | ||
| 44 | 45 | export const FORGET = 0.4; |
| 45 | 46 | /** Seconds for the cone to fall by 1/e while the same target is held. */ |
| 46 | 47 | export const SETTLE = 0.8; |
| 47 | /** The cone a bot settles to: under a Vandal's own spread, never zero. */ | |
| 48 | /** The cone a bot settles to: under the weapon's own spread, never zero. */ | |
| 48 | 49 | export const CONE_FLOOR = 0.008; |
| 49 | 50 | /** What the cone opens to the instant a target is acquired (~6°). */ |
| 50 | 51 | export const CONE_SWING = 0.105; |
src/engine/arena.test.ts +10 −1
| @@ -104,6 +104,15 @@ test("a viewer is sent only the bodies the game says they can see", () => { | ||
| 104 | 104 | expect(JSON.stringify(snap.players)).not.toContain('"b1"'); |
| 105 | 105 | }); |
| 106 | 106 | |
| 107 | test("an unknown viewer gets the spectator view, never another player's eyes", () => { | |
| 108 | const arena = toyGame(); | |
| 109 | const snap = JSON.parse(arena.snapshot("nobody-here")) as Record<string, unknown>; | |
| 110 | expect(snap).toMatchObject({ t: "s", spectator: true }); | |
| 111 | expect(snap.me).toBeUndefined(); | |
| 112 | expect(snap.players).toBeUndefined(); | |
| 113 | expect(JSON.stringify(snap)).not.toContain('"x"'); | |
| 114 | }); | |
| 115 | ||
| 107 | 116 | test("a spectator is sent the public state and never a position", () => { |
| 108 | 117 | const arena = toyGame(); |
| 109 | 118 | const got: string[] = []; |
| @@ -119,7 +128,7 @@ test("a spectator is sent the public state and never a position", () => { | ||
| 119 | 128 | |
| 120 | 129 | test("the sticky field goes out once, then only when it changes", () => { |
| 121 | 130 | const arena = toyGame(); |
| 122 | const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, board: "" }; | |
| 131 | const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, stickyCache: "" }; | |
| 123 | 132 | const first = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] }; |
| 124 | 133 | expect(first.board?.length).toBe(4); |
| 125 | 134 | const second = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] }; |
src/engine/arena.ts +9 −6
| @@ -38,7 +38,7 @@ export type Seat = { | ||
| 38 | 38 | address: string; |
| 39 | 39 | send: (data: string) => void; |
| 40 | 40 | /** The sticky part of the snapshot as this seat last received it, so it is only resent when it changes. */ |
| 41 | board: string; | |
| 41 | stickyCache: string; | |
| 42 | 42 | }; |
| 43 | 43 | |
| 44 | 44 | /** |
| @@ -148,13 +148,13 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) { | ||
| 148 | 148 | if (!seat) return null; |
| 149 | 149 | seat.name = freeName(name, seat.id); |
| 150 | 150 | addressSeats.set(address, session); |
| 151 | seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" }); | |
| 151 | seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, stickyCache: "" }); | |
| 152 | 152 | return seat; |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | 155 | /** The socket that actually opened fills in how to reach it and its token. */ |
| 156 | 156 | function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void { |
| 157 | seats.set(session, { playerId, token, address, send, board: "" }); | |
| 157 | seats.set(session, { playerId, token, address, send, stickyCache: "" }); | |
| 158 | 158 | } |
| 159 | 159 | |
| 160 | 160 | const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId); |
| @@ -218,8 +218,11 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) { | ||
| 218 | 218 | // ----------------------------------------------------------- the snapshot |
| 219 | 219 | |
| 220 | 220 | function snapshotFor(viewerId: string): Record<string, unknown> { |
| 221 | const me = findPlayer(viewerId); | |
| 222 | // No seat, no eyes: an id that names nobody gets exactly what a seatless | |
| 223 | // connection already gets, never another player's vision by accident. | |
| 224 | if (!me) return { t: "s", spectator: true, why: "unsaid", ...game.spectatorSnapshot(state, "unsaid") }; | |
| 221 | 225 | const players = game.players(state); |
| 222 | const me = findPlayer(viewerId) ?? players[0]!; | |
| 223 | 226 | const seen = players.filter((p) => game.visible(state, me, p)); |
| 224 | 227 | return { t: "s", ...game.snapshot(state, me, seen) }; |
| 225 | 228 | } |
| @@ -236,8 +239,8 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) { | ||
| 236 | 239 | const full = snapshotFor(seat.playerId); |
| 237 | 240 | if (game.sticky !== null) { |
| 238 | 241 | const part = JSON.stringify(full[game.sticky]); |
| 239 | if (part === seat.board) delete full[game.sticky]; | |
| 240 | else seat.board = part; | |
| 242 | if (part === seat.stickyCache) delete full[game.sticky]; | |
| 243 | else seat.stickyCache = part; | |
| 241 | 244 | } |
| 242 | 245 | return JSON.stringify(full); |
| 243 | 246 | } |
src/engine/boundary.test.ts +16 −5
| @@ -9,12 +9,21 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { expect, test } from "bun:test"; |
| 12 | import { readdirSync, readFileSync } from "node:fs"; | |
| 12 | import { existsSync, readdirSync, readFileSync } from "node:fs"; | |
| 13 | 13 | import { dirname, join, resolve } from "node:path"; |
| 14 | 14 | |
| 15 | 15 | const ROOT = resolve(import.meta.dir, "..", ".."); |
| 16 | 16 | const ENGINE = [join(ROOT, "src", "engine"), join(ROOT, "static", "engine")]; |
| 17 | 17 | |
| 18 | /** | |
| 19 | * The game's own nouns, read as data rather than imported as code — an | |
| 20 | * import from here would itself be a crossing the test below refuses. A | |
| 21 | * copy of the engine that lands in a fresh game starts with no such file | |
| 22 | * and so no such guard, until that game writes one down. | |
| 23 | */ | |
| 24 | const WORDS_FILE = join(ROOT, "src", "words.json"); | |
| 25 | const gameWords: string[] = existsSync(WORDS_FILE) ? JSON.parse(readFileSync(WORDS_FILE, "utf8")) : []; | |
| 26 | ||
| 18 | 27 | const inEngine = (path: string): boolean => ENGINE.some((dir) => resolve(path).startsWith(dir + "/")); |
| 19 | 28 | |
| 20 | 29 | /** Every import specifier in a file: static, re-exported, or dynamic. */ |
| @@ -70,10 +79,12 @@ test("no engine module, and no engine test, imports a game module", () => { | ||
| 70 | 79 | expect(crossings).toEqual([]); |
| 71 | 80 | }); |
| 72 | 81 | |
| 73 | test("the engine does not know what a spike is", () => { | |
| 74 | // The words of this game. An engine file that says one of them has a hole | |
| 75 | // that should have been a parameter. | |
| 76 | const words = /\b(spike|agent|credits|molly|defus\w*|plant\w*|orb|valorant)\b/i; | |
| 82 | test.skipIf(gameWords.length === 0)("the engine does not know what this game calls things", () => { | |
| 83 | // The words are the game's own, from src/words.json, never hardcoded here: | |
| 84 | // a game that legitimately has a "spike" should not inherit a guard against | |
| 85 | // a game it has never played. An engine file that says one of them has a | |
| 86 | // hole that should have been a parameter. | |
| 87 | const words = new RegExp(`\\b(${gameWords.join("|")})\\b`, "i"); | |
| 77 | 88 | const said: string[] = []; |
| 78 | 89 | for (const file of files) { |
| 79 | 90 | if (file.endsWith(".test.ts")) continue; |
src/engine/predict.test.ts +1 −1
| @@ -55,7 +55,7 @@ test("a frame of movement is the speed it was given, in the direction it was giv | ||
| 55 | 55 | const diagonal = anchor(makeLocal(), { x: 1200, y: 640 }); |
| 56 | 56 | advance(diagonal, 1, 1, 250, 1 / 50, world); |
| 57 | 57 | expect(Math.hypot(diagonal.x - 1200, diagonal.y - 640)).toBeCloseTo(5, 6); |
| 58 | // Standing still, planted, or stopped to defuse: nothing moves. | |
| 58 | // Standing still, or moving at a speed the game has reduced to zero: nothing moves. | |
| 59 | 59 | const still = anchor(makeLocal(), { x: 1200, y: 640 }); |
| 60 | 60 | advance(still, 0, 0, 250, 1 / 50, world); |
| 61 | 61 | advance(still, 1, 0, moveSpeed({ stopped: true }), 1 / 50, world); |
src/server.test.ts +1 −1
| @@ -267,7 +267,7 @@ test("the scoreboard is sent when it changes and left out when it has not", asyn | ||
| 267 | 267 | const { snapshotForSeat } = await import("./arena.ts"); |
| 268 | 268 | const state = match(); |
| 269 | 269 | const player = state.players[0]!; |
| 270 | const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, board: "" }; | |
| 270 | const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, stickyCache: "" }; | |
| 271 | 271 | const first = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[]; last?: unknown }; |
| 272 | 272 | expect(first.board?.length).toBe(10); |
| 273 | 273 | const second = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[] }; |
src/words.json added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | ["spike", "agent", "credits", "molly", "defus\\w*", "plant\\w*", "orb", "valorant"] |
static/engine/predict.js +7 −6
| @@ -162,12 +162,13 @@ export const insideWall = (local, world) => circleBlocked(local.x, local.y, worl | ||
| 162 | 162 | * onto that, advanced a frame, and never drawn past it. |
| 163 | 163 | * |
| 164 | 164 | * A lean across a corner can put the drawn body inside a wall, and `refused` |
| 165 | * is the caller's word for anywhere else the server would not let you stand | |
| 166 | * (the spawn pen in the buy phase). Both used to take the server's answer, | |
| 167 | * which is a jump: pressed against a wall and strafing, that fired most | |
| 168 | * frames and read as a stutter. Holding the last good place is invisible and | |
| 169 | * just as honest — the next snapshot corrects it anyway. Anchoring is kept | |
| 170 | * for the one case where holding still leaves the body in a wall. | |
| 165 | * is the caller's word for anywhere else the server would not let you stand — | |
| 166 | * a rule the game itself raises and drops, not a wall on the map. Both used | |
| 167 | * to take the server's answer, which is a jump: pressed against a wall and | |
| 168 | * strafing, that fired most frames and read as a stutter. Holding the last | |
| 169 | * good place is invisible and just as honest — the next snapshot corrects it | |
| 170 | * anyway. Anchoring is kept for the one case where holding still leaves the | |
| 171 | * body in a wall. | |
| 171 | 172 | */ |
| 172 | 173 | export function stepLocal(local, me, held, speed, age, dt, world, refused) { |
| 173 | 174 | const target = project(me, held.mx, held.my, speed, age, world); |