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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/170-a-match-is-seeded-by-the-wall-clock-make → main 1 commit fork
5 files
changed +84
−8
README.md +7
| @@ -44,6 +44,13 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 44 | 44 | not shipped until it passes its own gate: spawns to stand on, two sites to |
| 45 | 45 | plant on, a path from each spawn to each site, and no floor that cannot be |
| 46 | 46 | 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. | |
| 47 | 54 | - **Generated, not licensed.** The map (`de_forge`), the textures, the player and |
| 48 | 55 | weapon models and every sound are produced in code at start-up, by four |
| 49 | 56 | 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 @@ | ||
| 6 | 6 | * nothing is timed by a real clock — the test decides when a frame happens and |
| 7 | 7 | * how long it took. |
| 8 | 8 | * |
| 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 | * | |
| 9 | 16 | * The first frames are the atlas: `boot` paints a layer per frame and builds |
| 10 | 17 | * the renderer on the last of them, so a test that wants a match steps those |
| 11 | 18 | * frames first — `painted` below — and two tests are about that window itself. |
| @@ -20,11 +27,14 @@ import { expect, test } from "bun:test"; | ||
| 20 | 27 | import { nameOf, seedFrom } from "./generate.ts"; |
| 21 | 28 | import type { Level } from "./level.ts"; |
| 22 | 29 | import { boot, gameMeshes, type Frame } from "./main.ts"; |
| 30 | import { DE_FORGE } from "./map.ts"; | |
| 31 | import { SHORT } from "./match.ts"; | |
| 23 | 32 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 33 | import { snapshot, withoutEntropy } from "./replay.ts"; | |
| 24 | 34 | import { WEAPONS } from "./weapons.ts"; |
| 25 | 35 | import { parseResult } from "../result.ts"; |
| 26 | 36 | import { fakeStage, type FakeStage } from "./stage.test-helper.ts"; |
| 27 | import type { World } from "./world.ts"; | |
| 37 | import { createWorld, type World } from "./world.ts"; | |
| 28 | 38 | |
| 29 | 39 | /** Every frame booted here, so a stage can be asked what world it is running. */ |
| 30 | 40 | const running = new WeakMap<FakeStage, Frame>(); |
| @@ -597,3 +607,43 @@ test("the map field chooses the map, and a generated one costs one mesh", () => | ||
| 597 | 607 | // The geometry went back to de_forge's with it: two swaps, both of the level. |
| 598 | 608 | expect(stage.replaced.map((r) => r.name)).toEqual(["level", "level"]); |
| 599 | 609 | }); |
| 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 { | ||
| 422 | 422 | const team = sideField.value === "t" ? "t" : "ct"; |
| 423 | 423 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| 424 | 424 | 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); | |
| 425 | 435 | // A map a person chose: de_forge, or one carved out of this match's id. An |
| 426 | 436 | // 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; | |
| 428 | 438 | if (level !== shown) { |
| 429 | 439 | renderer.replace("level", meshFor("level", level)); |
| 430 | 440 | shown = level; |
| 431 | 441 | } |
| 432 | 442 | onMap.textContent = `Playing ${level.name}.`; |
| 433 | world = createWorld(level, rules, name, team, 5); | |
| 443 | world = createWorld(level, rules, name, team, 5, seed); | |
| 434 | 444 | posted = -1; |
| 435 | 445 | loop.reset(); |
| 436 | 446 | 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 | ||
| 199 | 199 | * A world on a level. The level comes first because it is the ground everything |
| 200 | 200 | * else stands on: hand it de_forge and you get the game, hand it a box room and |
| 201 | 201 | * 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. | |
| 202 | 211 | */ |
| 203 | 212 | export function createWorld( |
| 204 | 213 | level: Level, |
| 205 | 214 | ruleSet: rules.Rules, |
| 206 | 215 | playerName: string, |
| 207 | playerTeam: Team = "ct", | |
| 208 | teamSize = 5, | |
| 209 | seed = (Date.now() & 0xffff) >>> 0, | |
| 216 | playerTeam: Team, | |
| 217 | teamSize: number, | |
| 218 | seed: number, | |
| 210 | 219 | ): World { |
| 211 | 220 | const match = rules.newMatch(ruleSet, playerTeam); |
| 212 | 221 | 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
new above
offline