create-siltrun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/index.mjs +98 -0
  2. package/package.json +18 -0
  3. package/templates/mass-grid/AGENTS.md +106 -0
  4. package/templates/mass-grid/README.md +59 -0
  5. package/templates/mass-grid/_gitignore +4 -0
  6. package/templates/mass-grid/index.html +22 -0
  7. package/templates/mass-grid/package.json +31 -0
  8. package/templates/mass-grid/room.test.ts +214 -0
  9. package/templates/mass-grid/room.ts +530 -0
  10. package/templates/mass-grid/skills/authoritative-tick.md +116 -0
  11. package/templates/mass-grid/skills/compact-snapshots.md +110 -0
  12. package/templates/mass-grid/skills/demo-room-lifecycle.md +145 -0
  13. package/templates/mass-grid/skills/genre-lane-mapping.md +88 -0
  14. package/templates/mass-grid/skills/territory-capture.md +115 -0
  15. package/templates/mass-grid/src/Game.tsx +174 -0
  16. package/templates/mass-grid/src/main.tsx +9 -0
  17. package/templates/mass-grid/tsconfig.json +15 -0
  18. package/templates/mass-grid/vite.config.ts +9 -0
  19. package/templates/minimal/README.md +39 -0
  20. package/templates/minimal/_gitignore +4 -0
  21. package/templates/minimal/index.html +15 -0
  22. package/templates/minimal/package.json +30 -0
  23. package/templates/minimal/room.ts +30 -0
  24. package/templates/minimal/src/Game.tsx +71 -0
  25. package/templates/minimal/src/main.tsx +9 -0
  26. package/templates/minimal/tsconfig.json +15 -0
  27. package/templates/minimal/vite.config.ts +7 -0
  28. package/templates/tower-defense/AGENTS.md +132 -0
  29. package/templates/tower-defense/README.md +53 -0
  30. package/templates/tower-defense/_gitignore +4 -0
  31. package/templates/tower-defense/index.html +15 -0
  32. package/templates/tower-defense/package.json +31 -0
  33. package/templates/tower-defense/room.test.ts +159 -0
  34. package/templates/tower-defense/room.ts +321 -0
  35. package/templates/tower-defense/skills/authoritative-tick.md +96 -0
  36. package/templates/tower-defense/skills/demo-room-lifecycle.md +145 -0
  37. package/templates/tower-defense/skills/state-budget.md +79 -0
  38. package/templates/tower-defense/skills/two-lanes.md +66 -0
  39. package/templates/tower-defense/skills/waves-and-timing.md +79 -0
  40. package/templates/tower-defense/src/Game.tsx +227 -0
  41. package/templates/tower-defense/src/main.tsx +9 -0
  42. package/templates/tower-defense/tsconfig.json +15 -0
  43. package/templates/tower-defense/vite.config.ts +7 -0
  44. package/templates/turn-based-grid/AGENTS.md +183 -0
  45. package/templates/turn-based-grid/README.md +72 -0
  46. package/templates/turn-based-grid/_gitignore +4 -0
  47. package/templates/turn-based-grid/index.html +15 -0
  48. package/templates/turn-based-grid/package.json +30 -0
  49. package/templates/turn-based-grid/room.ts +309 -0
  50. package/templates/turn-based-grid/skills/play.md +97 -0
  51. package/templates/turn-based-grid/src/App.tsx +141 -0
  52. package/templates/turn-based-grid/src/GraphicsView.tsx +148 -0
  53. package/templates/turn-based-grid/src/brains.ts +112 -0
  54. package/templates/turn-based-grid/src/main.tsx +8 -0
  55. package/templates/turn-based-grid/src/stubAgent.ts +82 -0
  56. package/templates/turn-based-grid/src/substrate/ascii.ts +87 -0
  57. package/templates/turn-based-grid/src/substrate/map.ts +104 -0
  58. package/templates/turn-based-grid/src/substrate/types.ts +59 -0
  59. package/templates/turn-based-grid/src/view.ts +94 -0
  60. package/templates/turn-based-grid/tsconfig.json +15 -0
  61. package/templates/turn-based-grid/vite.config.ts +7 -0
