pike/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.
benjivers committed 57987e4 Browse files
3 files changed +70 −1
README.md +8
@@ -116,3 +116,11 @@ maths, `scene.js` the first-person view, `topdown.js` the minimap,
116116 `gun.js`, `beep.js`, `steer.js`, `roster.js` and `prompt.js` the rest; the server bundles
117117 them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
118118 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 {
352352
353353 // ----------------------------------------------------------------- the loop
354354
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
355371 function step(): void {
356372 runBots(state, TICK);
357373 tick(state, TICK);
@@ -382,9 +398,19 @@ function step(): void {
382398
383399 let timer: ReturnType<typeof setInterval> | null = null;
384400
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
385411 export function startArena(): void {
386412 if (timer) return;
387 timer = setInterval(step, TICK * 1000);
413 timer = setInterval(beat, TICK * 1000);
388414 // The clock must never hold the process open on its own.
389415 (timer as unknown as { unref?: () => void }).unref?.();
390416 }
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", () =>
301301 const names = match().players.map((p) => p.name);
302302 expect(new Set(names).size).toBe(names.length);
303303 });
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 });
↑↓ navigate ↵ open esc close

Keyboard shortcuts