fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
The match only runs while somebody is there
The arena ticked around the clock, so bots played out whole matches for an empty room: a share of a small box's CPU spent overnight, and a visitor arriving to a score that ran up without them. One beat of real time now happens only while at least one connection is open — a seat or a watcher, because watching is watching — and the world freezes otherwise, resuming exactly where it stopped. The match clock is state (state.now advances inside tick, not from the wall clock), so a frozen world is consistent on waking: cooldowns, fuses and the kill feed all pick up where they were rather than jumping. The gate sits on the clock rather than inside step(), so the tests can still drive the simulation with advance() — a test is an attendance of its own, and putting the rule inside step would only teach the suite to work around it. attendance() is exported and tested: seats plus watchers, nothing else.
3 files
changed +70
−1
README.md +8
| @@ -116,3 +116,11 @@ maths, `scene.js` the first-person view, `topdown.js` the minimap, | ||
| 116 | 116 | `gun.js`, `beep.js`, `steer.js`, `roster.js` and `prompt.js` the rest; the server bundles |
| 117 | 117 | them into one content-named file at start-up. The rules are pure functions and the tests play whole matches |
| 118 | 118 | headlessly. |
| 119 | ||
| 120 | ## It only runs while somebody is there | |
| 121 | ||
| 122 | The match is not a daemon playing to an empty room. One beat of the clock | |
| 123 | happens per tick **while at least one connection is open** — a seat or a | |
| 124 | watcher — and freezes otherwise, resuming exactly where it stopped. So the | |
| 125 | score you find is the score you left, nothing burns CPU on the box overnight, | |
| 126 | and no round is ever decided with nobody watching it. |
src/arena.ts +27 −1
| @@ -352,6 +352,22 @@ function readFalls(): void { | ||
| 352 | 352 | |
| 353 | 353 | // ----------------------------------------------------------------- the loop |
| 354 | 354 | |
| 355 | /** | |
| 356 | * Nobody watching, nothing happens. | |
| 357 | * | |
| 358 | * The arena used to tick around the clock, so bots played out rounds for an | |
| 359 | * empty room — burning a share of a small box's CPU and filling the record | |
| 360 | * with matches no one saw. The world now freezes when the last connection | |
| 361 | * goes and carries on from exactly where it stopped when someone arrives, so | |
| 362 | * a visitor never finds a score that ran up while they were away. | |
| 363 | * | |
| 364 | * A spectator counts as somebody: watching is watching. | |
| 365 | */ | |
| 366 | const attended = (): boolean => seats.size > 0 || spectators.size > 0; | |
| 367 | ||
| 368 | /** How many connections the arena is playing for: seats plus watchers. */ | |
| 369 | export const attendance = (): number => seats.size + spectators.size; | |
| 370 | ||
| 355 | 371 | function step(): void { |
| 356 | 372 | runBots(state, TICK); |
| 357 | 373 | tick(state, TICK); |
| @@ -382,9 +398,19 @@ function step(): void { | ||
| 382 | 398 | |
| 383 | 399 | let timer: ReturnType<typeof setInterval> | null = null; |
| 384 | 400 | |
| 401 | /** | |
| 402 | * One beat of real time. The gate lives here rather than inside `step` so the | |
| 403 | * tests can still drive the simulation directly with `advance` — a test is an | |
| 404 | * attendance of its own, and making it prove otherwise would only teach the | |
| 405 | * suite to work around the rule. | |
| 406 | */ | |
| 407 | export function beat(): void { | |
| 408 | if (attended()) step(); | |
| 409 | } | |
| 410 | ||
| 385 | 411 | export function startArena(): void { |
| 386 | 412 | if (timer) return; |
| 387 | timer = setInterval(step, TICK * 1000); | |
| 413 | timer = setInterval(beat, TICK * 1000); | |
| 388 | 414 | // The clock must never hold the process open on its own. |
| 389 | 415 | (timer as unknown as { unref?: () => void }).unref?.(); |
| 390 | 416 | } |
src/server.test.ts +35
| @@ -301,3 +301,38 @@ test("two people who pick the same name do not end up with the same name", () => | ||
| 301 | 301 | const names = match().players.map((p) => p.name); |
| 302 | 302 | expect(new Set(names).size).toBe(names.length); |
| 303 | 303 | }); |
| 304 | ||
| 305 | test("an empty room freezes the world instead of playing on", async () => { | |
| 306 | const { match, beat, takeSeat, attach, release } = await import("./arena.ts"); | |
| 307 | // Whatever else this file left attached, the rule is about the gate itself: | |
| 308 | // with nobody there a beat of real time changes nothing. | |
| 309 | const { attendance } = await import("./arena.ts"); | |
| 310 | const start = attendance(); | |
| 311 | const quiet = { now: match().now, round: match().round }; | |
| 312 | if (start === 0) { | |
| 313 | for (let i = 0; i < 450; i += 1) beat(); | |
| 314 | expect({ now: match().now, round: match().round }).toEqual(quiet); | |
| 315 | } | |
| 316 | ||
| 317 | // Somebody arrives and time starts again from where it stopped. | |
| 318 | const seat = takeSeat("198.51.100.77", "empty-room-session", "Guest"); | |
| 319 | expect(seat).not.toBeNull(); | |
| 320 | attach("empty-room-session", seat!.id, "198.51.100.77", "tok", () => {}); | |
| 321 | const before = match().now; | |
| 322 | for (let i = 0; i < 30; i += 1) beat(); | |
| 323 | expect(match().now).toBeGreaterThan(before); | |
| 324 | release("empty-room-session", "tok"); | |
| 325 | }); | |
| 326 | ||
| 327 | test("attendance counts seats and watchers, and nothing else", async () => { | |
| 328 | const { attendance, takeSeat, attach, release, watch, unwatch } = await import("./arena.ts"); | |
| 329 | const base = attendance(); | |
| 330 | const seat = takeSeat("198.51.100.78", "counted-session", "Guest"); | |
| 331 | attach("counted-session", seat!.id, "198.51.100.78", "tok2", () => {}); | |
| 332 | expect(attendance()).toBe(base + 1); | |
| 333 | watch("watcher-token", () => {}); | |
| 334 | expect(attendance()).toBe(base + 2); | |
| 335 | unwatch("watcher-token"); | |
| 336 | release("counted-session", "tok2"); | |
| 337 | expect(attendance()).toBe(base); | |
| 338 | }); |