package/index.mjs ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // create-siltrun — `npm create siltrun <dir>` scaffolds a runnable, editable Silt project:
3
+ // an authoritative room contract (room.ts) + a React client (vite), wired together
4
+ // by the siltrun CLI. Plain Node, zero dependencies — npm initializers run under Node.
5
+
6
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
7
+ import { basename, join, resolve, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+
12
+ function usage(exit = 1) {
13
+ console.log(`create-siltrun — scaffold a Silt multiplayer project
14
+
15
+ Usage
16
+ npm create siltrun <project-dir> [-- --template <name>]
17
+ npx create-siltrun <project-dir> [--template <name>]
18
+
19
+ Options
20
+ --template <name> project template (default: minimal)
21
+
22
+ Then
23
+ cd <project-dir> && npm install && npm run dev`);
24
+ process.exit(exit);
25
+ }
26
+
27
+ // ---- args ----
28
+ const argv = process.argv.slice(2);
29
+ let targetArg = null;
30
+ let template = "minimal";
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === "--help" || a === "-h") usage(0);
34
+ else if (a === "--template") {
35
+ template = argv[++i];
36
+ if (!template) usage();
37
+ } else if (a.startsWith("--template=")) template = a.slice("--template=".length);
38
+ else if (!a.startsWith("-") && targetArg === null) targetArg = a;
39
+ else usage();
40
+ }
41
+ if (!targetArg) usage();
42
+
43
+ const templateDir = join(here, "templates", template);
44
+ if (!existsSync(templateDir)) {
45
+ const available = readdirSync(join(here, "templates")).join(", ");
46
+ console.error(`create-siltrun: unknown template "${template}" (available: ${available})`);
47
+ process.exit(1);
48
+ }
49
+
50
+ const target = resolve(process.cwd(), targetArg);
51
+ if (existsSync(target)) {
52
+ // If the target is a FILE (or anything non-directory), readdirSync would throw
53
+ // a raw ENOTDIR stack (`npx create-siltrun notes.txt`). Stat first and give the
54
+ // same friendly "already exists" class of error instead.
55
+ if (!statSync(target).isDirectory()) {
56
+ console.error(`create-siltrun: ${target} already exists (it's a file, not a directory)`);
57
+ process.exit(1);
58
+ }
59
+ if (readdirSync(target).length > 0) {
60
+ console.error(`create-siltrun: ${target} already exists and is not empty`);
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ // Package names must be npm-safe; derive from the directory basename.
66
+ const rawName = basename(target);
67
+ const pkgName = rawName
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9._-]+/g, "-")
70
+ .replace(/^[-._]+|[-._]+$/g, "") || "my-silt-game";
71
+
72
+ // ---- scaffold ----
73
+ mkdirSync(target, { recursive: true });
74
+ cpSync(templateDir, target, { recursive: true });
75
+
76
+ // npm strips .gitignore from published tarballs — templates ship it as _gitignore.
77
+ const gi = join(target, "_gitignore");
78
+ if (existsSync(gi)) renameSync(gi, join(target, ".gitignore"));
79
+
80
+ // Stamp the project name.
81
+ const pkgPath = join(target, "package.json");
82
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
83
+ pkg.name = pkgName;
84
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
85
+
86
+ const rel = targetArg;
87
+ console.log(`
88
+ Scaffolded ${pkgName} (template: ${template})
89
+
90
+ cd ${rel}
91
+ npm install
92
+ npm run dev
93
+
94
+ npm run dev boots both halves: your authoritative room (siltrun dev room.ts,
95
+ room-info on http://localhost:4000) and the vite client (http://localhost:5173).
96
+ Open http://localhost:5173 in two windows — that's your multiplayer room.
97
+ Edit room.ts and watch both reload.
98
+ `);
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "create-siltrun",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a Silt multiplayer project — `npm create siltrun my-game`.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-siltrun": "index.mjs"
8
+ },
9
+ "files": ["index.mjs", "templates"],
10
+ "repository": "https://github.com/digitalpine/silt",
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
14
+ "scripts": {
15
+ "test": "node --test",
16
+ "typecheck": "true"
17
+ }
18
+ }
@@ -0,0 +1,106 @@
1
+ # AGENTS.md — mass-grid territory capture on Silt
2
+
3
+ You (a coding agent) are working in a **Silt** project: an authoritative multiplayer room.
4
+ This file orients you so you can extend the game immediately. Read it fully once, then read
5
+ the one skill in `skills/` that matches what you're about to change. The skills are the deep
6
+ knowledge; this file is the map.
7
+
8
+ ## What this project is
9
+
10
+ A **mass-grid territory-capture** starter — the Paper.io / Splix.io genre. Players share one
11
+ always-warm room: they drop in, ready up, and race to paint the grid. You leave your territory
12
+ to draw a **trail**, loop back to your own land to **claim** everything you enclosed, and you
13
+ **cut** a rival by crossing their exposed trail. Most territory when the round timer sounds
14
+ wins; a new round rolls immediately. It is a **teaching template**, not a finished game:
15
+ minimal genre mechanics chosen so each one demonstrates a Silt concept. Build your real game by
16
+ extending it.
17
+
18
+ Two parts, deliberately separated in `room.ts`:
19
+
20
+ 1. **The demo-room lifecycle shell** — the `══ DEMO-ROOM LIFECYCLE SHELL ══` block. Genre-
21
+ agnostic: the always-warm, tap-and-play room model (drop-in join, take over an abandoned
22
+ player, ready-up → rolling rounds → auto-restart, no lobby). You usually **don't edit this**
23
+ — you wire your game to its three signals. Spec: `skills/demo-room-lifecycle.md`.
24
+ 2. **The territory-capture genre** — everything below the shell. This is what you replace or
25
+ extend to change the game.
26
+
27
+ ## The one mental model that matters
28
+
29
+ **The server owns truth. Clients submit intent. The room decides.**
30
+
31
+ ```
32
+ browser ──send(intent)──▶ Go relay ──inputs──▶ room.ts tick() ──full state──▶ every browser
33
+ (WebTransport) (Bun, 60Hz, deterministic)
34
+ ```
35
+
36
+ `room.ts` default-exports `{ init, tick }`. It runs **server-side at 60Hz** inside a
37
+ deterministic realm. Each tick it receives the canonical state + a batch of inputs (joins,
38
+ leaves, and peers' intent), mutates state, and returns it. The server broadcasts the **entire**
39
+ returned state to every client every tick. Clients never own state — they render what the
40
+ server sends and send intent back. This is why cheating is hard: a client asking to turn is
41
+ *intent*; `tick` decides truth. Full model → `skills/authoritative-tick.md`.
42
+
43
+ ## The two ideas this genre is built to teach
44
+
45
+ - **The state IS the wire — so engineer it compact.** The whole `State` is JSON-marshaled into
46
+ ONE ~1200-byte datagram every tick. This genre wants a *crowd* on one board, so it packs
47
+ hard: the grid rides as a run-length string, players ride as a flat slot-indexed array, trails
48
+ ride as turn-point polylines. `tick()` unpacks these into readable objects, mutates, and
49
+ re-packs — exactly the way it treats the grid. **This is the single most important thing to
50
+ understand before you touch `State`.** → `skills/compact-snapshots.md`.
51
+ - **The genre picks the lane.** Moves ride the *reliable* lane, not the presence datagram lane,
52
+ because a slow discrete grid game wants ordered, no-drop turns (a dropped turn is a death).
53
+ **Ratified amendment (DIG-763): a room that opts into client-side prediction moves its
54
+ steering to the INPUT lane as held intent** — only input-envelope seqs are acked, so only
55
+ they can reconcile. → `skills/genre-lane-mapping.md` (incl. the prediction amendment).
56
+
57
+ ## Code map
58
+
59
+ | File | What it is |
60
+ |---|---|
61
+ | `room.ts` | The authoritative contract. Shell block (copy-verbatim) + territory-capture genre. **The game lives here.** |
62
+ | `room.test.ts` | Deterministic contract tests — `tick` is a pure function, so you test it with no browser. Your safety net, including the datagram-budget guard. |
63
+ | `src/Game.tsx` | The React client. `useRoom(url, { id })` → `{ state, send }`. Renders on `@siltrun/stage`: `createStage` + `cam.fitRect(board)` + `dpad` (keys AND swipe — phone-steerable by default). Decodes the compact state with the SAME codec the server uses (imported from `room.ts`) and redraws it each frame; sends turns on the reliable lane. |
64
+ | `src/main.tsx`, `index.html` | Standard vite entry. |
65
+ | `skills/` | The deep concept docs. Read the one for your change. |
66
+
67
+ ## Run it + verify your change
68
+
69
+ ```bash
70
+ npm install
71
+ npm run dev # boots BOTH halves: `siltrun dev room.ts` (room-info :4000) + vite (:5173)
72
+ ```
73
+
74
+ Open `http://localhost:5173` in **two windows** — that's your two-player room. Ready up in both
75
+ to start. Leave your patch of colour, loop back, and watch the enclosed area fill.
76
+
77
+ **Verify by observation, not by reading your own diff:**
78
+ - `bun test room.test.ts` — the fast loop and your **primary** verification. The contract is a
79
+ pure function; scripted inputs assert truth (membership, phase transitions, claim/collision/
80
+ respawn, and the datagram-size budget). It runs with **no install** because the `@siltrun/*`
81
+ imports are type-only and erased at runtime. When you change mechanics, add/adjust a test and
82
+ keep it green — especially the budget guard if you touch `State`.
83
+ - Note: `npm run typecheck` (`tsc`) will NOT resolve `@siltrun/*` until they're published (see
84
+ the alpha limitation) — it errors on unresolved modules, not your code. `bun test` is the
85
+ real check in the shipped state.
86
+ - Two windows for the live feel. `room.ts` hot-reloads on save (a determinism doctor re-checks
87
+ each reload) — if it rejects your change as non-deterministic, read `skills/authoritative-tick.md`.
88
+
89
+ ## The skills — read the one for your change
90
+
91
+ | Skill | Read it before you… |
92
+ |---|---|
93
+ | `skills/authoritative-tick.md` | change any server logic in `room.ts` (the core model + the determinism rule) |
94
+ | `skills/compact-snapshots.md` | add anything to `State` (the datagram budget + the pack/unpack discipline) — **the load-bearing one for this genre** |
95
+ | `skills/genre-lane-mapping.md` | send anything from the client (which lane, and why this genre chose reliable) |
96
+ | `skills/territory-capture.md` | change the game itself — trails, claiming, collisions, respawn, the round timer |
97
+ | `skills/demo-room-lifecycle.md` | touch seating, phases, or rounds (the shared shell spec + its invariants) |
98
+
99
+ ## ⚠️ Alpha limitation — installing `@siltrun/*`
100
+
101
+ At the time this template ships, the `@siltrun/*` packages are **not yet published to npm**, so
102
+ `npm install` here will fail to resolve them. Until the publish lands, consume Silt from a local
103
+ checkout using the workspace path documented in the silt repo's `docs/QUICKSTART.md` (clone
104
+ silt, point your project's `package.json` deps at the local `packages/*`, `bun install`,
105
+ `bun run dev`). The game code in this template is identical either way — only the dependency
106
+ wiring differs. `bun test room.test.ts` works regardless (type-only imports).
@@ -0,0 +1,59 @@
1
+ # silt · mass-grid territory capture
2
+
3
+ A Paper.io / Splix.io-style **territory-capture** game on [Silt](https://silt.run) — an
4
+ authoritative server contract (`room.ts`) plus a React client (`src/`), one project, one
5
+ language. Players share one always-warm room: drop in, ready up, paint the grid. Leave your
6
+ land to draw a trail, loop back to claim what you enclosed, cut a rival by crossing their trail.
7
+ Most territory when the timer sounds wins; a new round rolls immediately.
8
+
9
+ **Building on this with a coding agent? Point it at [`AGENTS.md`](./AGENTS.md) first** — it's
10
+ the orientation map, and `skills/` holds the deep concept docs.
11
+
12
+ ## Run
13
+
14
+ ```bash
15
+ npm install
16
+ npm run dev
17
+ ```
18
+
19
+ Boots both halves:
20
+ - **room** — `siltrun dev room.ts`: your contract running authoritatively at 60Hz (room-info on
21
+ `http://localhost:4000`, WebTransport on `:4433`). Hot-reloads on save.
22
+ - **web** — vite dev server on [http://localhost:5173](http://localhost:5173).
23
+
24
+ Open `http://localhost:5173` in **two windows** and ready up in both — that's your game. Arrows
25
+ or WASD to steer.
26
+
27
+ > **Alpha note:** the `@siltrun/*` packages aren't published to npm yet, so `npm install` won't
28
+ > resolve them until the publish lands. Until then, consume Silt from a local checkout per the
29
+ > silt repo's `docs/QUICKSTART.md` (the game code is identical; only dependency wiring differs).
30
+ > See [`AGENTS.md`](./AGENTS.md) → *Alpha limitation*.
31
+
32
+ ## Test
33
+
34
+ ```bash
35
+ bun test room.test.ts
36
+ ```
37
+
38
+ `room.ts` is a deterministic pure function, so the whole game is testable with no browser:
39
+ membership, phase transitions, claiming, collisions, respawn, and the **datagram-size budget**
40
+ are all asserted by scripted ticks. This is your fast feedback loop — keep it green as you build,
41
+ especially the budget guard if you touch `State`.
42
+
43
+ ## What's here
44
+
45
+ - `room.ts` — the authoritative contract: a genre-agnostic **demo-room lifecycle shell** (drop-in
46
+ join, take over an abandoned player, ready-up → rolling rounds → auto-restart) plus the
47
+ **territory-capture genre** below it. Replace the genre to build a different game on the same
48
+ shell.
49
+ - `src/Game.tsx` — the React client, rendered on `@siltrun/stage` (PixiJS): `createStage` +
50
+ `cam.fitRect(board)` + `dpad` is the whole ceremony, so the board fits any screen and is
51
+ steerable by swipe AND keys from birth. Decodes the compact state with the same codec the
52
+ server uses and redraws it each frame; sends turns on the reliable lane.
53
+ - `AGENTS.md` + `skills/` — the teaching layer. Read these to extend the game. The load-bearing
54
+ one for this genre is `skills/compact-snapshots.md` — the state is the wire, so you engineer it
55
+ compact.
56
+
57
+ ## Learn more
58
+
59
+ - Quickstart, the two lanes, the determinism realm, and the honest limits: https://silt.run/docs
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ *.log
4
+ .DS_Store
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
+ <meta name="apple-mobile-web-app-capable" content="yes" />
7
+ <meta name="mobile-web-app-capable" content="yes" />
8
+ <style>html, body { overscroll-behavior: none; }</style>
9
+ <title>Silt — mass-grid (territory capture)</title>
10
+ </head>
11
+ <body style="margin: 0; background: #0b0b0d; color: #eae7de; font-family: ui-sans-serif, system-ui, sans-serif;">
12
+ <div id="root"></div>
13
+ <!-- static chrome overlays the stage; the dynamic HUD strip is rendered by src/Game.tsx -->
14
+ <h1 style="position: fixed; top: calc(10px + env(safe-area-inset-top, 0px)); left: 0; right: 0; margin: 0; text-align: center; pointer-events: none; font-size: 13px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #7e837a;">
15
+ Silt · mass-grid
16
+ </h1>
17
+ <p style="position: fixed; bottom: calc(6px + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; margin: 0; text-align: center; pointer-events: none; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #7e837a;">
18
+ swipe / arrows to steer · loop home to claim · cut trails to take out
19
+ </p>
20
+ <script type="module" src="/src/main.tsx"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "my-silt-game",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "concurrently -k -n room,web -c yellow,cyan \"siltrun dev room.ts\" \"vite\"",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "test": "bun test room.test.ts",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@siltrun/stage": "^0.1.0",
15
+ "pixi.js": "^8.19.0",
16
+ "@siltrun/client": "^0.3.0",
17
+ "@siltrun/react": "^0.1.0",
18
+ "react": "^18.3.0",
19
+ "react-dom": "^18.3.0"
20
+ },
21
+ "devDependencies": {
22
+ "@siltrun/room": "^0.1.0",
23
+ "@types/react": "^18.3.0",
24
+ "@types/react-dom": "^18.3.0",
25
+ "@vitejs/plugin-react": "^4.3.0",
26
+ "concurrently": "^9.0.0",
27
+ "siltrun": "^0.1.0",
28
+ "typescript": "^5.6.0",
29
+ "vite": "^5.4.0"
30
+ }
31
+ }
@@ -0,0 +1,214 @@
1
+ // room.test.ts — the contract is a deterministic pure function, so we verify it by driving
2
+ // tick() with scripted inputs and asserting truth. No browser, no network, no install
3
+ // (the @siltrun/room import is type-only and erased).
4
+ // bun test room.test.ts
5
+ import { test, expect } from "bun:test";
6
+ import contract, {
7
+ encodeGrid, decodeGrid, trailCells, claim, countCells, readPlayers, writePlayers,
8
+ W, H, STEP_TICKS, ROUND_TICKS, MAX_TRAIL_TURNS,
9
+ type State, type Player, type Seat, type Cmd,
10
+ } from "./room.ts";
11
+ import type { Ctx, Input } from "@siltrun/room";
12
+
13
+ const MTU_BUDGET = 1150; // design target: worst-case state < this, headroom to the ~1200B datagram
14
+
15
+ // A deterministic Ctx. `random` is seeded + reproducible so spawn placement is testable.
16
+ function makeCtx(tick: number, seed = 1): Ctx {
17
+ let s = seed >>> 0;
18
+ return { tick, dt: 1 / 60, time: tick / 60, random() { s = (s * 1664525 + 1013904223) >>> 0; return s / 2 ** 32; }, emit() {} };
19
+ }
20
+ const join = (id: string): Input<Cmd> => ({ kind: "join", id });
21
+ const leave = (id: string): Input<Cmd> => ({ kind: "leave", id, reason: "left" });
22
+ const ready = (from: string): Input<Cmd> => ({ kind: "event", from, data: { type: "ready" } });
23
+ const turn = (from: string, t: number): Input<Cmd> => ({ kind: "event", from, data: { turn: t } });
24
+
25
+ // Drive to sim-step N (tick N*STEP_TICKS) with the given inputs. The sim only advances on
26
+ // step boundaries, so tests tick on them. Reuses the returned state (host feeds it back).
27
+ function makeRunner() {
28
+ let s: State | undefined;
29
+ let n = 0;
30
+ const step = (inputs: Input<Cmd>[] = []): State => (s = contract.tick(s, inputs, makeCtx(++n * STEP_TICKS)) as State);
31
+ return { step, get: () => s! };
32
+ }
33
+ const seatOf = (s: State, id: string) => s.shell.seats.findIndex((x) => x.id === id);
34
+
35
+ // ── PURE UNITS ────────────────────────────────────────────────────────────────
36
+
37
+ test("RLE codec round-trips an arbitrary grid", () => {
38
+ const g = new Int16Array(W * H);
39
+ for (let i = 0; i < g.length; i++) g[i] = (i * 7) % 5 === 0 ? i % 9 : 0;
40
+ expect(Array.from(decodeGrid(encodeGrid(g), W, H))).toEqual(Array.from(g));
41
+ });
42
+
43
+ test("trailCells expands an L polyline to the exact cells it covers", () => {
44
+ expect(trailCells([0, 0, 2, 0], 2, 2, 5).sort((a, b) => a - b)).toEqual([0, 1, 2, 7, 12]);
45
+ });
46
+
47
+ test("claim captures a fully-enclosed pocket (empty AND enemy cells)", () => {
48
+ const w = 5, h = 5, g = new Int16Array(w * h);
49
+ for (let x = 0; x < w; x++) { g[x] = 1; g[(h - 1) * w + x] = 1; }
50
+ for (let y = 0; y < h; y++) { g[y * w] = 1; g[y * w + w - 1] = 1; }
51
+ g[2 * w + 2] = 2;
52
+ claim(g, w, h, 1, []);
53
+ expect(countCells(g, 1)).toBe(w * h);
54
+ expect(countCells(g, 2)).toBe(0);
55
+ });
56
+
57
+ test("claim does NOT over-claim when the loop has a gap to the outside", () => {
58
+ const w = 5, h = 5, g = new Int16Array(w * h);
59
+ for (let x = 0; x < w; x++) { g[x] = 1; g[(h - 1) * w + x] = 1; }
60
+ for (let y = 0; y < h; y++) { g[y * w] = 1; g[y * w + w - 1] = 1; }
61
+ g[2 * w] = 0; g[2 * w + 1] = 0;
62
+ const before = countCells(g, 1);
63
+ claim(g, w, h, 1, []);
64
+ expect(countCells(g, 1)).toBe(before);
65
+ });
66
+
67
+ // ── LIFECYCLE ─────────────────────────────────────────────────────────────────
68
+
69
+ test("gathering → ready-up (2) → playing: round starts, players spawned", () => {
70
+ const r = makeRunner();
71
+ r.step([join("a"), join("b")]);
72
+ expect(r.get().shell.phase).toBe("gathering");
73
+ r.step([ready("a"), ready("b")]);
74
+ expect(r.get().shell.phase).toBe("playing");
75
+ expect(r.get().shell.round).toBe(1);
76
+ expect(readPlayers(r.get()).filter(Boolean).length).toBe(2);
77
+ });
78
+
79
+ test("a turn event steers the player on the next step", () => {
80
+ const r = makeRunner();
81
+ r.step([join("a"), join("b")]);
82
+ r.step([ready("a"), ready("b")]);
83
+ const seat = seatOf(r.get(), "a");
84
+ const p0 = readPlayers(r.get())[seat]!;
85
+ r.step([turn("a", 2)]); // face down → moves down one cell this step
86
+ const p1 = readPlayers(r.get())[seat]!;
87
+ expect([p1.x, p1.y]).toEqual([p0.x, p0.y + 1]);
88
+ });
89
+
90
+ test("timer horn ends the round; gameover then auto-resets to gathering", () => {
91
+ const r = makeRunner();
92
+ r.step([join("a"), join("b")]);
93
+ r.step([ready("a"), ready("b")]);
94
+ const roundSteps = Math.ceil(ROUND_TICKS / STEP_TICKS);
95
+ for (let i = 0; i < roundSteps + 1; i++) r.step();
96
+ expect(r.get().shell.phase).toBe("gameover");
97
+ expect(r.get().win).toBeGreaterThanOrEqual(0);
98
+ const overSteps = Math.ceil(200 / STEP_TICKS);
99
+ for (let i = 0; i < overSteps + 2; i++) r.step();
100
+ expect(r.get().shell.phase).toBe("gathering");
101
+ expect(readPlayers(r.get()).filter(Boolean).length).toBe(0);
102
+ });
103
+
104
+ test("take-over-abandoned: at a full table a leaver's seat + territory is adopted", () => {
105
+ const r = makeRunner();
106
+ r.step(["a", "b", "c", "d", "e", "f"].map(join)); // fills the 6-seat table
107
+ r.step([ready("a"), ready("b")]);
108
+ const seat = seatOf(r.get(), "a");
109
+ r.step([leave("a")]); // mid-game leave → abandoned, territory kept
110
+ expect(r.get().shell.seats[seat]!.abandoned).toBe(true);
111
+ const kept = countCells(decodeGrid(r.get().g, W, H), seat + 1);
112
+ expect(kept).toBeGreaterThan(0);
113
+ r.step([join("z")]); // full table → takes over the abandoned seat
114
+ expect(r.get().shell.seats[seat]!.id).toBe("z");
115
+ expect(countCells(decodeGrid(r.get().g, W, H), seat + 1)).toBe(kept);
116
+ });
117
+
118
+ // ── GENRE MECHANICS ─────────────────────────────────────────────────────────────
119
+
120
+ test("claim FLOW: a steered loop out and back grows territory (no diagonal trail)", () => {
121
+ const r = makeRunner();
122
+ r.step([join("a"), join("b")]);
123
+ r.step([ready("a"), ready("b")]);
124
+ const seat = seatOf(r.get(), "a");
125
+ const before = countCells(decodeGrid(r.get().g, W, H), seat + 1);
126
+ const DIR = { up: 0, right: 1, down: 2, left: 3 };
127
+ const plan: [keyof typeof DIR, number][] = [["right", 3], ["down", 3], ["left", 5], ["up", 4], ["right", 3]];
128
+ for (const [dir, n] of plan) for (let i = 0; i < n; i++) r.step([turn("a", DIR[dir])]);
129
+ expect(countCells(decodeGrid(r.get().g, W, H), seat + 1)).toBeGreaterThan(before);
130
+ expect(readPlayers(r.get())[seat]!.a).toBe(1);
131
+ });
132
+
133
+ test("collision: stepping onto a rival's trail cuts the TRAIL OWNER (not the cutter)", () => {
134
+ const seats: Seat[] = [
135
+ { id: "a", ready: false, abandoned: false },
136
+ { id: "b", ready: false, abandoned: false },
137
+ ];
138
+ const grid = new Int16Array(W * H);
139
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { grid[(5 + dy) * W + (5 + dx)] = 1; grid[(8 + dy) * W + (8 + dx)] = 2; }
140
+ const a: Player = { x: 10, y: 5, d: 1, nd: 1, a: 1, t: [6, 5], rs: 0 }; // trail cells (6..9,5)
141
+ const b: Player = { x: 8, y: 6, d: 0, nd: 0, a: 1, t: [], rs: 0 }; // steps up onto (8,5)
142
+ const state: State = { shell: { phase: "playing", seats, round: 1, phaseSince: 0, over: false }, w: W, h: H, g: encodeGrid(grid), pf: [], pt: [], win: -1 };
143
+ writePlayers(state, [a, b]);
144
+ const next = contract.tick(state, [], makeCtx(STEP_TICKS)) as State;
145
+ const [pa, pb] = readPlayers(next);
146
+ expect(pa!.a).toBe(0);
147
+ expect(countCells(decodeGrid(next.g, W, H), 1)).toBe(0);
148
+ expect(pb!.a).toBe(1);
149
+ });
150
+
151
+ test("respawn: a player that runs into a wall dies, then respawns with fresh territory", () => {
152
+ const r = makeRunner();
153
+ r.step([join("a"), join("b")]);
154
+ r.step([ready("a"), ready("b")]);
155
+ const seat = seatOf(r.get(), "a");
156
+ let dead = false;
157
+ for (let i = 0; i < H + 2 && !dead; i++) { r.step([turn("a", 0)]); dead = readPlayers(r.get())[seat]!.a === 0; }
158
+ expect(dead).toBe(true);
159
+ expect(countCells(decodeGrid(r.get().g, W, H), seat + 1)).toBe(0);
160
+ for (let i = 0; i < 20 && readPlayers(r.get())[seat]!.a === 0; i++) r.step();
161
+ expect(readPlayers(r.get())[seat]!.a).toBe(1);
162
+ expect(countCells(decodeGrid(r.get().g, W, H), seat + 1)).toBeGreaterThan(0);
163
+ });
164
+
165
+ // ── BUDGET ─────────────────────────────────────────────────────────────────────
166
+
167
+ function datagramBytes(grid: Int16Array, seats: Seat[], players: (Player | null)[]): number {
168
+ const state: State = { shell: { phase: "playing", seats, round: 5, phaseSince: 4200, over: false }, w: W, h: H, g: encodeGrid(grid), pf: [], pt: [], win: -1 };
169
+ writePlayers(state, players);
170
+ return 1 + new TextEncoder().encode(JSON.stringify({ tick: 4321, state })).length;
171
+ }
172
+ function fullTableAtTrailCap(): { seats: Seat[]; players: (Player | null)[] } {
173
+ const seats: Seat[] = Array.from({ length: 6 }, (_, i) => ({ id: i.toString(36).padStart(3, "0"), ready: false, abandoned: false }));
174
+ const players: (Player | null)[] = seats.map((_, slot) => {
175
+ const t: number[] = [];
176
+ let cx = (slot * 5) % W, cy = (slot * 7) % H;
177
+ for (let k = 0; k < MAX_TRAIL_TURNS; k++) { t.push(cx, cy); if (k % 2) cx = (cx + 2) % W; else cy = (cy + 2) % H; }
178
+ return { x: cx, y: cy, d: slot % 4, nd: slot % 4, a: 1 as const, t, rs: 0 };
179
+ });
180
+ return { seats, players };
181
+ }
182
+
183
+ test("GUARD: messy-realistic worst-case state fits one datagram with margin", () => {
184
+ const { seats, players } = fullTableAtTrailCap();
185
+ const N = 6;
186
+ const messy = (seed: number) => {
187
+ let s = seed >>> 0; const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
188
+ const seeds = Array.from({ length: N }, (_, i) => [((i * 5) % W) + 2, ((i * 9) % H) + 2]);
189
+ const g = new Int16Array(W * H); const R = Math.sqrt((W * H * 0.72) / (N * Math.PI));
190
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
191
+ let b = -1, bd = Infinity, b2 = -1, bd2 = Infinity;
192
+ for (let k = 0; k < N; k++) { const dx = x - seeds[k]![0]!, dy = y - seeds[k]![1]!, d = dx * dx + dy * dy; if (d < bd) { bd2 = bd; b2 = b; bd = d; b = k; } else if (d < bd2) { bd2 = d; b2 = k; } }
193
+ const dd = Math.sqrt(bd);
194
+ if (dd <= R) { const border = Math.sqrt(bd2) - dd < 2.2; if (border && rnd() < 0.5) g[y * W + x] = rnd() < 0.5 ? b2 + 1 : 0; else g[y * W + x] = b + 1; }
195
+ else if (rnd() < 0.05) g[y * W + x] = b + 1;
196
+ }
197
+ return g;
198
+ };
199
+ let worst = 0;
200
+ for (let seed = 1; seed <= 60; seed++) worst = Math.max(worst, datagramBytes(messy(seed), seats, players));
201
+ expect(worst).toBeLessThan(MTU_BUDGET);
202
+ });
203
+
204
+ test("CHARACTERIZATION: vertically-striped territory exceeds a datagram — the JSON-RLE ceiling", () => {
205
+ // Row-major RLE degrades on vertical striping (every column boundary is a run on every
206
+ // row). No seat/grid count survives this — the grid alone blows the datagram. It degrades
207
+ // GRACEFULLY (latest-wins droppable datagram → dropped tick → recover next tick); the real
208
+ // fix is binary/delta snapshots. This pins that the ceiling is real — nobody should assume
209
+ // JSON snapshots reach 100 players. See skills/compact-snapshots.md.
210
+ const { seats, players } = fullTableAtTrailCap();
211
+ const striped = new Int16Array(W * H);
212
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) striped[y * W + x] = (x % 6) + 1;
213
+ expect(datagramBytes(striped, seats, players)).toBeGreaterThan(1200);
214
+ });