Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

A match is seeded by the wall clock: make it reproducible from its id #183

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/170-a-match-is-seeded-by-the-wall-clock-make → main 1 commit fork

  • Merged. vesper/170-a-match-is-seeded-by-the-wall-clock-make was merged into main as df1c53d.

Review

Sign in to review this change.
5 files changed +84 −8
README.md +7
@@ -44,6 +44,13 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
4444 not shipped until it passes its own gate: spawns to stand on, two sites to
4545 plant on, a path from each spawn to each site, and no floor that cannot be
4646 walked to. A map costs 2.1 ms to make, which is the press of the Play button.
47 - **A match is its id.** The uuid a match is started under is the seed of the
48 whole of it, not just of the map: which spawn each body takes, which terrorist
49 carries the bomb, what the bots buy, and where every shot lands inside its
50 cone. Two matches begun on one id are the same match tick for tick — that is
51 a test in `src/client/main.test.ts` — and the id is the one the leaderboard
52 already stores, so a round on the board is a round that can be played again.
53 It used to be the wall clock, and nothing about a match could be reproduced.
4754 - **Generated, not licensed.** The map (`de_forge`), the textures, the player and
4855 weapon models and every sound are produced in code at start-up, by four
4956 generic tools in the engine — a grid of cells that meshes and paths, a box mesh
src/client/main.test.ts +51 −1
@@ -6,6 +6,13 @@
66 * nothing is timed by a real clock — the test decides when a frame happens and
77 * how long it took.
88 *
9 * Nothing here is seeded by one either, since #170: a match takes its seed from
10 * the id the page hands out, and this page hands out `…-000000000001` and
11 * counts. So every run of this file plays the same world — the same bots on the
12 * same spawns — and the assertions that read where a body stands (the camera in
13 * the spectating test below, and whose eye it is exactly on) are a fixed fact
14 * rather than a die rolled on each check.
15 *
916 * The first frames are the atlas: `boot` paints a layer per frame and builds
1017 * the renderer on the last of them, so a test that wants a match steps those
1118 * frames first — `painted` below — and two tests are about that window itself.
@@ -20,11 +27,14 @@ import { expect, test } from "bun:test";
2027 import { nameOf, seedFrom } from "./generate.ts";
2128 import type { Level } from "./level.ts";
2229 import { boot, gameMeshes, type Frame } from "./main.ts";
30 import { DE_FORGE } from "./map.ts";
31 import { SHORT } from "./match.ts";
2332 import { LAYERS, materialTextures } from "./materials.ts";
33 import { snapshot, withoutEntropy } from "./replay.ts";
2434 import { WEAPONS } from "./weapons.ts";
2535 import { parseResult } from "../result.ts";
2636 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
27 import type { World } from "./world.ts";
37 import { createWorld, type World } from "./world.ts";
2838
2939 /** Every frame booted here, so a stage can be asked what world it is running. */
3040 const running = new WeakMap<FakeStage, Frame>();
@@ -597,3 +607,43 @@ test("the map field chooses the map, and a generated one costs one mesh", () =>
597607 // The geometry went back to de_forge's with it: two swaps, both of the level.
598608 expect(stage.replaced.map((r) => r.name)).toEqual(["level", "level"]);
599609 });
610
611 /**
612 * A match played from one id: the page hands that id out instead of a fresh
613 * one, Play is pressed, and `frames` frames are stepped. The id is the only
614 * thing that differs between two calls, so two worlds that disagree disagree
615 * because of it — and `withoutEntropy` is what says so, because a frame that
616 * reached past the seed for `Math.random` or a clock would throw here rather
617 * than quietly make the comparison below a coin toss.
618 */
619 function fromId(id: string, frames: number): World {
620 return withoutEntropy(() => {
621 const stage = painted(start(fakeStage()));
622 stage.host.randomUUID = () => id;
623 stage.by("play").fire("click");
624 for (let frame = 0; frame < frames; frame++) stage.step(FRAME);
625 const world = worldOf(stage);
626 if (world === null) throw new Error(`the id ${id} started no match`);
627 return world;
628 });
629 }
630
631 test("a match is its id: the same id is the same match, and another id is another", () => {
632 const ID = "3d9b7c1e-0000-4000-8000-5a2f6b4c8d11";
633 // The world the page started is the world `createWorld` makes from this id —
634 // not one the wall clock picked. Same roster out of the same draws, same bomb
635 // in the same hands, and every number `snapshot` hashes identical.
636 const booted = fromId(ID, 0);
637 const mirror = createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(ID));
638 expect(booted.players.map((p) => p.name)).toEqual(mirror.players.map((p) => p.name));
639 expect(booted.players.find((p) => p.hasBomb)?.name).toBe(mirror.players.find((p) => p.hasBomb)?.name);
640 expect(snapshot(booted)).toBe(snapshot(mirror));
641
642 // And it stays that match as it is played: two seconds of frames from one id
643 // agree tick for tick, and one character of a different id is a different
644 // match — different spawns, different bots, a different round.
645 const played = (id: string): string => snapshot(fromId(id, 60));
646 const other = `${ID.slice(0, -1)}2`;
647 expect(played(ID)).toBe(played(ID));
648 expect(played(ID)).not.toBe(played(other));
649 });
src/client/main.ts +12 −2
@@ -422,15 +422,25 @@ export function boot(host: Host): Frame {
422422 const team = sideField.value === "t" ? "t" : "ct";
423423 const name = nameField.value.trim().slice(0, 24) || "you";
424424 matchId = host.randomUUID();
425 /**
426 * The whole of the match, as a number. It used to buy only the map, and the
427 * world took the wall clock behind it, so the id the leaderboard stores said
428 * where the round was played and nothing about what happened in it. One seed
429 * now, from the one id: the same id is the same map, the same bots on the
430 * same spawns, the same bomb carrier and the same spray — the map generator
431 * and the world's rng are two streams from the same number, drawing on
432 * different things.
433 */
434 const seed = seedFrom(matchId);
425435 // A map a person chose: de_forge, or one carved out of this match's id. An
426436 // unknown value in the field is de_forge, like an unknown anything here.
427 const level = mapField.value === "generated" ? generateLevel(seedFrom(matchId)) : DE_FORGE;
437 const level = mapField.value === "generated" ? generateLevel(seed) : DE_FORGE;
428438 if (level !== shown) {
429439 renderer.replace("level", meshFor("level", level));
430440 shown = level;
431441 }
432442 onMap.textContent = `Playing ${level.name}.`;
433 world = createWorld(level, rules, name, team, 5);
443 world = createWorld(level, rules, name, team, 5, seed);
434444 posted = -1;
435445 loop.reset();
436446 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
src/client/world.ts +12 −3
@@ -199,14 +199,23 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
199199 * A world on a level. The level comes first because it is the ground everything
200200 * else stands on: hand it de_forge and you get the game, hand it a box room and
201201 * you get the same game in a box room.
202 *
203 * The seed is required and has no default. It used to fall back to the wall
204 * clock, which meant the game — the one caller that took the default — played a
205 * different match every time and no match could be reproduced from anything a
206 * player or the leaderboard holds. Every number a round draws comes out of this
207 * one: which spawn each body takes, who carries the bomb, what the bots buy and
208 * where every shot lands inside its cone. The game derives it from the match id
209 * (`main.ts`), the arena from its episode (`rl/arena.ts`), and a test writes one
210 * down — so a world is a value of its arguments and nothing else.
202211 */
203212 export function createWorld(
204213 level: Level,
205214 ruleSet: rules.Rules,
206215 playerName: string,
207 playerTeam: Team = "ct",
208 teamSize = 5,
209 seed = (Date.now() & 0xffff) >>> 0,
216 playerTeam: Team,
217 teamSize: number,
218 seed: number,
210219 ): World {
211220 const match = rules.newMatch(ruleSet, playerTeam);
212221 const players: Player[] = [];
static/app.c520f46081.js → static/app.4b18a0517b.js renamed +2 −2

Diff not shown — 177,756 bytes of diff (limit 100,000). +2 −2

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs