rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files b5f6bddcbc04948cd09fc3f6b819512f8c7d129c
| 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 { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; |
| 17 | import type { Point } from "./map.ts"; |
| 18 | import { |
| 19 | buyAbility, |
| 20 | buyShield, |
| 21 | buyWeapon, |
| 22 | newMatch, |
| 23 | pickAgent, |
| 24 | setInput, |
| 25 | tick, |
| 26 | visible, |
| 27 | } from "./match.ts"; |
| 28 | import type { MatchState, Player } from "./state.ts"; |
| 29 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 30 | |
| 31 | export const TICK = 1 / 30; |
| 32 | |
| 33 | /** |
| 34 | * How many ticks apart the snapshots go out. |
| 35 | * |
| 36 | * The simulation runs at 30Hz and used to broadcast every other tick, which |
| 37 | * gave the browser fifteen positions a second to draw sixty frames from — |
| 38 | * not many, and with interpolation it also doubles the delay the drawing has |
| 39 | * to sit behind the present. A small room gets every tick: it is the case |
| 40 | * that matters (one or two people playing each other), the payload is a |
| 41 | * couple of kilobytes of JSON a seat, and the arena only runs at all while |
| 42 | * somebody is connected. Above that the cost multiplies by seats and the |
| 43 | * older cadence comes back. |
| 44 | */ |
| 45 | export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); |
| 46 | |
| 47 | export type Seat = { playerId: string; token: string; address: string; send: (data: string) => void }; |
| 48 | |
| 49 | let state: MatchState = newMatch(); |
| 50 | const seats = new Map<string, Seat>(); |
| 51 | // A session is a cookie, and a cookie costs one HTTP request — free for |
| 52 | // anyone to mint as many of as they like. The address is the one thing here |
| 53 | // a single machine cannot hand itself two of, so it is what a playing seat |
| 54 | // is actually rationed by; the session on top of it is only what lets a |
| 55 | // reload find its way back to the same seat rather than a fresh one. |
| 56 | const addressSeats = new Map<string, string>(); |
| 57 | let ticks = 0; |
| 58 | let lastResult: string | null = null; |
| 59 | // The kill feed is read off the match a tick at a time; the rules keep no |
| 60 | // list of their own, and this is a view, not a rule. |
| 61 | let marks: Mark[] = marksOf(state.players); |
| 62 | let feed: Fall[] = []; |
| 63 | |
| 64 | export const match = (): MatchState => state; |
| 65 | |
| 66 | export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id); |
| 67 | |
| 68 | /** |
| 69 | * A name nobody else in this match is using. |
| 70 | * |
| 71 | * The kill feed, the round log and the scoreboard all address people by |
| 72 | * name: two players called Kit make a line that reads as a team kill this |
| 73 | * game cannot have. The bots are dealt distinct names; a person who picks |
| 74 | * one that is taken gets a number after it, inside the sixteen characters a |
| 75 | * name is allowed. |
| 76 | */ |
| 77 | function freeName(want: string, exceptId: string): string { |
| 78 | const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name)); |
| 79 | if (!taken.has(want)) return want; |
| 80 | for (let n = 2; n < 100; n++) { |
| 81 | const tried = `${want.slice(0, 13)} ${n}`; |
| 82 | if (!taken.has(tried)) return tried; |
| 83 | } |
| 84 | return want; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Whether this address already plays under a different session. Checked |
| 89 | * before a socket is even offered a seat, so the caller can hand it a |
| 90 | * spectator view instead of upgrading it into a second side's vision. |
| 91 | */ |
| 92 | export function addressCapped(address: string, session: string): boolean { |
| 93 | const holder = addressSeats.get(address); |
| 94 | return holder !== undefined && holder !== session; |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Sit somebody down in the emptiest team, replacing a bot where it stands. |
| 99 | * |
| 100 | * Both claims go in right here, synchronously, before the caller ever opens |
| 101 | * a socket. A websocket upgrade completes on a later tick, so two requests |
| 102 | * arriving close together both used to read the maps before either had |
| 103 | * written to them — one client, two seats, opposite sides, the vision of |
| 104 | * both. Claiming inline closes that window regardless of how the two |
| 105 | * requests were related. |
| 106 | */ |
| 107 | export function takeSeat(address: string, session: string, name: string): Player | null { |
| 108 | const existing = seats.get(session); |
| 109 | const held = existing ? findPlayer(existing.playerId) : undefined; |
| 110 | if (held) { |
| 111 | held.name = freeName(name, held.id); |
| 112 | return held; |
| 113 | } |
| 114 | if (addressCapped(address, session)) return null; |
| 115 | const humans = { red: 0, blue: 0 }; |
| 116 | for (const player of state.players) if (!player.bot) humans[player.team] += 1; |
| 117 | const wanted = humans.red <= humans.blue ? "red" : "blue"; |
| 118 | const seat = |
| 119 | state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); |
| 120 | if (!seat) return null; |
| 121 | seat.bot = false; |
| 122 | seat.name = freeName(name, seat.id); |
| 123 | seat.think = 0; |
| 124 | seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; |
| 125 | addressSeats.set(address, session); |
| 126 | seats.set(session, { playerId: seat.id, token: "", address, send: () => {} }); |
| 127 | return seat; |
| 128 | } |
| 129 | |
| 130 | /** The socket that actually opened fills in how to reach it and its token. */ |
| 131 | export function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void { |
| 132 | seats.set(session, { playerId, token, address, send }); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * A person leaving gives the seat back to a bot, mid-round and all — but only |
| 137 | * if this is still the socket the session is on. A reload opens the new |
| 138 | * socket before the old one's close event arrives; without the token, that |
| 139 | * late close would delete the reconnect's seat entry and hand a live human's |
| 140 | * seat to a bot mid-round. |
| 141 | */ |
| 142 | export function release(session: string, token: string): void { |
| 143 | const seat = seats.get(session); |
| 144 | if (!seat || seat.token !== token) return; |
| 145 | seats.delete(session); |
| 146 | if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address); |
| 147 | const player = findPlayer(seat.playerId); |
| 148 | if (player && !stillHeld(seat.playerId)) { |
| 149 | player.bot = true; |
| 150 | player.think = 0; |
| 151 | player.input.fire = false; |
| 152 | player.input.use = false; |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Test-only hygiene: give back whatever this address currently holds. The |
| 158 | * arena is one match shared by the whole test run, over real HTTP, from one |
| 159 | * loopback address — this is how a test that needs that address free gets it |
| 160 | * free, rather than depending on every other test in the file to clean up |
| 161 | * after itself. |
| 162 | */ |
| 163 | export function releaseAddress(address: string): void { |
| 164 | const session = addressSeats.get(address); |
| 165 | if (session !== undefined) release(session, seats.get(session)?.token ?? ""); |
| 166 | } |
| 167 | |
| 168 | const stillHeld = (playerId: string): boolean => |
| 169 | [...seats.values()].some((s) => s.playerId === playerId); |
| 170 | |
| 171 | export const seatCount = (): number => seats.size; |
| 172 | |
| 173 | // -------------------------------------------------------------- spectators |
| 174 | |
| 175 | // A connection that never carried a session the page minted gets no side and |
| 176 | // no seat: only the public state everyone already sees on the page itself. |
| 177 | const spectators = new Map<string, { send: (data: string) => void; why: Why }>(); |
| 178 | |
| 179 | /** |
| 180 | * Why this connection is watching. The client says so in words, and the two |
| 181 | * have different answers — open the tab that is playing, or let the page keep |
| 182 | * its cookie — so which one it is travels with the snapshot. |
| 183 | */ |
| 184 | export type Why = "address" | "cookie" | "unsaid"; |
| 185 | |
| 186 | export function spectatorSnapshot(why: Why = "unsaid"): string { |
| 187 | return JSON.stringify({ |
| 188 | t: "s", |
| 189 | spectator: true, |
| 190 | why, |
| 191 | phase: state.phase, |
| 192 | clock: round2(Math.max(0, state.clock)), |
| 193 | round: state.round, |
| 194 | score: state.score, |
| 195 | attackers: state.attackers, |
| 196 | result: state.result, |
| 197 | events: state.events.slice(0, 5).map((e) => e.text), |
| 198 | // Names and a gun, the same as a seat is sent, and for the same reason it |
| 199 | // is safe there: the feed says what happened, never where. |
| 200 | feed: feed.map((f) => ({ |
| 201 | k: f.killer, |
| 202 | v: f.victim, |
| 203 | w: f.weapon ? WEAPONS[f.weapon].name : null, |
| 204 | h: f.headshot, |
| 205 | t: round2(Math.max(0, state.now - f.at)), |
| 206 | })), |
| 207 | }); |
| 208 | } |
| 209 | |
| 210 | export function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void { |
| 211 | spectators.set(token, { send, why }); |
| 212 | send(spectatorSnapshot(why)); |
| 213 | } |
| 214 | |
| 215 | export function unwatch(token: string): void { |
| 216 | spectators.delete(token); |
| 217 | } |
| 218 | |
| 219 | // ------------------------------------------------------------- the commands |
| 220 | |
| 221 | export type Command = { t: string; [key: string]: unknown }; |
| 222 | |
| 223 | const num = (value: unknown): number | undefined => |
| 224 | typeof value === "number" && Number.isFinite(value) ? value : undefined; |
| 225 | |
| 226 | /** Everything a client may ask for, validated here and nowhere else. */ |
| 227 | export function command(playerId: string, message: Command): void { |
| 228 | const player = findPlayer(playerId); |
| 229 | if (!player || player.bot) return; |
| 230 | switch (message.t) { |
| 231 | case "i": |
| 232 | setInput(player, { |
| 233 | mx: num(message.mx), |
| 234 | my: num(message.my), |
| 235 | angle: num(message.a), |
| 236 | fire: typeof message.f === "boolean" ? message.f : undefined, |
| 237 | use: typeof message.u === "boolean" ? message.u : undefined, |
| 238 | reload: typeof message.r === "boolean" ? message.r : undefined, |
| 239 | cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined, |
| 240 | }); |
| 241 | break; |
| 242 | case "buy": { |
| 243 | const what = String(message.what ?? ""); |
| 244 | if (isWeaponId(what)) buyWeapon(state, player, what); |
| 245 | else if (what === "light" || what === "heavy") buyShield(state, player, what); |
| 246 | else if (what === "c" || what === "q") buyAbility(state, player, what); |
| 247 | break; |
| 248 | } |
| 249 | case "agent": |
| 250 | if (typeof message.id === "string") pickAgent(state, player, message.id); |
| 251 | break; |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // ------------------------------------------------------------- the snapshot |
| 256 | |
| 257 | const round2 = (n: number): number => Math.round(n * 10) / 10; |
| 258 | |
| 259 | export function snapshot(viewerId: string): string { |
| 260 | const me = findPlayer(viewerId) ?? state.players[0]!; |
| 261 | const agent = agentById(me.agent); |
| 262 | const seen = state.players.filter((p) => visible(state, me, p)); |
| 263 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); |
| 264 | |
| 265 | return JSON.stringify({ |
| 266 | t: "s", |
| 267 | phase: state.phase, |
| 268 | clock: round2(Math.max(0, state.clock)), |
| 269 | round: state.round, |
| 270 | score: state.score, |
| 271 | attackers: state.attackers, |
| 272 | result: state.result, |
| 273 | me: { |
| 274 | id: me.id, |
| 275 | side: me.side, |
| 276 | team: me.team, |
| 277 | agent: me.agent, |
| 278 | alive: me.alive, |
| 279 | x: round2(me.x), |
| 280 | y: round2(me.y), |
| 281 | angle: round2(me.angle), |
| 282 | hp: Math.round(me.hp), |
| 283 | armour: Math.round(me.armour), |
| 284 | credits: me.credits, |
| 285 | weapon: me.weapon, |
| 286 | ammo: me.ammo, |
| 287 | reloading: me.reloadIn > 0, |
| 288 | charges: me.charges, |
| 289 | cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, |
| 290 | ult: me.ult, |
| 291 | ultNeeds: agent.abilities.x.points, |
| 292 | blind: round2(me.blind), |
| 293 | slow: round2(me.slow), |
| 294 | haste: round2(me.haste), |
| 295 | spike: me.spike, |
| 296 | planting: round2(me.planting), |
| 297 | // Your own two ways of being held still, so the browser's prediction |
| 298 | // of your own body runs at the speed the server is about to use. |
| 299 | defusing: me.defusing > 0, |
| 300 | bot: me.bot, |
| 301 | }, |
| 302 | players: seen.map((p) => ({ |
| 303 | id: p.id, |
| 304 | name: p.name, |
| 305 | agent: p.agent, |
| 306 | x: round2(p.x), |
| 307 | y: round2(p.y), |
| 308 | angle: round2(p.angle), |
| 309 | hp: Math.round(p.hp), |
| 310 | armour: Math.round(p.armour), |
| 311 | alive: p.alive, |
| 312 | mate: p.side === me.side, |
| 313 | spike: p.spike, |
| 314 | blind: p.blind > 0, |
| 315 | revealed: p.revealed > 0, |
| 316 | })), |
| 317 | zones: state.zones |
| 318 | .filter((z) => (z.kind === "trip" ? z.side === me.side : true)) |
| 319 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), |
| 320 | spike: { |
| 321 | planted: state.spike.planted, |
| 322 | carried: state.spike.carried !== null, |
| 323 | fuse: round2(state.spike.fuse), |
| 324 | defuse: round2(state.spike.defuse), |
| 325 | checkpoint: state.spike.checkpoint, |
| 326 | // A loose spike is the attackers' business until it is armed. |
| 327 | x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, |
| 328 | y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, |
| 329 | }, |
| 330 | orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), |
| 331 | shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), |
| 332 | board: state.players.map((p) => ({ |
| 333 | id: p.id, |
| 334 | name: p.name, |
| 335 | team: p.team, |
| 336 | side: p.side, |
| 337 | agent: p.agent, |
| 338 | bot: p.bot, |
| 339 | alive: p.alive, |
| 340 | kills: p.kills, |
| 341 | deaths: p.deaths, |
| 342 | damage: Math.round(p.damageDone), |
| 343 | credits: p.team === me.team ? p.credits : null, |
| 344 | ult: p.ult, |
| 345 | ultNeeds: agentById(p.agent).abilities.x.points, |
| 346 | })), |
| 347 | events: state.events.slice(0, 5).map((e) => e.text), |
| 348 | // Names and a gun, never a position: the feed says what happened, not |
| 349 | // where, so it can never be read as a call-out. |
| 350 | feed: feed.map((f) => ({ |
| 351 | k: f.killer, |
| 352 | v: f.victim, |
| 353 | w: f.weapon ? WEAPONS[f.weapon].name : null, |
| 354 | h: f.headshot, |
| 355 | t: round2(Math.max(0, state.now - f.at)), |
| 356 | })), |
| 357 | }); |
| 358 | } |
| 359 | |
| 360 | /** |
| 361 | * Who fell on this tick. A shot that landed this tick and started where the |
| 362 | * killer stands is what tells a gun kill from an ability kill — the position |
| 363 | * is used here and goes no further. |
| 364 | */ |
| 365 | function readFalls(): void { |
| 366 | const after = marksOf(state.players); |
| 367 | const shooters = new Set<string>(); |
| 368 | for (const shot of state.shots) { |
| 369 | if (!shot.hit || shot.age > 0) continue; |
| 370 | for (const player of state.players) |
| 371 | if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); |
| 372 | } |
| 373 | const falls = fallsBetween(marks, after, { |
| 374 | at: state.now, |
| 375 | events: state.events.slice(0, 6).map((e) => e.text), |
| 376 | shooters, |
| 377 | }); |
| 378 | marks = after; |
| 379 | if (falls.length) feed = [...feed, ...falls]; |
| 380 | feed = trimFeed(feed, state.now); |
| 381 | } |
| 382 | |
| 383 | // ----------------------------------------------------------------- the loop |
| 384 | |
| 385 | /** |
| 386 | * Nobody watching, nothing happens. |
| 387 | * |
| 388 | * The arena used to tick around the clock, so bots played out rounds for an |
| 389 | * empty room — burning a share of a small box's CPU and filling the record |
| 390 | * with matches no one saw. The world now freezes when the last connection |
| 391 | * goes and carries on from exactly where it stopped when someone arrives, so |
| 392 | * a visitor never finds a score that ran up while they were away. |
| 393 | * |
| 394 | * A spectator counts as somebody: watching is watching. |
| 395 | */ |
| 396 | const attended = (): boolean => seats.size > 0 || spectators.size > 0; |
| 397 | |
| 398 | /** How many connections the arena is playing for: seats plus watchers. */ |
| 399 | export const attendance = (): number => seats.size + spectators.size; |
| 400 | |
| 401 | function step(): void { |
| 402 | runBots(state, TICK); |
| 403 | tick(state, TICK); |
| 404 | readFalls(); |
| 405 | if (state.result && state.result !== lastResult) { |
| 406 | lastResult = state.result; |
| 407 | recordResult(state.result, state.score.red, state.score.blue, state.round); |
| 408 | } |
| 409 | if (state.result === null) lastResult = null; |
| 410 | ticks += 1; |
| 411 | if (ticks % broadcastEvery(seats.size) !== 0) return; |
| 412 | for (const seat of seats.values()) { |
| 413 | try { |
| 414 | seat.send(snapshot(seat.playerId)); |
| 415 | } catch { |
| 416 | // A socket that will not take a frame is closed already; the close |
| 417 | // handler gives the seat back. |
| 418 | } |
| 419 | } |
| 420 | for (const spectator of spectators.values()) { |
| 421 | try { |
| 422 | spectator.send(spectatorSnapshot(spectator.why)); |
| 423 | } catch { |
| 424 | // Same story: the close handler drops it from the set. |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | let timer: ReturnType<typeof setInterval> | null = null; |
| 430 | |
| 431 | /** |
| 432 | * One beat of real time. The gate lives here rather than inside `step` so the |
| 433 | * tests can still drive the simulation directly with `advance` — a test is an |
| 434 | * attendance of its own, and making it prove otherwise would only teach the |
| 435 | * suite to work around the rule. |
| 436 | */ |
| 437 | export function beat(): void { |
| 438 | if (attended()) step(); |
| 439 | } |
| 440 | |
| 441 | export function startArena(): void { |
| 442 | if (timer) return; |
| 443 | timer = setInterval(beat, TICK * 1000); |
| 444 | // The clock must never hold the process open on its own. |
| 445 | (timer as unknown as { unref?: () => void }).unref?.(); |
| 446 | } |
| 447 | |
| 448 | export function stopArena(): void { |
| 449 | if (timer) clearInterval(timer); |
| 450 | timer = null; |
| 451 | } |
| 452 | |
| 453 | /** Used by the tests: run the arena forward without waiting for real time. */ |
| 454 | export function advance(seconds: number): void { |
| 455 | for (let i = 0; i < Math.round(seconds / TICK); i++) step(); |
| 456 | } |