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

forked from trevato/cs

The shipped weights stop riding in the entry bundle: 9.5 kB of the 44 kB a player downloads becomes its own cached asset #223

Merged opened this

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

Merged

torque/cs-engine:torque/214-the-shipped-weights-stop-riding-in-the-e → main 2 commits fork

  • Merged. torque/214-the-shipped-weights-stop-riding-in-the-e was merged into main as d6c9844.

Review

Sign in to review this change.
22 files changed +801 −206
scripts/build.test.ts +50 −6
@@ -3,8 +3,12 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } fro
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import {
6 ASSETS,
7 assetOf,
68 bundle,
9 collect,
710 ENTRIES,
11 isNamed,
812 GZIP_LEVEL,
913 isBundleOf,
1014 line,
@@ -38,7 +42,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
3842
3943 test("the entry table is what the app ships today, each with the size it may not pass", () => {
4044 expect(ENTRIES).toEqual([
41 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 100_000, gzip: 45_000 } },
45 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 98_000, gzip: 39_000 } },
4246 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
4347 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } },
4448 ]);
@@ -52,7 +56,7 @@ test("the entry table is what the app ships today, each with the size it may not
5256
5357 test("every entry is bundled to its own name and its own content hash", async () => {
5458 const { dir, entries } = fixture();
55 const built = await write(entries, dir);
59 const built = await write(entries, dir, []);
5660 expect(built.map((b) => b.name)).toEqual(["app", "sandbox"]);
5761 for (const { name, file, code } of built) {
5862 expect(file).toMatch(new RegExp(`^${name}\\.[0-9a-f]{10}\\.js$`));
@@ -65,12 +69,12 @@ test("every entry is bundled to its own name and its own content hash", async ()
6569
6670 test("rebuilding one entry sweeps up its own stale bundle and leaves the other game alone", async () => {
6771 const { dir, entries, source } = fixture();
68 const first = await write(entries, dir);
72 const first = await write(entries, dir, []);
6973 const [staleApp, sandbox] = first;
7074 writeFileSync(join(dir, "keep.txt"), "not a bundle");
7175
7276 source("app", `export const game = "app";\nconsole.log(game, "again");\n`);
73 const second = await write(entries, dir);
77 const second = await write(entries, dir, []);
7478 const [freshApp, sandboxAgain] = second;
7579
7680 expect(freshApp?.file).not.toBe(staleApp?.file);
@@ -159,7 +163,7 @@ test("the committed bundle of every entry is under its ceiling", () => {
159163
160164 test("weighing a committed bundle reads the file and builds nothing", async () => {
161165 const { dir, entries } = fixture();
162 const [built] = await write(entries, dir);
166 const [built] = await write(entries, dir, []);
163167 const file = readFileSync(join(dir, built?.file as string));
164168 // The same ruler `sizes` is: on disk, and gzipped at the pinned level.
165169 expect(weigh("app", dir)).toEqual(sizes(file));
@@ -172,10 +176,50 @@ test("weighing a committed bundle reads the file and builds nothing", async () =
172176 expect(() => weigh("nowhere", dir)).toThrow(/has no bundle/);
173177 });
174178
179 test("the asset table is what the app downloads beside its bundles", () => {
180 // The shooter's weights, and nothing else: the registry projected again.
181 expect(ASSETS).toEqual([
182 { name: "weights", source: "src/client/weights.json", ceiling: { raw: 12_300, gzip: 9_100 } },
183 ]);
184 expect(ASSETS).toEqual(GAMES.flatMap((game) => game.assets.map((asset) => ({ ...asset }))));
185 });
186
187 test("an asset is copied verbatim, stamped by its own contents, and swept when it changes", () => {
188 const dir = mkdtempSync(join(tmpdir(), "assets-"));
189 writeFileSync(join(dir, "w.json"), `{"a":1}`);
190 const asset = { name: "weights", source: join(dir, "w.json"), ceiling: ROOMY };
191 const [first] = collect([asset], dir);
192 // The bytes in static/ are the bytes the file holds: an asset is a copy.
193 expect(first?.code).toBe(`{"a":1}`);
194 expect(first?.file).toMatch(/^weights\.[0-9a-f]{10}\.json$/);
195 expect(isNamed("weights", "json", first?.file as string)).toBe(true);
196 expect(isBundleOf("weights", first?.file as string)).toBe(false);
197 // The hash is the contents', so a retrain is a new name and a sweep.
198 writeFileSync(join(dir, "w.json"), `{"a":2}`);
199 const [second] = collect([asset], dir);
200 expect(second?.file).not.toBe(first?.file);
201 // And the finder reads one back by name, or says what to run.
202 expect(assetOf(asset, [second?.file as string], dir)).toBe(second?.file as string);
203 expect(() => assetOf(asset, [], dir)).toThrow(/has no asset.*run `bun run scripts\/build\.ts`/);
204 expect(() => assetOf(asset, [first?.file as string, second?.file as string], dir)).toThrow(/has 2 assets/);
205 });
206
207 test("the committed asset of every game is under its ceiling, and is what its source holds", () => {
208 const files = readdirSync(STATIC_DIR);
209 for (const asset of ASSETS) {
210 const file = assetOf(asset, files);
211 const bytes = readFileSync(join(STATIC_DIR, file));
212 expect(overshoot(`static/${file} (committed)`, sizes(bytes), asset.ceiling)).toBeNull();
213 // Byte for byte the file the trainer wrote: a stale copy in static/ is a
214 // policy nobody trained, served to every player.
215 expect(bytes.toString("utf8")).toBe(collect([asset])[0]?.code as string);
216 }
217 });
218
175219 test("the build prints each bundle's raw and gzipped size next to its name", () => {
176220 const code = `console.log("hi");\n`;
177221 const { raw, gzip } = sizes(code);
178 const printed = line({ name: "app", file: "app.6864d577a4.js", code, ceiling: { raw: 400, gzip: 300 } });
222 const printed = line({ name: "app", ext: "js", file: "app.6864d577a4.js", code, ceiling: { raw: 400, gzip: 300 } });
179223 expect(printed).toContain("static/app.6864d577a4.js");
180224 expect(printed).toContain(`${raw} B raw / 400`);
181225 expect(printed).toContain(`${gzip} B gzip-${GZIP_LEVEL} / 300`);
scripts/build.ts +90 −29
@@ -1,20 +1,26 @@
11 /**
22 * The build: every game in the registry, bundled to `<name>.<hash>.js` in
3 * static/.
3 * static/, and every asset a game declares beside it, copied to
4 * `<name>.<hash>.<ext>` the same way.
45 * `bun run build` writes them; the test compares what is committed against what
56 * the sources produce now, so a stale bundle fails the check instead of the
67 * browser. Each entry also carries the size it may not pass, and `build.test.ts`
78 * weighs both the committed file and a fresh build against it.
9 *
10 * A bundle and an asset are the same thing here — bytes filed under a name and
11 * their own content hash, swept when they change and held to a ceiling — and
12 * they differ only in where the bytes come from: a bundler, or a file. That is
13 * why `write` returns one list of both.
814 */
915
1016 import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11 import { join, resolve } from "node:path";
12 import { type Ceiling, GAMES } from "../src/games.ts";
17 import { extname, join, resolve } from "node:path";
18 import { type Asset, type Ceiling, GAMES } from "../src/games.ts";
1319
1420 const ROOT = join(import.meta.dir, "..");
1521 export const STATIC_DIR = join(ROOT, "static");
1622
17 export type { Ceiling };
23 export type { Asset, Ceiling };
1824
1925 /**
2026 * One bundle: the name it is filed under, the source it starts from, and the
@@ -30,6 +36,16 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling };
3036 */
3137 export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling }));
3238
39 /**
40 * And what is copied, projected the same way: every game's assets, flattened,
41 * because static/ is one directory and a name in it belongs to one file
42 * whichever game declared it.
43 */
44 export const ASSETS: Asset[] = GAMES.flatMap((game) => game.assets.map((asset) => ({ ...asset })));
45
46 /** The extension an asset is filed under, taken from the file it is copied from. */
47 export const extOf = (asset: Asset): string => extname(asset.source).replace(/^\./, "");
48
3349 /**
3450 * The one level gzip is ever measured at here. Compression level is the knob
3551 * that moves the number between machines, so it is pinned: `bun test` on a
@@ -66,36 +82,54 @@ export function overshoot(name: string, measured: Ceiling, ceiling: Ceiling): st
6682 // plain, so neither can be surprised by it.
6783 const NAME = /^[a-z][a-z0-9-]*$/;
6884
69 /** Is this file in static/ a bundle of that entry — `app.3f2a9c1b04.js` of `app`? */
70 export function isBundleOf(name: string, file: string): boolean {
71 return NAME.test(name) && new RegExp(`^${name}\\.[0-9a-f]+\\.js$`).test(file);
85 /** Is this file in static/ filed under that name and extension — `app.3f2a9c1b04.js` of `app`? */
86 export function isNamed(name: string, ext: string, file: string): boolean {
87 return NAME.test(name) && NAME.test(ext) && new RegExp(`^${name}\\.[0-9a-f]+\\.${ext}$`).test(file);
7288 }
7389
90 /** Is this file in static/ a bundle of that entry — `app.3f2a9c1b04.js` of `app`? */
91 export const isBundleOf = (name: string, file: string): boolean => isNamed(name, "js", file);
92
7493 /**
75 * The one `<name>.<hash>.js` among a directory's `files`, or a sentence saying
76 * what to do about it: none means nobody built or committed it, and two means a
77 * stale one was left behind and whoever picks between them picks at random.
78 * `where` is the directory those names came from, and is only ever read out in
79 * the message.
94 * The one `<name>.<hash>.<ext>` among a directory's `files`, or a sentence
95 * saying what to do about it: none means nobody built or committed it, and two
96 * means a stale one was left behind and whoever picks between them picks at
97 * random. `what` is the word the message uses for it — a bundle, an asset —
98 * and `where` is the directory those names came from, read out in the message
99 * and nowhere else.
80100 *
81101 * It is here rather than beside either caller because both — the server, which
82 * refuses to boot without every game's bundle, and the tests, which boot the
83 * committed file — are asking the same question of the same directory, and one
102 * refuses to boot without every game's files, and the tests, which boot the
103 * committed ones — are asking the same question of the same directory, and one
84104 * answer cannot drift from itself.
85105 */
86 export function bundleOf(name: string, files: readonly string[], where = STATIC_DIR): string {
87 const mine = files.filter((file) => isBundleOf(name, file));
106 export function oneNamed(
107 name: string,
108 ext: string,
109 files: readonly string[],
110 where = STATIC_DIR,
111 what = "bundle",
112 ): string {
113 const mine = files.filter((file) => isNamed(name, ext, file));
88114 const first = mine[0];
89115 if (mine.length === 1 && first !== undefined) return first;
90116 const build = "run `bun run scripts/build.ts` and commit what it writes";
91117 throw new Error(
92118 mine.length === 0
93 ? `the game ${JSON.stringify(name)} has no bundle: nothing in ${where} is named ${name}.<hash>.js — ${build}`
94 : `the game ${JSON.stringify(name)} has ${mine.length} bundles in ${where}: ${mine.join(", ")} — ` +
119 ? `the game ${JSON.stringify(name)} has no ${what}: nothing in ${where} is named ${name}.<hash>.${ext} — ${build}`
120 : `the game ${JSON.stringify(name)} has ${mine.length} ${what}s in ${where}: ${mine.join(", ")} — ` +
95121 `all but one are stale, so ${build}`,
96122 );
97123 }
98124
125 /** The one `<name>.<hash>.js` in a directory, or the sentence saying what to do. */
126 export const bundleOf = (name: string, files: readonly string[], where = STATIC_DIR): string =>
127 oneNamed(name, "js", files, where);
128
129 /** The one `<name>.<hash>.<ext>` of a declared asset, or the same sentence. */
130 export const assetOf = (asset: Asset, files: readonly string[], where = STATIC_DIR): string =>
131 oneNamed(asset.name, extOf(asset), files, where, "asset");
132
99133 /**
100134 * What the committed bundle of `name` weighs right now: the one
101135 * `<name>.<hash>.js` in `dir`, on disk and gzipped at `GZIP_LEVEL`. It builds
@@ -110,7 +144,14 @@ export function weigh(name: string, dir = STATIC_DIR): Ceiling {
110144 return sizes(readFileSync(join(dir, bundleOf(name, readdirSync(dir), dir))));
111145 }
112146
113 export type Built = { name: string; file: string; code: string; ceiling: Ceiling };
147 export type Built = { name: string; ext: string; file: string; code: string; ceiling: Ceiling };
148
149 /** `app` and its contents into `app.3f2a9c1b04.js`: the one naming rule here. */
150 function stamp(name: string, ext: string, code: string, ceiling: Ceiling): Built {
151 if (!NAME.test(name)) throw new Error(`entry name ${JSON.stringify(name)} is not a plain name`);
152 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
153 return { name, ext, file: `${name}.${hash}.${ext}`, code, ceiling };
154 }
114155
115156 /** Bundle every entry and give each the file name its own contents deserve. */
116157 export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> {
@@ -128,24 +169,44 @@ export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> {
128169 if (!out.success) throw new Error(out.logs.map(String).join("\n"));
129170 const output = out.outputs[0];
130171 if (output === undefined) throw new Error(`the bundle for ${name} produced no output`);
131 const code = await output.text();
132 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
133 built.push({ name, file: `${name}.${hash}.js`, code, ceiling });
172 built.push(stamp(name, "js", await output.text(), ceiling));
134173 }
135174 return built;
136175 }
137176
138177 /**
139 * Write every bundle, and sweep up after each entry only: a stale `app.*.js`
140 * goes, another game's bundle beside it stays. Anything else in static/ is left
141 * where it is.
178 * Every declared asset, read off disk and stamped with its own content hash —
179 * verbatim, because the bytes in static/ are the bytes the file holds. A
180 * source that is not there throws here, naming the file, rather than deploying
181 * a page that links to nothing.
142182 */
143 export async function write(entries: Entry[] = ENTRIES, dir = STATIC_DIR): Promise<Built[]> {
144 const built = await bundle(entries);
183 export function collect(assets: Asset[] = ASSETS, root = ROOT): Built[] {
184 if (new Set(assets.map((a) => a.name)).size !== assets.length)
185 throw new Error("two assets share a name; each one needs its own");
186 return assets.map((asset) => {
187 const ext = extOf(asset);
188 if (ext === "") throw new Error(`the asset ${JSON.stringify(asset.name)} has no extension: ${asset.source}`);
189 return stamp(asset.name, ext, readFileSync(resolve(root, asset.source), "utf8"), asset.ceiling);
190 });
191 }
192
193 /**
194 * Write every bundle and every asset, and sweep up after each name and
195 * extension only: a stale `app.*.js` goes, another game's bundle beside it
196 * stays, and so does `weights.*.json`. Anything else in static/ is left where
197 * it is.
198 */
199 export async function write(
200 entries: Entry[] = ENTRIES,
201 dir = STATIC_DIR,
202 assets: Asset[] = ASSETS,
203 root = ROOT,
204 ): Promise<Built[]> {
205 const built = [...(await bundle(entries)), ...collect(assets, root)];
145206 mkdirSync(dir, { recursive: true });
146207 const present = readdirSync(dir);
147 for (const { name, file, code } of built) {
148 for (const old of present) if (old !== file && isBundleOf(name, old)) rmSync(join(dir, old));
208 for (const { name, ext, file, code } of built) {
209 for (const old of present) if (old !== file && isNamed(name, ext, old)) rmSync(join(dir, old));
149210 writeFileSync(join(dir, file), code);
150211 }
151212 return built;
scripts/train.test.ts +36 −21
@@ -4,7 +4,7 @@
44 * minutes between the two is not a thing a test suite should spend, and the
55 * duels that grade the shipped weights are `src/rl/learned.test.ts`'s.
66 *
7 * The test that matters is the last one: `weightsModule` handed what
7 * The test that matters is the last one: `cardModule` handed what
88 * `src/client/weights.ts` exports today gives that file back character for
99 * character. It says at once that the generator's formatting is what is in the
1010 * tree, that the file in the tree was written by this generator and not by
@@ -17,8 +17,10 @@ import { expect, test } from "bun:test";
1717 import { mkdtempSync, writeFileSync } from "node:fs";
1818 import { tmpdir } from "node:os";
1919 import { join, resolve } from "node:path";
20 import { DEFAULTS, parseOptions, USAGE, WEIGHTS, weightsModule, type Options } from "./train.ts";
21 import { CARD, PACKED } from "../src/client/weights.ts";
20 import { cardModule, DEFAULTS, parseOptions, policyDocument, POLICY, USAGE, WEIGHTS, type Options } from "./train.ts";
21 import { parsePacked } from "../src/client/packed.ts";
22 import { PACKED } from "../src/client/shipped.ts";
23 import { CARD } from "../src/client/weights.ts";
2224
2325 const SOURCE = join(import.meta.dir, "..", "src", "client", "weights.ts");
2426
@@ -87,46 +89,59 @@ test("a flag nobody knows, and a word that is not a flag, stop the run and say s
8789 expect(USAGE).toContain(flag);
8890 });
8991
90 test("--out is the one path a run writes, resolved against where it was run", () => {
92 test("--out and --policy are the two paths a run writes, resolved against where it was run", () => {
9193 expect(options("--out", "/tmp/weights.ts").out).toBe("/tmp/weights.ts");
9294 expect(options("--out", "elsewhere/w.ts").out).toBe(resolve("elsewhere/w.ts"));
9395 expect(options("--out", "/tmp/w.ts").out).not.toBe(WEIGHTS);
96 // Since #214 the weights are their own file: the card is one write and the
97 // packed policy the build copies into static/ is the other.
98 expect(DEFAULTS.policy).toBe(POLICY);
99 expect(POLICY.endsWith("src/client/weights.json")).toBe(true);
100 expect(options("--policy", "/tmp/w.json").policy).toBe("/tmp/w.json");
101 expect(options("--policy", "elsewhere/w.json").policy).toBe(resolve("elsewhere/w.json"));
102 expect(options("--policy", "/tmp/w.json").out).toBe(WEIGHTS);
103 });
104
105 test("the policy document it writes is what the page's reader accepts, and is the file in the tree", async () => {
106 const text = policyDocument(PACKED);
107 expect(text.endsWith("\n")).toBe(true);
108 // One line of JSON: these bytes are copied into static/ verbatim and are
109 // what a browser downloads, so `parsePacked` is the other end of this.
110 expect(parsePacked(text)).toEqual(PACKED);
111 expect(policyDocument(PACKED)).toBe(await Bun.file(POLICY).text());
94112 });
95113
96114 test("the generated module says not to edit it, and says it first", () => {
97 const text = weightsModule(PACKED, CARD);
115 const text = cardModule(CARD);
98116 expect(text.startsWith("/**")).toBe(true);
99 expect(text).toContain("written by scripts/train.ts. Do not edit by hand.");
117 expect(text).toContain("scripts/train.ts. Do not edit by hand.");
100118 expect(text.indexOf("Do not edit by hand")).toBeLessThan(text.indexOf("export const CARD"));
101119 expect(text.endsWith("\n")).toBe(true);
102120 });
103121
104122 test("the same card and the same weights write the same text, every time", () => {
105 expect(weightsModule(PACKED, CARD)).toBe(weightsModule(PACKED, CARD));
123 expect(cardModule(CARD)).toBe(cardModule(CARD));
106124 // And a different card is a different file in exactly one place: its numbers.
107 const other = weightsModule(PACKED, { ...CARD, winRate: 3 });
108 expect(other).not.toBe(weightsModule(PACKED, CARD));
125 const other = cardModule({ ...CARD, winRate: 3 });
126 expect(other).not.toBe(cardModule(CARD));
109127 expect(other).toContain(`"winRate": 3`);
110 expect(other.replace(`"winRate": 3`, `"winRate": ${CARD.winRate}`)).toBe(weightsModule(PACKED, CARD));
128 expect(other.replace(`"winRate": 3`, `"winRate": ${CARD.winRate}`)).toBe(cardModule(CARD));
111129 });
112130
113131 test("what it emits parses, and exports what src/client/weights.ts exports today", async () => {
114 // Transpiled rather than imported as TypeScript: the only import in the
115 // generated module is `import type`, which erases, so what is left is a
116 // module with no imports at all and nothing of this repository in it.
117 const js = new Bun.Transpiler({ loader: "ts" }).transformSync(weightsModule(PACKED, CARD));
132 // Transpiled rather than imported as TypeScript: the generated module has no
133 // imports at all, so what is left is a module with nothing of this repository
134 // in it.
135 const js = new Bun.Transpiler({ loader: "ts" }).transformSync(cardModule(CARD));
118136 const file = join(mkdtempSync(join(tmpdir(), "train-")), "weights.js");
119137 writeFileSync(file, js);
120 const generated = (await import(file)) as { CARD: typeof CARD; PACKED: typeof PACKED };
138 const generated = (await import(file)) as { CARD: typeof CARD };
121139 const shipped = await import("../src/client/weights.ts");
122140
123141 expect(Object.keys(generated).sort()).toEqual(Object.keys(shipped).sort());
124142 expect(generated.CARD).toEqual(CARD);
125 expect(generated.PACKED).toEqual(PACKED);
126 // The bytes survive the round trip through the source text exactly: the
127 // weights are base64 in a string literal and one wrong character is a
128 // different policy.
129 expect(generated.PACKED.bytes).toBe(PACKED.bytes);
143 // And the weights are not in it any more: they are the JSON beside it.
144 expect(Object.keys(generated)).not.toContain("PACKED");
130145 });
131146
132147 test("the file in the tree is this generator's output, character for character", async () => {
@@ -134,5 +149,5 @@ test("the file in the tree is this generator's output, character for character",
134149 // the generator's formatting moved under it. Both are worth a failure: the
135150 // page reads the file in the tree, and every other test here reads the
136151 // generator.
137 expect(weightsModule(PACKED, CARD)).toBe(await Bun.file(SOURCE).text());
152 expect(cardModule(CARD)).toBe(await Bun.file(SOURCE).text());
138153 });
scripts/train.ts +63 −34
@@ -1,12 +1,17 @@
11 /**
22 * Training. Runs a few hundred PPO updates over a batch of duels and writes
3 * the result to src/client/weights.ts, which is what the game ships.
3 * two files the game ships: src/client/weights.json, which is the packed
4 * policy and is copied into static/ under its own content hash by
5 * `bun run scripts/build.ts`, and src/client/weights.ts, which is the card the
6 * page boasts about it. A run that writes them is followed by a build and a
7 * commit of both, and of what static/ then holds.
48 *
59 * bun run scripts/train.ts --bench # how fast the arena steps
610 * bun run scripts/train.ts --iters 300 # train and write the weights
711 * bun run scripts/train.ts --iters 300 --dry # train without writing
812 * bun run scripts/train.ts --maps 1 # one map, the way it used to train
9 * bun run scripts/train.ts --out /tmp/w.ts # write the module somewhere else
13 * bun run scripts/train.ts --out /tmp/w.ts # write the card somewhere else
14 * bun run scripts/train.ts --policy /tmp/w.json # and the weights somewhere else
1015 *
1116 * Nothing here is imported by the server or the page: this file runs on a
1217 * machine with a few minutes to spare, and its only output is a table of
@@ -30,9 +35,10 @@ import { botThink, makeBrain } from "../src/client/bots.ts";
3035 import {
3136 computeAdvantages, copyPolicy, createAdam, createPolicy, createRollout, forward, greedy,
3237 newActivations, pack, rng, sample, unpack, update, DEFAULT_HYPER,
33 type Hyper, type Packed, type Policy, type Shape,
38 type Hyper, type Packed, type Policy,
3439 } from "../src/engine/index.ts";
3540 import { OBS_SIZE } from "../src/client/observe.ts";
41 import { SHAPE } from "../src/client/packed.ts";
3642 import {
3743 createArena, EPISODE_TICKS, observeFoe, observeLearner, policyFoe, relevel, reset, scriptedFoe, SKIP,
3844 step, stepInput, stillFoe, type Arena,
@@ -42,13 +48,22 @@ import { DE_FORGE } from "../src/client/map.ts";
4248 import type { Level } from "../src/client/level.ts";
4349 import type { Card } from "../src/client/weights.ts";
4450
45 export const SHAPE: Shape = { obs: OBS_SIZE, hidden: 64, heads: HEADS };
51 /**
52 * The shape trained here, which is the shape the game asks for: it is
53 * `src/client/packed.ts`'s constant and not a second copy, because the browser
54 * refuses a downloaded policy of any other shape and a trainer that disagreed
55 * with it would write eight kilobytes nobody can run.
56 */
57 export { SHAPE };
4658
4759 // --- the boundary ---------------------------------------------------------
4860
49 /** Where the module lands when nobody says otherwise: the file the game ships. */
61 /** Where the card lands when nobody says otherwise: the file the page reads. */
5062 export const WEIGHTS = join(import.meta.dir, "..", "src", "client", "weights.ts");
5163
64 /** And where the weights themselves land: the document the build copies to static/. */
65 export const POLICY = join(import.meta.dir, "..", "src", "client", "weights.json");
66
5267 /** Who a run practises against: the curriculum, or one opponent all the way. */
5368 export type Foe = "mixed" | "still" | "scripted";
5469
@@ -67,8 +82,10 @@ export type Options = {
6782 /** How many layouts it is graded on, none of which it trained on. */
6883 holdout: number;
6984 foe: Foe;
70 /** The one path this script writes. Absolute, resolved against the cwd. */
85 /** Where the card is written. Absolute, resolved against the cwd. */
7186 out: string;
87 /** Where the packed weights are written. Absolute, resolved against the cwd. */
88 policy: string;
7289 };
7390
7491 export const DEFAULTS: Options = {
@@ -82,6 +99,7 @@ export const DEFAULTS: Options = {
8299 holdout: 24,
83100 foe: "mixed",
84101 out: WEIGHTS,
102 policy: POLICY,
85103 };
86104
87105 /** The flags that take a whole number, and the smallest one each will accept. */
@@ -93,7 +111,7 @@ const NUMBERS = {
93111
94112 const FOES: readonly Foe[] = ["mixed", "still", "scripted"];
95113
96 const KNOWN = ["--bench", "--dry", "--foe", "--out", ...Object.keys(NUMBERS).map((k) => `--${k}`)].sort();
114 const KNOWN = ["--bench", "--dry", "--foe", "--out", "--policy", ...Object.keys(NUMBERS).map((k) => `--${k}`)].sort();
97115
98116 /** What the usage line says, printed under any complaint below. */
99117 export const USAGE = `usage: bun run scripts/train.ts [${KNOWN.join("] [")}]`;
@@ -121,10 +139,11 @@ export function parseOptions(argv: readonly string[]): Options {
121139 const foe = value(flag, argv[++i]);
122140 if (!FOES.includes(foe as Foe)) throw new Error(`--foe is one of ${FOES.join(", ")}, and got ${JSON.stringify(foe)}`);
123141 options.foe = foe as Foe;
124 } else if (flag === "--out") {
125 const out = value(flag, argv[++i]);
126 if (out.trim() === "") throw new Error("--out cannot be empty");
127 options.out = resolve(out);
142 } else if (flag === "--out" || flag === "--policy") {
143 const where = value(flag, argv[++i]);
144 if (where.trim() === "") throw new Error(`${flag} cannot be empty`);
145 if (flag === "--out") options.out = resolve(where);
146 else options.policy = resolve(where);
128147 } else if (Object.hasOwn(NUMBERS, name)) {
129148 const key = name as keyof typeof NUMBERS;
130149 const text = value(flag, argv[++i]);
@@ -369,9 +388,11 @@ async function train(options: Options): Promise<void> {
369388 `(${rate(unseenBaseline)}% won, ${draws(unseenBaseline)}% drawn)`);
370389
371390 if (options.dry) return;
372 // The one write this script does, and it goes to the path the options name.
391 // The two writes this script does, each to the path the options name: the
392 // weights themselves, and the card that says what they measured.
373393 const out = options.out;
374 writeFileSync(out, weightsModule(packed, {
394 writeFileSync(options.policy, policyDocument(packed));
395 writeFileSync(out, cardModule({
375396 trainedAt: new Date().toISOString().slice(0, 10),
376397 decisions: envSteps,
377398 envSteps: envSteps * SKIP,
@@ -387,26 +408,41 @@ async function train(options: Options): Promise<void> {
387408 unseenBaselineWinRate: rate(unseenBaseline),
388409 unseenDrawRate: draws(unseen),
389410 }));
390 console.log(`\nwrote ${out} (${packed.bytes.length} base64 chars)`);
411 console.log(`\nwrote ${options.policy} (${packed.bytes.length} base64 chars) and ${out}`);
412 console.log("now run `bun run scripts/build.ts` and commit both files with what it writes into static/");
391413 }
392414
393415 /**
394 * The generated module, as text: the header that says not to edit it, the type
395 * the card is, the card this run measured and the packed weights. It is a pure
396 * function of its two arguments and formats the same way every time, so
397 * `train.test.ts` can hand it what `src/client/weights.ts` exports today and
398 * get that file back character for character.
399 */
400 export function weightsModule(packed: Packed, card: Card): string {
401 return `/**
402 * The trained policy, written by scripts/train.ts. Do not edit by hand.
416 * The packed weights as they are written to disk and as they travel: one
417 * compact JSON document, newline-ended like any other file here. Compact
418 * because these bytes are what a browser downloads — this file is copied into
419 * static/ verbatim — and nobody reads eight kilobytes of base64 by eye.
403420 *
404 * Weights are int8 with one scale per tensor — about eight kilobytes instead of
405 * thirty — and are dequantised once when the page loads. CARD is what the run
406 * that produced them measured, and it is what the page and the terminal show.
421 * `src/client/packed.ts` is the other end of it and the only reader: what this
422 * writes, that accepts, and `train.test.ts` holds the pair to it.
407423 */
424 export const policyDocument = (packed: Packed): string => `${JSON.stringify(packed)}\n`;
408425
409 import type { Packed } from "../engine/index.ts";
426 /**
427 * The generated module, as text: the header that says not to edit it, the type
428 * the card is, and the card this run measured. It is a pure function of its one
429 * argument and formats the same way every time, so `train.test.ts` can hand it
430 * what `src/client/weights.ts` exports today and get that file back character
431 * for character.
432 */
433 export function cardModule(card: Card): string {
434 return `/**
435 * The card for the weights in \`weights.json\` beside this file, written by
436 * scripts/train.ts. Do not edit by hand.
437 *
438 * The weights themselves are that JSON document and not a literal in here:
439 * int8 with one scale per tensor, about eight kilobytes, shipped as this
440 * game's own asset (\`src/games.ts\`) and fetched at boot rather than bundled
441 * into the entry a player downloads first. CARD is what the run that produced
442 * them measured, and it is what the page and the terminal show — a hundred
443 * bytes of prose numbers, which is why it is still a module the first paint
444 * can read.
445 */
410446
411447 export type Card = {
412448 trainedAt: string;
@@ -430,13 +466,6 @@ export type Card = {
430466 };
431467
432468 export const CARD: Card = ${JSON.stringify(card, null, 2)};
433
434 export const PACKED: Packed = {
435 shape: ${JSON.stringify(packed.shape)},
436 scales: ${JSON.stringify(packed.scales)},
437 bytes:
438 "${packed.bytes}",
439 };
440469 `;
441470 }
442471
src/client/action.test.ts +1 −1
@@ -19,7 +19,7 @@ import {
1919 } from "./action.ts";
2020 import { forward, greedy, newActivations, rng, sample, unpack } from "../engine/index.ts";
2121 import { OBS_SIZE } from "./observe.ts";
22 import { PACKED } from "./weights.ts";
22 import { PACKED } from "./shipped.ts";
2323 import { NO_INPUT } from "./world.ts";
2424
2525 /** The bins, written out again by hand. If these two lists ever disagree, one of them moved. */
src/client/bots.test.ts +22 −9
@@ -1,6 +1,8 @@
11 import { expect, test } from "bun:test";
2 import { PACKED, useShipped } from "./shipped.ts";
3 import { unpack, type Policy } from "../engine/index.ts";
24 import {
3 applyIntent, blindness, botThink, CARD, dry, flashed, makeBrain, nextWeapon, pathTo, policy,
5 applyIntent, blindness, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy, usePolicy,
46 ride, think,
57 } from "./bots.ts";
68 import { grip } from "./spray.ts";
@@ -12,6 +14,25 @@ import { DE_FORGE } from "./map.ts";
1214 import { COMPETITIVE } from "./match.ts";
1315 import { aimAt, applyInput, bodyCentre, createWorld, type Player } from "./world.ts";
1416
17 // The bots in here think with the policy this repository ships, handed in the
18 // way a browser hands in the one it downloaded.
19 useShipped();
20
21 test("a bot with no policy handed to it says so, and one handed a policy uses that one", () => {
22 // The seam #214 opened: the weights are data now, so `usePolicy` is the only
23 // way a bot gets any — a browser hands it what it downloaded, Bun hands it
24 // what `shipped.ts` read. Nobody having handed one in is loud, not a bot
25 // quietly aiming at nothing.
26 usePolicy(null as unknown as Policy);
27 expect(() => policy()).toThrow(NO_POLICY);
28 const net = useShipped();
29 expect(policy()).toBe(net);
30 const other = unpack(PACKED);
31 usePolicy(other);
32 expect(policy()).toBe(other);
33 useShipped();
34 });
35
1536 const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => {
1637 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, seed);
1738 world.match.phase = "live";
@@ -156,14 +177,6 @@ test("a bot whose objective sits on a crate walks to it rather than standing sti
156177 expect(dist(bot.pos, world.bomb.pos)).toBeLessThan(before - 200);
157178 });
158179
159 test("the card describes a real training run", () => {
160 expect(CARD.envSteps).toBeGreaterThan(1_000_000);
161 expect(CARD.updates).toBeGreaterThan(0);
162 expect(CARD.duels).toBeGreaterThan(0);
163 expect(CARD.winRate).toBeGreaterThanOrEqual(0);
164 expect(CARD.winRate).toBeLessThanOrEqual(100);
165 expect(CARD.trainedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
166 });
167180
168181 test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, blinder", () => {
169182 const { world, me, bot } = duel();
src/client/bots.ts +35 −9
@@ -7,31 +7,54 @@
77 * The split is deliberate. The round's objective is a plan (go to a site, plant
88 * the bomb, defuse it) and A* already solves it. The fight is a reflex, and a
99 * reflex is what reinforcement learning is good at: src/rl trains this policy
10 * on millions of duels in the same simulation you are playing, and the weights
11 * it produces are in weights.ts.
10 * on millions of duels in the same simulation you are playing.
11 *
12 * The weights are not in here and are not imported: they arrive as data. In a
13 * browser `src/client/main.ts` downloads `weights.json` as this game's own
14 * asset and calls `usePolicy` before Play is pressable; in Bun
15 * `src/client/shipped.ts` reads the committed document and calls the same
16 * function. So a second game with its own policy — or with none — is a
17 * different caller of `usePolicy` and not a different copy of this file.
1218 */
1319
1420 import { decode, HEADS, STILL, type Intent } from "./action.ts";
1521 import {
16 angleDelta, clamp, dist, forward, newActivations, rng, sample, unpack,
22 angleDelta, clamp, dist, forward, newActivations, rng, sample,
1723 type Activations, type Policy, type Vec3,
1824 } from "../engine/index.ts";
1925 import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
2026 import { CS_HULL } from "./movement.ts";
2127 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
28 import { SHAPE } from "./packed.ts";
2229 import { ease, grip, gripRate, sprayPull } from "./spray.ts";
23 import { CARD, PACKED } from "./weights.ts";
2430 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
2531 import { buy } from "./economy.ts";
2632 import type { Input, Player, World } from "./world.ts";
2733 import { aimAt, bodyCentre, canSee, NO_INPUT } from "./world.ts";
2834
29 export { CARD };
30
31 /** The weights are unpacked once, the first time a bot needs to think. */
35 /**
36 * The policy every bot in this process thinks with, put here once by whoever
37 * got hold of one. There is one because there is one game: a bot's own skill
38 * is what makes it easy or hard, not a network of its own.
39 */
3240 let learned: Policy | null = null;
41
42 /** What `policy()` says when nobody handed one in. */
43 export const NO_POLICY = "no policy: nothing has called usePolicy, so a bot has nothing to think with";
44
45 /** Hand the bots the policy they think with. `main.ts` at boot, `shipped.ts` in Bun. */
46 export function usePolicy(net: Policy): void {
47 learned = net;
48 }
49
50 /**
51 * The policy the bots think with. It throws rather than falling back to the
52 * scripted fight: a round played against bots that quietly stopped being the
53 * trained ones is the failure nobody would notice, and the browser never
54 * reaches here anyway — Play is not pressable until the weights have arrived.
55 */
3356 export function policy(): Policy {
34 if (learned === null) learned = unpack(PACKED);
57 if (learned === null) throw new Error(NO_POLICY);
3558 return learned;
3659 }
3760
@@ -91,7 +114,10 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
91114 site: "A", reset: true,
92115 random: rng(seed),
93116 obs: new Float32Array(OBS_SIZE),
94 activations: newActivations(PACKED.shape),
117 // Sized from the shape the game asks for rather than from a policy: a
118 // brain is made before any weights have arrived, and the two agree because
119 // `parsePacked` refuses a document of any other shape.
120 activations: newActivations(SHAPE),
95121 actions: new Int32Array(HEADS.length),
96122 memory: { sinceSeen: 9 },
97123 intent: { ...STILL },
src/client/main.ts +94 −7
@@ -65,13 +65,16 @@ import type { Level } from "./level.ts";
6565 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
6666 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
6767 import { meshFor } from "./models.ts";
68 import { parsePacked } from "./packed.ts";
6869 import { nextStep, type Answer } from "./pending.ts";
6970 import { drawPreview, previewText } from "./preview.ts";
7071 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
72 import { usePolicy } from "./bots.ts";
7173 import { createSpectator, cycle, follow } from "./spectate.ts";
7274 import { buyable, WEAPONS } from "./weapons.ts";
7375 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
7476 import { createWorld, eyePos, update, type Player, type World } from "./world.ts";
77 import { unpack } from "../engine/index.ts";
7578
7679 const TICK = 1 / 64;
7780
@@ -95,6 +98,23 @@ export const NOT_AN_ID = "That is not a match id — playing a fresh match inste
9598 /** And what it says when the Map name field holds something that is not one. */
9699 export const NOT_A_MAP = "That is not a map name — playing de_forge instead.";
97100
101 /**
102 * What the menu says once the textures are ready and the bots' policy is not:
103 * one more request stands between a player and a match, and on a slow
104 * connection this is the line they read while it finishes.
105 */
106 export const LOADING_POLICY = "Downloading the bots\u2019 policy\u2026";
107
108 /**
109 * And what it says when that request never landed — the loudness a missing
110 * bundle gets, in the menu, before Play. Play stays disabled: a round against
111 * bots with nothing to think with is not a round, so it is a sentence rather
112 * than a canvas that never moves.
113 */
114 export const NO_POLICY =
115 "The bots\u2019 policy did not download, so there is nothing to play against yet. Reload the page \u2014 " +
116 "if it keeps happening, this deployment is missing the weights the page asked for.";
117
98118 /** What the copy control says once the URL is on the clipboard, and if it is not. */
99119 export const COPIED = "Copied";
100120 export const NOT_COPIED = "Copy failed — the link still works";
@@ -130,6 +150,13 @@ export type Sheet = Panel & {
130150 getContext(kind: "2d"): Paint | null;
131151 };
132152
153 /**
154 * A link in the head that says where one of this game's assets is. The entry
155 * reads the URL off it rather than spelling one out: the file is named by its
156 * content and only the server knows which hash is current.
157 */
158 export type Link = Panel & { getAttribute(name: string): string | null };
159
133160 /** A button that says which of two states it is in — the full screen one does. */
134161 export type Toggle = Panel & { setAttribute(name: string, value: string): void };
135162
@@ -200,6 +227,16 @@ export type Host = {
200227 keyboard?: KeyboardLock;
201228 storage: Store;
202229 fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<Reply>;
230 /**
231 * Fetch one of this game's assets and hand back its text, or null when it did
232 * not arrive — a bad status, a refused request, a connection that died.
233 *
234 * A callback rather than a promise because this file has no clock of its own:
235 * a browser resolves it from `fetch`, and the stub page in
236 * `stage.test-helper.ts` answers when the test says so, exactly the way it
237 * decides when a frame happens.
238 */
239 load(url: string, then: (text: string | null) => void): void;
203240 /**
204241 * A fresh id: the match when nobody asked for one, and the nonce on the row
205242 * key when somebody did. `crypto.randomUUID` is one; `src/matchid.ts` says
@@ -287,6 +324,9 @@ export function boot(host: Host): Frame {
287324 const copyLabel = copyMap.textContent;
288325 const prep = by("prep");
289326 const play = by<Panel & { disabled: boolean }>("play");
327 // Where this game's weights are: the shell put the link in the head with the
328 // content-hashed URL on it, so this is the only place the path is known.
329 const weightsLink = doc.getElementById("weights") as Link | null;
290330 const resumeButton = by("resume");
291331 // The line in the pause panel that says what Escape just did on this browser.
292332 const escHint = by("esc");
@@ -417,11 +457,50 @@ export function boot(host: Host): Frame {
417457 };
418458
419459 // The menu says what it is doing while the atlas is painted, and Play cannot
420 // be pressed until there is something to play with. Both are undone on the
421 // frame the last slice lands.
460 // be pressed until there is something to play with: the textures, and the
461 // policy the bots think with. Both are undone when the later of the two lands.
422462 play.disabled = true;
423463 show(prep, true);
424464
465 /**
466 * The weights, asked for at boot rather than at the first bot's first
467 * thought: one request, started before a single texture has been painted, so
468 * on a fast connection it is long finished by the time the ninth slice lands
469 * and on a slow one the menu says what is holding Play up. A policy that
470 * never comes is `NO_POLICY` in the menu and a button that stays off.
471 */
472 let policyIn = false;
473 const href = weightsLink === null ? null : weightsLink.getAttribute("href");
474 if (href === null) prep.textContent = NO_POLICY;
475 else
476 host.load(href, (text) => {
477 const packed = text === null ? null : parsePacked(text);
478 if (packed === null) {
479 prep.textContent = NO_POLICY;
480 return;
481 }
482 usePolicy(unpack(packed));
483 policyIn = true;
484 ready();
485 });
486
487 /**
488 * Play, once both halves of a startable match are here. Called from the frame
489 * that finishes the atlas and from the load that finishes the policy,
490 * whichever is later; before that the line says which one is still missing.
491 */
492 function ready(): void {
493 if (renderer === null) return;
494 if (!policyIn) {
495 // A policy that failed has already written its own sentence here, and
496 // that sentence is not overwritten by this one.
497 if (prep.textContent !== NO_POLICY) prep.textContent = LOADING_POLICY;
498 return;
499 }
500 show(prep, false);
501 play.disabled = false;
502 }
503
425504 // --- the buy menu, rebuilt whenever it opens so the prices are honest
426505 function renderBuy(): void {
427506 if (world === null) return;
@@ -705,9 +784,10 @@ export function boot(host: Host): Frame {
705784 nameNextMap();
706785
707786 function start(): void {
708 // The button is disabled until the atlas is painted; a click that arrives
709 // anyway — a keyboard, a script, a browser being generous — is not a match.
710 if (renderer === null) return;
787 // The button is disabled until the atlas is painted and the policy has
788 // arrived; a click that arrives anyway — a keyboard, a script, a browser
789 // being generous — is not a match.
790 if (renderer === null || !policyIn) return;
711791 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
712792 const team = sideField.value === "t" ? "t" : "ct";
713793 const name = nameField.value.trim().slice(0, 24) || "you";
@@ -835,8 +915,7 @@ export function boot(host: Host): Frame {
835915 if (renderer === null) {
836916 if (!atlas.paint(TEX_SIZE)) return;
837917 renderer = build(atlas.bytes);
838 show(prep, false);
839 play.disabled = false;
918 ready();
840919 return;
841920 }
842921 // The round that has not landed, tried again on the frame its backoff runs
@@ -952,6 +1031,14 @@ const browser = (): Host => ({
9521031 keyboard: (navigator as { keyboard?: KeyboardLock }).keyboard,
9531032 storage: localStorage,
9541033 fetch: (url, init) => fetch(url, init),
1034 // One GET for one asset. Anything that is not a 200 with a body — a 404 from
1035 // a half-finished deploy, a proxy's error page, a connection that dropped —
1036 // arrives here as null and is the sentence in the menu.
1037 load: (url, then) => {
1038 fetch(url)
1039 .then((reply) => (reply.ok ? reply.text() : null))
1040 .then(then, () => then(null));
1041 },
9551042 randomUUID: () => crypto.randomUUID(),
9561043 origin: location.origin,
9571044 // The clipboard, if this browser has one. Read off `navigator` rather than
src/client/packed.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * The policy as it travels: the shape this game asks a policy for, and the one
3 * reader that turns a downloaded document back into a `Packed` — or into null,
4 * which is a sentence in the menu rather than a bot that thinks in zeros.
5 *
6 * The weights are no longer a literal in the bundle. They are `weights.json`
7 * beside this file, copied into `static/weights.<hash>.json` by the build and
8 * declared as this game's asset in `src/games.ts`; the page names it in a
9 * `<link>` and `src/client/main.ts` fetches it at boot. So the eight kilobytes
10 * of int8 a player downloads are cached by their own content hash and are not
11 * re-downloaded when one line of the game changes.
12 *
13 * That puts a network response on the path of every match, which makes this a
14 * boundary: what arrives is parsed here and checked against the shape the game
15 * was compiled for, field by field, before a single float is dequantised. A
16 * truncated file, a half-written deploy, a proxy that answered an HTML error
17 * page with a 200 — each of them is null here, and the menu says so.
18 *
19 * `scripts/train.ts` writes documents this accepts and `src/client/shipped.ts`
20 * reads the committed one through this same function, so the trainer, the
21 * arena, the tests and the browser all agree on what a policy file is.
22 */
23
24 import { HEADS } from "./action.ts";
25 import { OBS_SIZE } from "./observe.ts";
26 import { paramCount, type Packed, type Shape } from "../engine/index.ts";
27
28 /**
29 * The shape the game asks the policy for, and the shape the trainer trains:
30 * one observation per field `observe` fills, one head per choice `decode`
31 * reads, and a hidden width that is the trainer's to pick. It is a dozen bytes
32 * and it is in the bundle, because `makeBrain` sizes a bot's activations from
33 * it before any policy has arrived.
34 */
35 export const SHAPE: Shape = { obs: OBS_SIZE, hidden: 64, heads: HEADS };
36
37 /**
38 * How many tensors a policy is — `tensorsOf` order, so one scale each. It is
39 * written down rather than derived so that reading a document costs no policy:
40 * `src/client/packed.test.ts` holds it against `tensorsOf` itself.
41 */
42 export const TENSORS = 8;
43
44 /** Is this the shape this game was built for, field for field? */
45 const isShape = (value: unknown): boolean => {
46 if (typeof value !== "object" || value === null) return false;
47 const { obs, hidden, heads } = value as Partial<Shape>;
48 if (obs !== SHAPE.obs || hidden !== SHAPE.hidden) return false;
49 return Array.isArray(heads) && heads.length === SHAPE.heads.length &&
50 heads.every((n, i) => n === SHAPE.heads[i]);
51 };
52
53 /**
54 * One policy document in, a `Packed` out — or null, with nothing thrown and
55 * nothing half-read. The shape handed back is this module's own constant and
56 * never the object that arrived: what comes out of here is the shape the
57 * bundle was compiled against, or nothing came out at all.
58 */
59 export function parsePacked(text: string): Packed | null {
60 let value: unknown;
61 try {
62 value = JSON.parse(text);
63 } catch {
64 return null;
65 }
66 if (typeof value !== "object" || value === null) return null;
67 const { shape, scales, bytes } = value as Partial<Packed>;
68 if (!isShape(shape)) return null;
69 if (!Array.isArray(scales) || scales.length !== TENSORS) return null;
70 for (const scale of scales) if (typeof scale !== "number" || !(scale > 0) || !Number.isFinite(scale)) return null;
71 if (typeof bytes !== "string") return null;
72 let decoded: string;
73 try {
74 decoded = atob(bytes);
75 } catch {
76 return null;
77 }
78 // One byte per parameter: a truncated download is short here rather than a
79 // policy that quietly reads zeros off the end of its last tensor.
80 if (decoded.length !== paramCount(SHAPE)) return null;
81 return { shape: SHAPE, scales, bytes };
82 }
src/client/shipped.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * The weights this repository ships, read off disk — the door for everything
3 * that runs in Bun and never for a browser.
4 *
5 * A browser downloads `static/weights.<hash>.json` because the page named it;
6 * the arena, the trainer and the tests read `weights.json` out of the tree,
7 * because a file is a file here and a fetch would be a pretence. Both go
8 * through `parsePacked`, so there is one answer to "is this a policy" and the
9 * committed document is held to it every time this module is imported.
10 *
11 * It imports `node:fs`, which is the other half of "never for a browser": a
12 * bundle that reached for this would fail to build rather than ship eleven
13 * kilobytes nobody asked for. Nothing under `src/client/` imports it except
14 * the tests, and `src/client/bundle.test.ts` reads the committed bundle for
15 * the packed bytes and finds none of them.
16 */
17
18 import { readFileSync } from "node:fs";
19 import { join } from "node:path";
20 import { usePolicy } from "./bots.ts";
21 import { parsePacked } from "./packed.ts";
22 import { unpack, type Packed, type Policy } from "../engine/index.ts";
23
24 /** Where the trainer writes them and the build copies them from. */
25 export const WEIGHTS_FILE = join(import.meta.dir, "weights.json");
26
27 /** The committed policy document, parsed once, at import. */
28 export const PACKED: Packed = read(WEIGHTS_FILE);
29
30 function read(file: string): Packed {
31 const packed = parsePacked(readFileSync(file, "utf8"));
32 if (packed === null)
33 throw new Error(
34 `${file} is not a packed policy this game can run — run \`bun run scripts/train.ts\` to write one`,
35 );
36 return packed;
37 }
38
39 /** The shipped weights dequantised, once per process however often this is asked. */
40 let learned: Policy | null = null;
41
42 /**
43 * Hand the bots the shipped policy, the way `src/client/main.ts` hands them
44 * the downloaded one. Every Bun-side caller that wants a bot to think — the
45 * arena, the trainer through it, and the tests that play a round — says this
46 * once; a bot asked to think before anyone has is the error `policy()` throws,
47 * not a bot that aims at nothing.
48 */
49 export function useShipped(): Policy {
50 if (learned === null) learned = unpack(PACKED);
51 usePolicy(learned);
52 return learned;
53 }
src/client/stage.test-helper.ts +71 −4
@@ -28,6 +28,8 @@
2828 * and it no longer needs to: what the two shared is behind the door now.
2929 */
3030
31 import { readFileSync } from "node:fs";
32 import { join } from "node:path";
3133 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeKeyboard, type FakePaint, type FakeWindow } from "../engine/index.ts";
3234 import type { Audio } from "./audio.ts";
3335 import type { Mesh } from "../engine/index.ts";
@@ -97,6 +99,17 @@ export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null
9799 /** Where this stub page is, for the whole URL a copied link has to be. */
98100 export const ORIGIN = "https://cs.example";
99101
102 /**
103 * Where this page says its weights are. A content-hashed name like the real
104 * one, so nothing here can pass by matching a path the entry guessed: the
105 * entry reads this off the `#weights` link the shell renders, and the `load`
106 * below answers exactly this URL and nothing else.
107 */
108 export const WEIGHTS_URL = "/static/weights.0123456789.json";
109
110 /** The committed policy document, which is the file the build copies to static/. */
111 const WEIGHTS_TEXT = readFileSync(join(import.meta.dir, "weights.json"), "utf8");
112
100113 /** One POST the frame made, as it was made. */
101114 export type Post = { url: string; method: string; headers: Record<string, string>; body: string };
102115
@@ -149,6 +162,13 @@ export type FakeStage = {
149162 */
150163 headings: number[];
151164 store: Map<string, string>;
165 /** Every asset URL the entry asked to load, in order. */
166 loads: string[];
167 /**
168 * The loads this page is holding, when it was asked to hold them: call one to
169 * deliver the policy on the test's own clock, the way a slow connection does.
170 */
171 deliver(): void;
152172 /** The match ids handed out, newest last. */
153173 ids: string[];
154174 /** Every text the page put on the clipboard, in order. */
@@ -171,7 +191,7 @@ export type FakeStage = {
171191 * contract between the page and this file, so `src/server.test.ts` checks the
172192 * served page against this same list rather than a second copy of it.
173193 */
174 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
194 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
175195
176196 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
177197 const HIDDEN = ["pause", "buy", "touch", "full"];
@@ -201,7 +221,18 @@ const GLOBALS = ["document", "window", "localStorage", "fetch", "location"];
201221 * game's pause has to be honest on.
202222 */
203223 export function fakeStage(
204 options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean } = {},
224 options: {
225 webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean;
226 /**
227 * What this page's weights are: the committed ones, a deploy that lost them
228 * (`missing`), or bytes that are not a policy (`junk`).
229 */
230 weights?: "shipped" | "missing" | "junk";
231 /** Keep every load until the test delivers it, the way a slow connection does. */
232 hold?: boolean;
233 /** Leave the `#weights` link off the page entirely: a page that named nothing. */
234 unlinked?: boolean;
235 } = {},
205236 ): FakeStage {
206237 const page = fakePage({
207238 ids: PAGE_IDS,
@@ -214,6 +245,9 @@ export function fakeStage(
214245 });
215246 // Asked for with `paint` left on, so every canvas has its 2D recorder.
216247 const [scene, hud, plan] = page.canvases as [StageCanvas, StageCanvas, StageCanvas];
248 // The link the shell renders in the head, with the content-hashed URL on it.
249 // `unlinked: true` leaves it blank: a page that named no weights at all.
250 if (options.unlinked !== true) page.by("weights").setAttribute("href", WEIGHTS_URL);
217251 // The same switch on both sides of the seam: `webgl: false` is a browser with
218252 // no context to give, whether the frame asks this page's `host` for a renderer
219253 // or — as the committed bundle does — asks the canvas itself.
@@ -226,7 +260,16 @@ export function fakeStage(
226260 const sounds: string[] = [];
227261 const headings: number[] = [];
228262 const store = new Map<string, string>();
263 const loads: string[] = [];
264 const held: (() => void)[] = [];
229265 const ids: string[] = [];
266 /** What this page answers for one asset URL, and null for anything else. */
267 const assetText = (url: string): string | null =>
268 url !== WEIGHTS_URL || options.weights === "missing"
269 ? null
270 : options.weights === "junk"
271 ? "<!doctype html><p>not a policy"
272 : WEIGHTS_TEXT;
230273 const copied: string[] = [];
231274 let uuid = 0;
232275
@@ -270,6 +313,15 @@ export function fakeStage(
270313 posts.push(post);
271314 return stub.answer(post);
272315 },
316 // The assets the page linked, answered the way this stage was asked to:
317 // the committed weights by default, nothing at all for a page whose
318 // deploy lost them, junk for one that half-wrote them, and — when the
319 // test holds them — not yet, with the callback kept for it to fire.
320 load: (url, then) => {
321 loads.push(url);
322 if (options.hold === true) held.push(() => then(assetText(url)));
323 else then(assetText(url));
324 },
273325 randomUUID: () => {
274326 uuid += 1;
275327 ids.push(`00000000-0000-4000-8000-${String(uuid).padStart(12, "0")}`);
@@ -301,7 +353,10 @@ export function fakeStage(
301353 plan,
302354 buttons: page.buttons,
303355 frames: page.frames,
304 posts, draws, replaced, atlases, sounds, headings, store, ids, copied,
356 posts, draws, replaced, atlases, sounds, headings, store, loads, ids, copied,
357 deliver() {
358 for (const fire of held.splice(0)) fire();
359 },
305360 refuseCopy: false,
306361 by: page.by,
307362 step: page.step,
@@ -330,7 +385,19 @@ export function fakeStage(
330385 globals.window = page.window;
331386 globals.localStorage = stub.host.storage;
332387 globals.location = { origin: ORIGIN };
333 globals.fetch = (url: string, init: Parameters<Host["fetch"]>[1]) => stub.host.fetch(url, init);
388 // One global `fetch` for both jobs the entry has: a POST with an init is
389 // a result, and a bare GET is an asset. The shipped entry builds its own
390 // host out of this one name, so this is where the two part.
391 globals.fetch = (url: string, init?: Parameters<Host["fetch"]>[1]) => {
392 if (init !== undefined) return stub.host.fetch(url, init);
393 loads.push(url);
394 const text = assetText(url);
395 return Promise.resolve({
396 ok: text !== null,
397 status: text === null ? 404 : 200,
398 text: () => Promise.resolve(text ?? "not found"),
399 });
400 };
334401 return () => {
335402 for (const { key, there, value } of was)
336403 if (there) globals[key] = value;
src/client/weights.json added +1

Diff not shown — a line 11,119 characters wide (limit 2,000). +1

View file
src/client/weights.test.ts +2 −1
@@ -23,7 +23,8 @@ import { expect, test } from "bun:test";
2323 import { paramCount, tensorsOf, unpack, zeroPolicy } from "../engine/index.ts";
2424 import { HEADS } from "./action.ts";
2525 import { OBS_SIZE } from "./observe.ts";
26 import { CARD, PACKED, type Card } from "./weights.ts";
26 import { PACKED } from "./shipped.ts";
27 import { CARD, type Card } from "./weights.ts";
2728
2829 /** What `unpack(PACKED)` has dequantised to since the weights were trained. */
2930 const DIGEST = "755d03516ba0122e6b30a04ac575c311e652ef12f38276fa0e5eb6289ed34103";
src/client/weights.ts +9 −13

Diff not shown — a line 10,883 characters wide (limit 2,000). +9 −13

View file
src/games.ts +55 −1
@@ -17,6 +17,28 @@
1717 /** The bytes a bundle may not pass: raw on disk, and gzipped over the wire. */
1818 export type Ceiling = { raw: number; gzip: number };
1919
20 /**
21 * A file a game needs that is not its bundle: copied into
22 * `static/<name>.<hash>.<ext>` by the build exactly as a bundle is, served
23 * immutable for a year like one, and named for the game's page in a `<link
24 * rel="preload">` carrying this `name` as its id — which is how the entry
25 * finds it without a path spelled out in the client.
26 *
27 * It exists because the shooter's trained weights are eight kilobytes of int8
28 * that change only when somebody retrains: bundled, they were a fifth of the
29 * first thing a player downloads and were re-downloaded with every deploy that
30 * touched one line of the game. As their own content-named file they are
31 * fetched once and kept.
32 */
33 export type Asset = {
34 /** What it is filed under, and the id of the link that points at it. Plain, like a game's name. */
35 name: string;
36 /** The file the bytes are copied from, relative to the repository root. Its extension is the asset's. */
37 source: string;
38 /** The size it may not pass, on the same terms a bundle's is. */
39 ceiling: Ceiling;
40 };
41
2042 /** One game, whole: what it is called, where it answers, and what it ships. */
2143 export type Game = {
2244 /** The name its bundle is filed under: `static/<name>.<hash>.js`. Plain — a letter, then letters, digits and dashes. */
@@ -29,6 +51,8 @@ export type Game = {
2951 entry: string;
3052 /** The size that bundle may not pass. Not optional — a game that ships without a number is a game nobody is holding to one. */
3153 ceiling: Ceiling;
54 /** What else it downloads, each weighed and held to a ceiling of its own. Empty for a game that downloads nothing. */
55 assets: readonly Asset[];
3256 /** The one sentence the *other* pages say about this game. Text, escaped where it is rendered. Prose about a game belongs to that game, or it drifts. */
3357 blurb: string;
3458 };
@@ -357,6 +381,27 @@ export type Game = {
357381 * behind a near plane of six Source units. Both fit, so neither moved. Measured
358382 * 2026-09-21: app 98,617 raw / 44,304 gzipped, range 34,810 / 14,569,
359383 * example 21,115 / 9,189.
384 * #214 took the trained weights out of the entry bundle: they are
385 * `src/client/weights.json`, this game's first `Asset`, copied to
386 * `static/weights.<hash>.json` by the build and fetched at boot. So this is the
387 * first line on this page where a ceiling comes *down*: app 98,617 → 89,037 raw
388 * and 44,304 → 35,268 gzipped, which is 9,580 raw and 9,036 gzipped gone, and
389 * the ceiling is 100,000/45,000 → 98,000/39,000, today's size plus about ten
390 * per cent like every other number here. Nothing was deleted to get it — 10,878
391 * raw and 9,518 gzipped bytes of packed literal left, and about 1,900 raw came
392 * back as the reader that validates a downloaded policy, the two sentences the
393 * menu says about one that did not arrive, and the load itself. The weights
394 * became an asset of their own at 11,120 raw / 8,251 gzipped, under a ceiling
395 * of 12,300 / 9,100 on the same rule; the JSON is 242 raw bytes larger than the
396 * literal it replaced and 1,267 gzipped bytes smaller, because a bundle
397 * compresses around base64 worse than a file that is almost nothing else. A
398 * player downloads 100,157 raw / 43,519 gzipped across the two on a cold visit
399 * — 785 gzipped bytes less than before — and on the next deploy of the game
400 * downloads only the bundle, because the weights are named by their own
401 * content and change when somebody retrains. Neither other bundle moved a
402 * byte: range 34,810 raw / 14,569 gzipped and example 21,115 / 9,189, the same
403 * numbers #216 measured, and both declare no assets at all. Measured
404 * 2026-09-21 with #216 merged in.
360405 */
361406 export const GAMES: readonly Game[] = [
362407 {
@@ -364,7 +409,14 @@ export const GAMES: readonly Game[] = [
364409 href: "/",
365410 label: "cs",
366411 entry: "src/client/main.ts",
367 ceiling: { raw: 100_000, gzip: 45_000 },
412 ceiling: { raw: 98_000, gzip: 39_000 },
413 assets: [
414 {
415 name: "weights",
416 source: "src/client/weights.json",
417 ceiling: { raw: 12_300, gzip: 9_100 },
418 },
419 ],
368420 blurb:
369421 "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.",
370422 },
@@ -374,6 +426,7 @@ export const GAMES: readonly Game[] = [
374426 label: "range",
375427 entry: "src/range/main.ts",
376428 ceiling: { raw: 37_000, gzip: 15_500 },
429 assets: [],
377430 blurb:
378431 "thirty seconds on the clock and two drills down one lane — plates that pop up, and plates that slide across it — a shot traced in three dimensions and a score, every line of it out of the engine and none of it out of the shooter.",
379432 },
@@ -383,6 +436,7 @@ export const GAMES: readonly Game[] = [
383436 label: "example",
384437 entry: "src/example/main.ts",
385438 ceiling: { raw: 23_500, gzip: 10_200 },
439 assets: [],
386440 blurb:
387441 "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above for a browser that has none — the price tag on a third game.",
388442 },
src/pages/game.ts +3 −2
@@ -69,7 +69,7 @@ export const namedMap = (map: MapChoice): string | null =>
6969 map === "de_forge" || map === "generated" ? null : map;
7070
7171 /** What the shell needs that this page cannot know: the environment it runs in. */
72 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh"> & {
72 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "assets" | "ssh"> & {
7373 /** The forge identity the request carried, verified in `src/who.ts`. */
7474 who: Who;
7575 /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */
@@ -249,7 +249,8 @@ export const policyHtml = (card: Card): string =>
249249 ${num(card.maps - 1)} carved from seeds — and on ${num(card.unseenMaps)} more it was never trained on it
250250 still wins ${num(card.unseenWinRate)}% against the same bots' ${num(card.unseenBaselineWinRate)}%, which is
251251 what a map made the moment you press play asks of it. Its weights are about eight kilobytes
252 of int8 inside the bundle — there is no inference server and nothing is sent anywhere. Each
252 of int8, downloaded once beside the bundle and cached under their own name — there is no
253 inference server and nothing is sent anywhere. Each
253254 bot's own skill still decides how fast it can turn, how steady its hands are and how long it
254255 takes to react, so there is still a ladder from fodder to uncomfortable.</p>`;
255256
src/pages/shell.ts +31 −1
@@ -15,6 +15,16 @@
1515 * in none and gets no script element at all. Everything else in the document is
1616 * the same for both, which is the point of the shell.
1717 *
18 * A game's assets are the same bargain one step further: a game hands in
19 * `{ id, href }` for each file it downloads at boot and gets a `<link
20 * rel="preload">` in the head, high up, so the browser starts fetching it
21 * alongside the bundle instead of after the module has parsed. The id is the
22 * whole interface — the entry finds the link by `getElementById` and reads its
23 * `href` — so no path to a content-hashed file is ever written into a client.
24 * `crossorigin` is on it because a preload is only reused by a later `fetch()`
25 * when the two agree on mode and credentials, and that attribute is what makes
26 * them agree; without it the file would be downloaded twice.
27 *
1828 * Escaping is the contract at this boundary. A parameter that carries text is
1929 * escaped here (`title`, `heading`, `env`, `commit`, `ssh`, a touch label, an
2030 * id, a nav entry's href and label); a parameter that carries markup the
@@ -135,6 +145,21 @@ const FAVICON =
135145 "%3Cpath d='M8 2.2v3.4M8 10.4v3.4M2.2 8h3.4M10.4 8h3.4' stroke='%23e2b04a' stroke-width='1.4' stroke-linecap='round'/%3E" +
136146 "%3Ccircle cx='8' cy='8' r='1.3' fill='%236fa8e8'/%3E%3C/svg%3E";
137147
148 /**
149 * One file a page's game downloads at boot: the id the entry finds it by, and
150 * where it is. Both are text and both are escaped where they are rendered.
151 */
152 export type Linked = { id: string; href: string };
153
154 /** The preload links, in the head: one per asset, and nothing for a page with none. */
155 export const preloads = (assets: readonly Linked[] = []): string =>
156 assets
157 .map(
158 (asset) =>
159 `<link rel="preload" as="fetch" crossorigin id="${escape(asset.id)}" href="${escape(asset.href)}">\n`,
160 )
161 .join("");
162
138163 /** One touch button: the action `src/engine/controls.ts` holds, and its label. */
139164 export type TouchButton = { action: string; label: string };
140165
@@ -228,6 +253,11 @@ export type PageOptions = {
228253 * nothing leaves this out, which emits no `<script>` element at all.
229254 */
230255 script?: string;
256 /**
257 * The files this page's game fetches at boot, each preloaded and each findable
258 * by its id. Absent, or empty, is a page that downloads nothing but its script.
259 */
260 assets?: readonly Linked[];
231261 ssh: string | undefined;
232262 /** The sentence in front of the ssh command, when there is one. */
233263 terminalLead: string;
@@ -251,7 +281,7 @@ export function page(options: PageOptions): string {
251281 <meta charset="utf-8">
252282 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
253283 <link rel="icon" href="${FAVICON}">
254 <title>${escape(options.title)}</title>
284 ${preloads(options.assets)}<title>${escape(options.title)}</title>
255285 <meta name="description" content="${escape(options.description)}">
256286 <style>${styleFor(options.extraStyle)}</style>
257287 <header>
src/rl/arena.ts +6
@@ -14,6 +14,7 @@
1414
1515 import { decode, toInput, type Intent } from "../client/action.ts";
1616 import { makeBrain, botThink, ride, type BotBrain } from "../client/bots.ts";
17 import { useShipped } from "../client/shipped.ts";
1718 import { openCells, type Level } from "../client/level.ts";
1819 import { DE_FORGE } from "../client/map.ts";
1920 import { grip as gripOf, gripRate } from "../client/spray.ts";
@@ -102,6 +103,11 @@ export function policyFoe(think: (arena: Arena) => Intent): (arena: Arena, dt: n
102103 }
103104
104105 export function createArena(seed: number, opponent = scriptedFoe, level: Level = DE_FORGE): Arena {
106 // The bots in here think with the policy this repository ships — the scripted
107 // opponent's brain is the one this trained against, and `evaluate` plays the
108 // shipped one against it. In a browser `src/client/main.ts` hands the same
109 // function a downloaded policy; here it is read off disk, once per process.
110 useShipped();
105111 // Two players, one a side: createWorld with a team size of one gives exactly
106112 // that — the person's slot and one bot — and we drive both ourselves. The
107113 // level is de_forge because that is the game the policy has to be good at.
src/server.ts +38 −10
@@ -18,9 +18,10 @@
1818 * and the first paint stays two requests, without `'unsafe-inline'` waving in
1919 * every other inline style with it. `src/policy.ts` writes the sentence.
2020 *
21 * Both halves of a game are a start-up contract: an entry with no page and an
22 * entry with no bundle in static/ each throw before this listens, so neither is
23 * ever a 404 a visitor meets instead.
21 * Every half of a game is a start-up contract: an entry with no page, an entry
22 * with no bundle in static/, and an entry whose declared asset is not there
23 * each throw before this listens, so none of them is ever a 404 a visitor meets
24 * instead.
2425 *
2526 * A result is the browser's word for the numbers and nobody's word for the
2627 * name, so the name is not taken from it: `src/who.ts` verifies the forge's
@@ -31,7 +32,7 @@
3132
3233 import { existsSync, readdirSync } from "node:fs";
3334 import { join } from "node:path";
34 import { bundleOf, STATIC_DIR } from "../scripts/build.ts";
35 import { assetOf, bundleOf, STATIC_DIR } from "../scripts/build.ts";
3536 import { bind } from "./bind.ts";
3637 import { measureExample } from "./cost.ts";
3738 import { openDatabase, type Player, saveMatch } from "./db.ts";
@@ -44,7 +45,7 @@ import { matchFrom } from "./matchid.ts";
4445 import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
4546 import { rangePage } from "./pages/range.ts";
4647 import { policyFor } from "./policy.ts";
47 import { styleFor } from "./pages/shell.ts";
48 import { styleFor, type Linked } from "./pages/shell.ts";
4849 import { cleanName, parseResult } from "./result.ts";
4950 import { who, type Who } from "./who.ts";
5051
@@ -231,6 +232,28 @@ export function findScripts(dir = STATIC_DIR, games = GAMES): Map<string, string
231232 }
232233 const scripts = findScripts();
233234
235 /**
236 * And every game's assets, resolved the same way and for the same reason: the
237 * shooter's page links its weights by content hash, and a deploy that shipped
238 * the page without the file behind it would be a menu that says the policy did
239 * not load — honest, and useless. So it is a failed start here instead, with
240 * the game, the name it looked for and the command that writes it.
241 *
242 * Each is `{ id, href }` because that is what the page does with it: the id is
243 * the asset's name and the link that carries it is how the entry finds the
244 * URL without a path written into the client.
245 */
246 export function findAssets(dir = STATIC_DIR, games = GAMES): Map<string, Linked[]> {
247 const files = existsSync(dir) ? readdirSync(dir) : [];
248 return new Map(
249 games.map(({ name, assets }) => [
250 name,
251 assets.map((asset) => ({ id: asset.name, href: `/static/${assetOf(asset, files, dir)}` })),
252 ]),
253 );
254 }
255 const assets = findAssets();
256
234257 /**
235258 * What `/example` says a third game cost, weighed and counted once, here,
236259 * against the files this process serves: the committed bundles in static/ and
@@ -259,7 +282,7 @@ type Drawn = { body: string; extra?: Headers };
259282 * be, so this is the map, and one entry here is the whole of what a third game
260283 * adds to this file.
261284 */
262 type Render = { extraStyle?: string; draw: (request: Request, script: string) => Drawn };
285 type Render = { extraStyle?: string; draw: (request: Request, script: string, linked: Linked[]) => Drawn };
263286
264287 /**
265288 * What a link to the shooter asked for. `/?match=<id>` plays that match again
@@ -297,11 +320,14 @@ const RENDER: Record<string, Render> = {
297320 // session cookie for a browser that arrives without one.
298321 app: {
299322 extraStyle: GAME_STYLE,
300 draw: (request, script) => {
323 draw: (request, script, linked) => {
301324 const session = readSession(request);
302325 const extra: Headers = session === null ? { "set-cookie": sessionCookie(newSession()) } : {};
303326 return {
304 body: gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey), asked: asked(request) }),
327 body: gamePage(db, {
328 env, pull, commit, script, assets: linked, ssh,
329 who: who(request, identityKey), asked: asked(request),
330 }),
305331 extra,
306332 };
307333 },
@@ -325,15 +351,17 @@ const READ = new Map<string, (request: Request) => Response>(
325351 GAMES.map((game) => {
326352 const render = RENDER[game.name];
327353 if (render === undefined) throw new Error(`the game ${JSON.stringify(game.name)} has no page to render it`);
328 // Every game resolved to a bundle that exists, or this module threw above.
354 // Every game resolved to a bundle and to every asset it declared, or this
355 // module threw above.
329356 const script = scripts.get(game.name) as string;
357 const linked = assets.get(game.name) as Linked[];
330358 // This game's floor, hashed here and not again: the style block it serves
331359 // is the same on every request, so its policy is too.
332360 const security = documentFloor(render.extraStyle);
333361 return [
334362 game.href,
335363 (request: Request) => {
336 const { body, extra } = render.draw(request, script);
364 const { body, extra } = render.draw(request, script, linked);
337365 return html(body, security, 200, extra);
338366 },
339367 ];
static/app.1519a8ba7e.js deleted −58

Diff not shown — a line 79,254 characters wide (limit 2,000). −58

View file
static/app.b71cd5e5b8.js added +58

Diff not shown — a line 69,665 characters wide (limit 2,000). +58

View file
static/weights.f18906194d.json added +1

Diff not shown — a line 11,119 characters wide (limit 2,000). +1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs