benjivers/val Public Public: anyone can read this; the crew can change it.
Files 31494a25fdd48724e276e99986bb4f49dec5802d
| 1 | /** |
| 2 | * The shape of a served match: one world, always running while somebody is |
| 3 | * connected, and the seats people sit in. |
| 4 | * |
| 5 | * Nothing here knows what the game is. The game hands over its state and a |
| 6 | * handful of answers — how to step it, who is in it, what one viewer may see |
| 7 | * and what a snapshot looks like — and this module keeps the rules every |
| 8 | * served game needs and none should re-derive: |
| 9 | * |
| 10 | * - The idle gate: nobody watching, nothing happens. The world freezes when |
| 11 | * the last connection goes and carries on when someone arrives. |
| 12 | * - A playing seat is rationed by address, not by cookie: a cookie costs one |
| 13 | * HTTP request, an address is the one thing a machine cannot mint two of. |
| 14 | * Both claims go in synchronously, before a socket is ever offered a seat. |
| 15 | * - A connection with no seat watches: it is sent the public state and no |
| 16 | * position in it. |
| 17 | * - Every snapshot is built for one viewer, from the bodies the game says |
| 18 | * that viewer can see. The client never learns a position it has no line |
| 19 | * of sight to. |
| 20 | */ |
| 21 | |
| 22 | export const TICK = 1 / 30; |
| 23 | |
| 24 | /** |
| 25 | * How many ticks apart the snapshots go out when the game does not say. |
| 26 | * |
| 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 | */ |
| 33 | export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); |
| 34 | |
| 35 | export type Seat = { |
| 36 | playerId: string; |
| 37 | token: string; |
| 38 | address: string; |
| 39 | send: (data: string) => void; |
| 40 | /** The sticky part of the snapshot as this seat last received it, so it is only resent when it changes. */ |
| 41 | stickyCache: string; |
| 42 | }; |
| 43 | |
| 44 | /** |
| 45 | * Why this connection is watching. The client says so in words, and the two |
| 46 | * have different answers — open the tab that is playing, or let the page keep |
| 47 | * its cookie — so which one it is travels with the snapshot. |
| 48 | */ |
| 49 | export type Why = "address" | "cookie" | "unsaid"; |
| 50 | |
| 51 | export type Command = { t: string; [key: string]: unknown }; |
| 52 | |
| 53 | /** What the arena needs of a body: a way to find it, address it, and tell a bot from a person. */ |
| 54 | export type Body = { id: string; name: string; bot: boolean }; |
| 55 | |
| 56 | /** The game, as the arena sees it. */ |
| 57 | export type Game<S, P extends Body> = { |
| 58 | /** The one world. Mutated in place; the arena never replaces it. */ |
| 59 | state: S; |
| 60 | players: (state: S) => P[]; |
| 61 | /** One fixed step of the world, bots and rules and all. */ |
| 62 | step: (state: S, dt: number) => void; |
| 63 | /** Hand a bot's seat to a person: the same body, made human. Null when there is none. */ |
| 64 | sit: (state: S) => P | null; |
| 65 | /** Give a seat back to a bot. */ |
| 66 | stand: (state: S, player: P) => void; |
| 67 | /** May this viewer be sent this body? The server's answer, never the client's. */ |
| 68 | visible: (state: S, viewer: P, other: P) => boolean; |
| 69 | /** The snapshot for one viewer, given exactly the bodies they may see. */ |
| 70 | snapshot: (state: S, viewer: P, seen: P[]) => Record<string, unknown>; |
| 71 | /** |
| 72 | * A field of the snapshot resent only when it differs from what the seat |
| 73 | * last received; the client keeps the one it has. Null sends everything. |
| 74 | */ |
| 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; |
| 78 | /** The public view for a connection with no seat. Must carry no position. */ |
| 79 | spectatorSnapshot: (state: S, why: Why) => Record<string, unknown>; |
| 80 | /** Everything a client may ask for, validated by the game and nowhere else. */ |
| 81 | command: (state: S, player: P, message: Command) => void; |
| 82 | }; |
| 83 | |
| 84 | export function makeArena<S, P extends Body>(game: Game<S, P>) { |
| 85 | const state = game.state; |
| 86 | const seats = new Map<string, Seat>(); |
| 87 | // A session is a cookie, and a cookie costs one HTTP request — free for |
| 88 | // anyone to mint as many of as they like. The address is the one thing here |
| 89 | // a single machine cannot hand itself two of, so it is what a playing seat |
| 90 | // is actually rationed by; the session on top of it is only what lets a |
| 91 | // reload find its way back to the same seat rather than a fresh one. |
| 92 | const addressSeats = new Map<string, string>(); |
| 93 | let ticks = 0; |
| 94 | |
| 95 | const match = (): S => state; |
| 96 | const findPlayer = (id: string): P | undefined => game.players(state).find((p) => p.id === id); |
| 97 | |
| 98 | /** |
| 99 | * A name nobody else in this match is using. Two players under one name |
| 100 | * make a feed line that reads as a team kill; a person who picks a taken |
| 101 | * name gets a number after it, inside the sixteen characters a name is |
| 102 | * allowed. |
| 103 | */ |
| 104 | function freeName(want: string, exceptId: string): string { |
| 105 | const taken = new Set( |
| 106 | game |
| 107 | .players(state) |
| 108 | .filter((p) => p.id !== exceptId) |
| 109 | .map((p) => p.name), |
| 110 | ); |
| 111 | if (!taken.has(want)) return want; |
| 112 | for (let n = 2; n < 100; n++) { |
| 113 | const tried = `${want.slice(0, 13)} ${n}`; |
| 114 | if (!taken.has(tried)) return tried; |
| 115 | } |
| 116 | return want; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Whether this address already plays under a different session. Checked |
| 121 | * before a socket is even offered a seat, so the caller can hand it a |
| 122 | * spectator view instead of upgrading it into a second side's vision. |
| 123 | */ |
| 124 | function addressCapped(address: string, session: string): boolean { |
| 125 | const holder = addressSeats.get(address); |
| 126 | return holder !== undefined && holder !== session; |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Sit somebody down, replacing a bot where it stands. |
| 131 | * |
| 132 | * Both claims go in right here, synchronously, before the caller ever opens |
| 133 | * a socket. A websocket upgrade completes on a later tick, so two requests |
| 134 | * arriving close together both used to read the maps before either had |
| 135 | * written to them — one client, two seats, opposite sides, the vision of |
| 136 | * both. Claiming inline closes that window regardless of how the two |
| 137 | * requests were related. |
| 138 | */ |
| 139 | function takeSeat(address: string, session: string, name: string): P | null { |
| 140 | const existing = seats.get(session); |
| 141 | const held = existing ? findPlayer(existing.playerId) : undefined; |
| 142 | if (held) { |
| 143 | held.name = freeName(name, held.id); |
| 144 | return held; |
| 145 | } |
| 146 | if (addressCapped(address, session)) return null; |
| 147 | const seat = game.sit(state); |
| 148 | if (!seat) return null; |
| 149 | seat.name = freeName(name, seat.id); |
| 150 | addressSeats.set(address, session); |
| 151 | seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, stickyCache: "" }); |
| 152 | return seat; |
| 153 | } |
| 154 | |
| 155 | /** The socket that actually opened fills in how to reach it and its token. */ |
| 156 | function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void { |
| 157 | seats.set(session, { playerId, token, address, send, stickyCache: "" }); |
| 158 | } |
| 159 | |
| 160 | const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId); |
| 161 | |
| 162 | /** |
| 163 | * A person leaving gives the seat back to a bot, mid-round and all — but only |
| 164 | * if this is still the socket the session is on. A reload opens the new |
| 165 | * socket before the old one's close event arrives; without the token, that |
| 166 | * late close would delete the reconnect's seat entry and hand a live human's |
| 167 | * seat to a bot mid-round. |
| 168 | */ |
| 169 | function release(session: string, token: string): void { |
| 170 | const seat = seats.get(session); |
| 171 | if (!seat || seat.token !== token) return; |
| 172 | seats.delete(session); |
| 173 | if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address); |
| 174 | const player = findPlayer(seat.playerId); |
| 175 | if (player && !stillHeld(seat.playerId)) game.stand(state, player); |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Test-only hygiene: give back whatever this address currently holds. The |
| 180 | * arena is one match shared by the whole test run, over real HTTP, from one |
| 181 | * loopback address — this is how a test that needs that address free gets it |
| 182 | * free, rather than depending on every other test in the file to clean up |
| 183 | * after itself. |
| 184 | */ |
| 185 | function releaseAddress(address: string): void { |
| 186 | const session = addressSeats.get(address); |
| 187 | if (session !== undefined) release(session, seats.get(session)?.token ?? ""); |
| 188 | } |
| 189 | |
| 190 | const seatCount = (): number => seats.size; |
| 191 | |
| 192 | // ------------------------------------------------------------ spectators |
| 193 | |
| 194 | // A connection that never carried a session the page minted gets no side and |
| 195 | // no seat: only the public state everyone already sees on the page itself. |
| 196 | const spectators = new Map<string, { send: (data: string) => void; why: Why }>(); |
| 197 | |
| 198 | const spectatorSnapshot = (why: Why = "unsaid"): string => |
| 199 | JSON.stringify({ t: "s", spectator: true, why, ...game.spectatorSnapshot(state, why) }); |
| 200 | |
| 201 | function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void { |
| 202 | spectators.set(token, { send, why }); |
| 203 | send(spectatorSnapshot(why)); |
| 204 | } |
| 205 | |
| 206 | function unwatch(token: string): void { |
| 207 | spectators.delete(token); |
| 208 | } |
| 209 | |
| 210 | // ----------------------------------------------------------- the commands |
| 211 | |
| 212 | function command(playerId: string, message: Command): void { |
| 213 | const player = findPlayer(playerId); |
| 214 | if (!player || player.bot) return; |
| 215 | game.command(state, player, message); |
| 216 | } |
| 217 | |
| 218 | // ----------------------------------------------------------- the snapshot |
| 219 | |
| 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") }; |
| 225 | const players = game.players(state); |
| 226 | const seen = players.filter((p) => game.visible(state, me, p)); |
| 227 | return { t: "s", ...game.snapshot(state, me, seen) }; |
| 228 | } |
| 229 | |
| 230 | /** The full snapshot for one viewer, as one string. */ |
| 231 | const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId)); |
| 232 | |
| 233 | /** |
| 234 | * The snapshot a seat is actually sent on this tick: everything, every time, |
| 235 | * except the sticky field, which goes out only when it differs from what |
| 236 | * this seat last received. |
| 237 | */ |
| 238 | function snapshotForSeat(seat: Seat): string { |
| 239 | const full = snapshotFor(seat.playerId); |
| 240 | if (game.sticky !== null) { |
| 241 | const part = JSON.stringify(full[game.sticky]); |
| 242 | if (part === seat.stickyCache) delete full[game.sticky]; |
| 243 | else seat.stickyCache = part; |
| 244 | } |
| 245 | return JSON.stringify(full); |
| 246 | } |
| 247 | |
| 248 | // --------------------------------------------------------------- the loop |
| 249 | |
| 250 | /** |
| 251 | * Nobody watching, nothing happens. |
| 252 | * |
| 253 | * An arena that ticks around the clock plays out rounds for an empty room — |
| 254 | * burning a share of a small box's CPU and filling the record with matches |
| 255 | * no one saw. The world freezes when the last connection goes and carries on |
| 256 | * from exactly where it stopped when someone arrives, so a visitor never |
| 257 | * finds a score that ran up while they were away. |
| 258 | * |
| 259 | * A spectator counts as somebody: watching is watching. |
| 260 | */ |
| 261 | const attended = (): boolean => seats.size > 0 || spectators.size > 0; |
| 262 | |
| 263 | /** How many connections the arena is playing for: seats plus watchers. */ |
| 264 | const attendance = (): number => seats.size + spectators.size; |
| 265 | |
| 266 | const cadence = game.cadence ?? broadcastEvery; |
| 267 | |
| 268 | function step(): void { |
| 269 | game.step(state, TICK); |
| 270 | ticks += 1; |
| 271 | if (ticks % cadence(seats.size) !== 0) return; |
| 272 | for (const seat of seats.values()) { |
| 273 | try { |
| 274 | seat.send(snapshotForSeat(seat)); |
| 275 | } catch { |
| 276 | // A socket that will not take a frame is closed already; the close |
| 277 | // handler gives the seat back. |
| 278 | } |
| 279 | } |
| 280 | for (const spectator of spectators.values()) { |
| 281 | try { |
| 282 | spectator.send(spectatorSnapshot(spectator.why)); |
| 283 | } catch { |
| 284 | // Same story: the close handler drops it from the set. |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | let timer: ReturnType<typeof setInterval> | null = null; |
| 290 | |
| 291 | /** |
| 292 | * One beat of real time. The gate lives here rather than inside `step` so the |
| 293 | * tests can still drive the simulation directly with `advance` — a test is an |
| 294 | * attendance of its own, and making it prove otherwise would only teach the |
| 295 | * suite to work around the rule. |
| 296 | */ |
| 297 | function beat(): void { |
| 298 | if (attended()) step(); |
| 299 | } |
| 300 | |
| 301 | function start(): void { |
| 302 | if (timer) return; |
| 303 | timer = setInterval(beat, TICK * 1000); |
| 304 | // The clock must never hold the process open on its own. |
| 305 | (timer as unknown as { unref?: () => void }).unref?.(); |
| 306 | } |
| 307 | |
| 308 | function stop(): void { |
| 309 | if (timer) clearInterval(timer); |
| 310 | timer = null; |
| 311 | } |
| 312 | |
| 313 | /** Used by the tests: run the arena forward without waiting for real time. */ |
| 314 | function advance(seconds: number): void { |
| 315 | for (let i = 0; i < Math.round(seconds / TICK); i++) step(); |
| 316 | } |
| 317 | |
| 318 | return { |
| 319 | match, |
| 320 | findPlayer, |
| 321 | addressCapped, |
| 322 | takeSeat, |
| 323 | attach, |
| 324 | release, |
| 325 | releaseAddress, |
| 326 | seatCount, |
| 327 | watch, |
| 328 | unwatch, |
| 329 | spectatorSnapshot, |
| 330 | command, |
| 331 | snapshot, |
| 332 | snapshotForSeat, |
| 333 | attendance, |
| 334 | beat, |
| 335 | start, |
| 336 | stop, |
| 337 | advance, |
| 338 | }; |
| 339 | } |