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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/214-the-shipped-weights-stop-riding-in-the-e → main 2 commits fork
scripts/build.test.ts +50 −6
| @@ -3,8 +3,12 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } fro | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { |
| 6 | ASSETS, | |
| 7 | assetOf, | |
| 6 | 8 | bundle, |
| 9 | collect, | |
| 7 | 10 | ENTRIES, |
| 11 | isNamed, | |
| 8 | 12 | GZIP_LEVEL, |
| 9 | 13 | isBundleOf, |
| 10 | 14 | line, |
| @@ -38,7 +42,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 38 | 42 | |
| 39 | 43 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 40 | 44 | 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 } }, | |
| 42 | 46 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } }, |
| 43 | 47 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } }, |
| 44 | 48 | ]); |
| @@ -52,7 +56,7 @@ test("the entry table is what the app ships today, each with the size it may not | ||
| 52 | 56 | |
| 53 | 57 | test("every entry is bundled to its own name and its own content hash", async () => { |
| 54 | 58 | const { dir, entries } = fixture(); |
| 55 | const built = await write(entries, dir); | |
| 59 | const built = await write(entries, dir, []); | |
| 56 | 60 | expect(built.map((b) => b.name)).toEqual(["app", "sandbox"]); |
| 57 | 61 | for (const { name, file, code } of built) { |
| 58 | 62 | 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 () | ||
| 65 | 69 | |
| 66 | 70 | test("rebuilding one entry sweeps up its own stale bundle and leaves the other game alone", async () => { |
| 67 | 71 | const { dir, entries, source } = fixture(); |
| 68 | const first = await write(entries, dir); | |
| 72 | const first = await write(entries, dir, []); | |
| 69 | 73 | const [staleApp, sandbox] = first; |
| 70 | 74 | writeFileSync(join(dir, "keep.txt"), "not a bundle"); |
| 71 | 75 | |
| 72 | 76 | 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, []); | |
| 74 | 78 | const [freshApp, sandboxAgain] = second; |
| 75 | 79 | |
| 76 | 80 | expect(freshApp?.file).not.toBe(staleApp?.file); |
| @@ -159,7 +163,7 @@ test("the committed bundle of every entry is under its ceiling", () => { | ||
| 159 | 163 | |
| 160 | 164 | test("weighing a committed bundle reads the file and builds nothing", async () => { |
| 161 | 165 | const { dir, entries } = fixture(); |
| 162 | const [built] = await write(entries, dir); | |
| 166 | const [built] = await write(entries, dir, []); | |
| 163 | 167 | const file = readFileSync(join(dir, built?.file as string)); |
| 164 | 168 | // The same ruler `sizes` is: on disk, and gzipped at the pinned level. |
| 165 | 169 | expect(weigh("app", dir)).toEqual(sizes(file)); |
| @@ -172,10 +176,50 @@ test("weighing a committed bundle reads the file and builds nothing", async () = | ||
| 172 | 176 | expect(() => weigh("nowhere", dir)).toThrow(/has no bundle/); |
| 173 | 177 | }); |
| 174 | 178 | |
| 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 | ||
| 175 | 219 | test("the build prints each bundle's raw and gzipped size next to its name", () => { |
| 176 | 220 | const code = `console.log("hi");\n`; |
| 177 | 221 | 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 } }); | |
| 179 | 223 | expect(printed).toContain("static/app.6864d577a4.js"); |
| 180 | 224 | expect(printed).toContain(`${raw} B raw / 400`); |
| 181 | 225 | expect(printed).toContain(`${gzip} B gzip-${GZIP_LEVEL} / 300`); |
scripts/build.ts +90 −29
| @@ -1,20 +1,26 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 4 | 5 | * `bun run build` writes them; the test compares what is committed against what |
| 5 | 6 | * the sources produce now, so a stale bundle fails the check instead of the |
| 6 | 7 | * browser. Each entry also carries the size it may not pass, and `build.test.ts` |
| 7 | 8 | * 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. | |
| 8 | 14 | */ |
| 9 | 15 | |
| 10 | 16 | 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"; | |
| 13 | 19 | |
| 14 | 20 | const ROOT = join(import.meta.dir, ".."); |
| 15 | 21 | export const STATIC_DIR = join(ROOT, "static"); |
| 16 | 22 | |
| 17 | export type { Ceiling }; | |
| 23 | export type { Asset, Ceiling }; | |
| 18 | 24 | |
| 19 | 25 | /** |
| 20 | 26 | * 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 }; | ||
| 30 | 36 | */ |
| 31 | 37 | export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling })); |
| 32 | 38 | |
| 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 | ||
| 33 | 49 | /** |
| 34 | 50 | * The one level gzip is ever measured at here. Compression level is the knob |
| 35 | 51 | * 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 | ||
| 66 | 82 | // plain, so neither can be surprised by it. |
| 67 | 83 | const NAME = /^[a-z][a-z0-9-]*$/; |
| 68 | 84 | |
| 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); | |
| 72 | 88 | } |
| 73 | 89 | |
| 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 | ||
| 74 | 93 | /** |
| 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. | |
| 80 | 100 | * |
| 81 | 101 | * 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 | |
| 84 | 104 | * answer cannot drift from itself. |
| 85 | 105 | */ |
| 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)); | |
| 88 | 114 | const first = mine[0]; |
| 89 | 115 | if (mine.length === 1 && first !== undefined) return first; |
| 90 | 116 | const build = "run `bun run scripts/build.ts` and commit what it writes"; |
| 91 | 117 | throw new Error( |
| 92 | 118 | 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(", ")} — ` + | |
| 95 | 121 | `all but one are stale, so ${build}`, |
| 96 | 122 | ); |
| 97 | 123 | } |
| 98 | 124 | |
| 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 | ||
| 99 | 133 | /** |
| 100 | 134 | * What the committed bundle of `name` weighs right now: the one |
| 101 | 135 | * `<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 { | ||
| 110 | 144 | return sizes(readFileSync(join(dir, bundleOf(name, readdirSync(dir), dir)))); |
| 111 | 145 | } |
| 112 | 146 | |
| 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 | } | |
| 114 | 155 | |
| 115 | 156 | /** Bundle every entry and give each the file name its own contents deserve. */ |
| 116 | 157 | export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> { |
| @@ -128,24 +169,44 @@ export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> { | ||
| 128 | 169 | if (!out.success) throw new Error(out.logs.map(String).join("\n")); |
| 129 | 170 | const output = out.outputs[0]; |
| 130 | 171 | 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)); | |
| 134 | 173 | } |
| 135 | 174 | return built; |
| 136 | 175 | } |
| 137 | 176 | |
| 138 | 177 | /** |
| 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. | |
| 142 | 182 | */ |
| 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)]; | |
| 145 | 206 | mkdirSync(dir, { recursive: true }); |
| 146 | 207 | 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)); | |
| 149 | 210 | writeFileSync(join(dir, file), code); |
| 150 | 211 | } |
| 151 | 212 | return built; |
scripts/train.test.ts +36 −21
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * minutes between the two is not a thing a test suite should spend, and the |
| 5 | 5 | * duels that grade the shipped weights are `src/rl/learned.test.ts`'s. |
| 6 | 6 | * |
| 7 | * The test that matters is the last one: `weightsModule` handed what | |
| 7 | * The test that matters is the last one: `cardModule` handed what | |
| 8 | 8 | * `src/client/weights.ts` exports today gives that file back character for |
| 9 | 9 | * character. It says at once that the generator's formatting is what is in the |
| 10 | 10 | * 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"; | ||
| 17 | 17 | import { mkdtempSync, writeFileSync } from "node:fs"; |
| 18 | 18 | import { tmpdir } from "node:os"; |
| 19 | 19 | 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"; | |
| 22 | 24 | |
| 23 | 25 | const SOURCE = join(import.meta.dir, "..", "src", "client", "weights.ts"); |
| 24 | 26 | |
| @@ -87,46 +89,59 @@ test("a flag nobody knows, and a word that is not a flag, stop the run and say s | ||
| 87 | 89 | expect(USAGE).toContain(flag); |
| 88 | 90 | }); |
| 89 | 91 | |
| 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", () => { | |
| 91 | 93 | expect(options("--out", "/tmp/weights.ts").out).toBe("/tmp/weights.ts"); |
| 92 | 94 | expect(options("--out", "elsewhere/w.ts").out).toBe(resolve("elsewhere/w.ts")); |
| 93 | 95 | 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()); | |
| 94 | 112 | }); |
| 95 | 113 | |
| 96 | 114 | test("the generated module says not to edit it, and says it first", () => { |
| 97 | const text = weightsModule(PACKED, CARD); | |
| 115 | const text = cardModule(CARD); | |
| 98 | 116 | 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."); | |
| 100 | 118 | expect(text.indexOf("Do not edit by hand")).toBeLessThan(text.indexOf("export const CARD")); |
| 101 | 119 | expect(text.endsWith("\n")).toBe(true); |
| 102 | 120 | }); |
| 103 | 121 | |
| 104 | 122 | 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)); | |
| 106 | 124 | // 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)); | |
| 109 | 127 | 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)); | |
| 111 | 129 | }); |
| 112 | 130 | |
| 113 | 131 | 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)); | |
| 118 | 136 | const file = join(mkdtempSync(join(tmpdir(), "train-")), "weights.js"); |
| 119 | 137 | 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 }; | |
| 121 | 139 | const shipped = await import("../src/client/weights.ts"); |
| 122 | 140 | |
| 123 | 141 | expect(Object.keys(generated).sort()).toEqual(Object.keys(shipped).sort()); |
| 124 | 142 | 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"); | |
| 130 | 145 | }); |
| 131 | 146 | |
| 132 | 147 | 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", | ||
| 134 | 149 | // the generator's formatting moved under it. Both are worth a failure: the |
| 135 | 150 | // page reads the file in the tree, and every other test here reads the |
| 136 | 151 | // generator. |
| 137 | expect(weightsModule(PACKED, CARD)).toBe(await Bun.file(SOURCE).text()); | |
| 152 | expect(cardModule(CARD)).toBe(await Bun.file(SOURCE).text()); | |
| 138 | 153 | }); |
scripts/train.ts +63 −34
| @@ -1,12 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 4 | 8 | * |
| 5 | 9 | * bun run scripts/train.ts --bench # how fast the arena steps |
| 6 | 10 | * bun run scripts/train.ts --iters 300 # train and write the weights |
| 7 | 11 | * bun run scripts/train.ts --iters 300 --dry # train without writing |
| 8 | 12 | * 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 | |
| 10 | 15 | * |
| 11 | 16 | * Nothing here is imported by the server or the page: this file runs on a |
| 12 | 17 | * 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"; | ||
| 30 | 35 | import { |
| 31 | 36 | computeAdvantages, copyPolicy, createAdam, createPolicy, createRollout, forward, greedy, |
| 32 | 37 | newActivations, pack, rng, sample, unpack, update, DEFAULT_HYPER, |
| 33 | type Hyper, type Packed, type Policy, type Shape, | |
| 38 | type Hyper, type Packed, type Policy, | |
| 34 | 39 | } from "../src/engine/index.ts"; |
| 35 | 40 | import { OBS_SIZE } from "../src/client/observe.ts"; |
| 41 | import { SHAPE } from "../src/client/packed.ts"; | |
| 36 | 42 | import { |
| 37 | 43 | createArena, EPISODE_TICKS, observeFoe, observeLearner, policyFoe, relevel, reset, scriptedFoe, SKIP, |
| 38 | 44 | step, stepInput, stillFoe, type Arena, |
| @@ -42,13 +48,22 @@ import { DE_FORGE } from "../src/client/map.ts"; | ||
| 42 | 48 | import type { Level } from "../src/client/level.ts"; |
| 43 | 49 | import type { Card } from "../src/client/weights.ts"; |
| 44 | 50 | |
| 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 }; | |
| 46 | 58 | |
| 47 | 59 | // --- the boundary --------------------------------------------------------- |
| 48 | 60 | |
| 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. */ | |
| 50 | 62 | export const WEIGHTS = join(import.meta.dir, "..", "src", "client", "weights.ts"); |
| 51 | 63 | |
| 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 | ||
| 52 | 67 | /** Who a run practises against: the curriculum, or one opponent all the way. */ |
| 53 | 68 | export type Foe = "mixed" | "still" | "scripted"; |
| 54 | 69 | |
| @@ -67,8 +82,10 @@ export type Options = { | ||
| 67 | 82 | /** How many layouts it is graded on, none of which it trained on. */ |
| 68 | 83 | holdout: number; |
| 69 | 84 | 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. */ | |
| 71 | 86 | out: string; |
| 87 | /** Where the packed weights are written. Absolute, resolved against the cwd. */ | |
| 88 | policy: string; | |
| 72 | 89 | }; |
| 73 | 90 | |
| 74 | 91 | export const DEFAULTS: Options = { |
| @@ -82,6 +99,7 @@ export const DEFAULTS: Options = { | ||
| 82 | 99 | holdout: 24, |
| 83 | 100 | foe: "mixed", |
| 84 | 101 | out: WEIGHTS, |
| 102 | policy: POLICY, | |
| 85 | 103 | }; |
| 86 | 104 | |
| 87 | 105 | /** The flags that take a whole number, and the smallest one each will accept. */ |
| @@ -93,7 +111,7 @@ const NUMBERS = { | ||
| 93 | 111 | |
| 94 | 112 | const FOES: readonly Foe[] = ["mixed", "still", "scripted"]; |
| 95 | 113 | |
| 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(); | |
| 97 | 115 | |
| 98 | 116 | /** What the usage line says, printed under any complaint below. */ |
| 99 | 117 | export const USAGE = `usage: bun run scripts/train.ts [${KNOWN.join("] [")}]`; |
| @@ -121,10 +139,11 @@ export function parseOptions(argv: readonly string[]): Options { | ||
| 121 | 139 | const foe = value(flag, argv[++i]); |
| 122 | 140 | if (!FOES.includes(foe as Foe)) throw new Error(`--foe is one of ${FOES.join(", ")}, and got ${JSON.stringify(foe)}`); |
| 123 | 141 | 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); | |
| 128 | 147 | } else if (Object.hasOwn(NUMBERS, name)) { |
| 129 | 148 | const key = name as keyof typeof NUMBERS; |
| 130 | 149 | const text = value(flag, argv[++i]); |
| @@ -369,9 +388,11 @@ async function train(options: Options): Promise<void> { | ||
| 369 | 388 | `(${rate(unseenBaseline)}% won, ${draws(unseenBaseline)}% drawn)`); |
| 370 | 389 | |
| 371 | 390 | 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. | |
| 373 | 393 | const out = options.out; |
| 374 | writeFileSync(out, weightsModule(packed, { | |
| 394 | writeFileSync(options.policy, policyDocument(packed)); | |
| 395 | writeFileSync(out, cardModule({ | |
| 375 | 396 | trainedAt: new Date().toISOString().slice(0, 10), |
| 376 | 397 | decisions: envSteps, |
| 377 | 398 | envSteps: envSteps * SKIP, |
| @@ -387,26 +408,41 @@ async function train(options: Options): Promise<void> { | ||
| 387 | 408 | unseenBaselineWinRate: rate(unseenBaseline), |
| 388 | 409 | unseenDrawRate: draws(unseen), |
| 389 | 410 | })); |
| 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/"); | |
| 391 | 413 | } |
| 392 | 414 | |
| 393 | 415 | /** |
| 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. | |
| 403 | 420 | * |
| 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. | |
| 407 | 423 | */ |
| 424 | export const policyDocument = (packed: Packed): string => `${JSON.stringify(packed)}\n`; | |
| 408 | 425 | |
| 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 | */ | |
| 410 | 446 | |
| 411 | 447 | export type Card = { |
| 412 | 448 | trainedAt: string; |
| @@ -430,13 +466,6 @@ export type Card = { | ||
| 430 | 466 | }; |
| 431 | 467 | |
| 432 | 468 | 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 | }; | |
| 440 | 469 | `; |
| 441 | 470 | } |
| 442 | 471 |
src/client/action.test.ts +1 −1
| @@ -19,7 +19,7 @@ import { | ||
| 19 | 19 | } from "./action.ts"; |
| 20 | 20 | import { forward, greedy, newActivations, rng, sample, unpack } from "../engine/index.ts"; |
| 21 | 21 | import { OBS_SIZE } from "./observe.ts"; |
| 22 | import { PACKED } from "./weights.ts"; | |
| 22 | import { PACKED } from "./shipped.ts"; | |
| 23 | 23 | import { NO_INPUT } from "./world.ts"; |
| 24 | 24 | |
| 25 | 25 | /** 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { PACKED, useShipped } from "./shipped.ts"; | |
| 3 | import { unpack, type Policy } from "../engine/index.ts"; | |
| 2 | 4 | 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, | |
| 4 | 6 | ride, think, |
| 5 | 7 | } from "./bots.ts"; |
| 6 | 8 | import { grip } from "./spray.ts"; |
| @@ -12,6 +14,25 @@ import { DE_FORGE } from "./map.ts"; | ||
| 12 | 14 | import { COMPETITIVE } from "./match.ts"; |
| 13 | 15 | import { aimAt, applyInput, bodyCentre, createWorld, type Player } from "./world.ts"; |
| 14 | 16 | |
| 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 | ||
| 15 | 36 | const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => { |
| 16 | 37 | const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, seed); |
| 17 | 38 | world.match.phase = "live"; |
| @@ -156,14 +177,6 @@ test("a bot whose objective sits on a crate walks to it rather than standing sti | ||
| 156 | 177 | expect(dist(bot.pos, world.bomb.pos)).toBeLessThan(before - 200); |
| 157 | 178 | }); |
| 158 | 179 | |
| 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 | }); | |
| 167 | 180 | |
| 168 | 181 | test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, blinder", () => { |
| 169 | 182 | const { world, me, bot } = duel(); |
src/client/bots.ts +35 −9
| @@ -7,31 +7,54 @@ | ||
| 7 | 7 | * The split is deliberate. The round's objective is a plan (go to a site, plant |
| 8 | 8 | * the bomb, defuse it) and A* already solves it. The fight is a reflex, and a |
| 9 | 9 | * 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. | |
| 12 | 18 | */ |
| 13 | 19 | |
| 14 | 20 | import { decode, HEADS, STILL, type Intent } from "./action.ts"; |
| 15 | 21 | import { |
| 16 | angleDelta, clamp, dist, forward, newActivations, rng, sample, unpack, | |
| 22 | angleDelta, clamp, dist, forward, newActivations, rng, sample, | |
| 17 | 23 | type Activations, type Policy, type Vec3, |
| 18 | 24 | } from "../engine/index.ts"; |
| 19 | 25 | import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts"; |
| 20 | 26 | import { CS_HULL } from "./movement.ts"; |
| 21 | 27 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 28 | import { SHAPE } from "./packed.ts"; | |
| 22 | 29 | import { ease, grip, gripRate, sprayPull } from "./spray.ts"; |
| 23 | import { CARD, PACKED } from "./weights.ts"; | |
| 24 | 30 | import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; |
| 25 | 31 | import { buy } from "./economy.ts"; |
| 26 | 32 | import type { Input, Player, World } from "./world.ts"; |
| 27 | 33 | import { aimAt, bodyCentre, canSee, NO_INPUT } from "./world.ts"; |
| 28 | 34 | |
| 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 | */ | |
| 32 | 40 | 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 | */ | |
| 33 | 56 | export function policy(): Policy { |
| 34 | if (learned === null) learned = unpack(PACKED); | |
| 57 | if (learned === null) throw new Error(NO_POLICY); | |
| 35 | 58 | return learned; |
| 36 | 59 | } |
| 37 | 60 | |
| @@ -91,7 +114,10 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 91 | 114 | site: "A", reset: true, |
| 92 | 115 | random: rng(seed), |
| 93 | 116 | 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), | |
| 95 | 121 | actions: new Int32Array(HEADS.length), |
| 96 | 122 | memory: { sinceSeen: 9 }, |
| 97 | 123 | intent: { ...STILL }, |
src/client/main.ts +94 −7
| @@ -65,13 +65,16 @@ import type { Level } from "./level.ts"; | ||
| 65 | 65 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 66 | 66 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 67 | 67 | import { meshFor } from "./models.ts"; |
| 68 | import { parsePacked } from "./packed.ts"; | |
| 68 | 69 | import { nextStep, type Answer } from "./pending.ts"; |
| 69 | 70 | import { drawPreview, previewText } from "./preview.ts"; |
| 70 | 71 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 72 | import { usePolicy } from "./bots.ts"; | |
| 71 | 73 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 72 | 74 | import { buyable, WEAPONS } from "./weapons.ts"; |
| 73 | 75 | import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; |
| 74 | 76 | import { createWorld, eyePos, update, type Player, type World } from "./world.ts"; |
| 77 | import { unpack } from "../engine/index.ts"; | |
| 75 | 78 | |
| 76 | 79 | const TICK = 1 / 64; |
| 77 | 80 | |
| @@ -95,6 +98,23 @@ export const NOT_AN_ID = "That is not a match id — playing a fresh match inste | ||
| 95 | 98 | /** And what it says when the Map name field holds something that is not one. */ |
| 96 | 99 | export const NOT_A_MAP = "That is not a map name — playing de_forge instead."; |
| 97 | 100 | |
| 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 | ||
| 98 | 118 | /** What the copy control says once the URL is on the clipboard, and if it is not. */ |
| 99 | 119 | export const COPIED = "Copied"; |
| 100 | 120 | export const NOT_COPIED = "Copy failed — the link still works"; |
| @@ -130,6 +150,13 @@ export type Sheet = Panel & { | ||
| 130 | 150 | getContext(kind: "2d"): Paint | null; |
| 131 | 151 | }; |
| 132 | 152 | |
| 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 | ||
| 133 | 160 | /** A button that says which of two states it is in — the full screen one does. */ |
| 134 | 161 | export type Toggle = Panel & { setAttribute(name: string, value: string): void }; |
| 135 | 162 | |
| @@ -200,6 +227,16 @@ export type Host = { | ||
| 200 | 227 | keyboard?: KeyboardLock; |
| 201 | 228 | storage: Store; |
| 202 | 229 | 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; | |
| 203 | 240 | /** |
| 204 | 241 | * A fresh id: the match when nobody asked for one, and the nonce on the row |
| 205 | 242 | * key when somebody did. `crypto.randomUUID` is one; `src/matchid.ts` says |
| @@ -287,6 +324,9 @@ export function boot(host: Host): Frame { | ||
| 287 | 324 | const copyLabel = copyMap.textContent; |
| 288 | 325 | const prep = by("prep"); |
| 289 | 326 | 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; | |
| 290 | 330 | const resumeButton = by("resume"); |
| 291 | 331 | // The line in the pause panel that says what Escape just did on this browser. |
| 292 | 332 | const escHint = by("esc"); |
| @@ -417,11 +457,50 @@ export function boot(host: Host): Frame { | ||
| 417 | 457 | }; |
| 418 | 458 | |
| 419 | 459 | // 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. | |
| 422 | 462 | play.disabled = true; |
| 423 | 463 | show(prep, true); |
| 424 | 464 | |
| 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 | ||
| 425 | 504 | // --- the buy menu, rebuilt whenever it opens so the prices are honest |
| 426 | 505 | function renderBuy(): void { |
| 427 | 506 | if (world === null) return; |
| @@ -705,9 +784,10 @@ export function boot(host: Host): Frame { | ||
| 705 | 784 | nameNextMap(); |
| 706 | 785 | |
| 707 | 786 | 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; | |
| 711 | 791 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 712 | 792 | const team = sideField.value === "t" ? "t" : "ct"; |
| 713 | 793 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| @@ -835,8 +915,7 @@ export function boot(host: Host): Frame { | ||
| 835 | 915 | if (renderer === null) { |
| 836 | 916 | if (!atlas.paint(TEX_SIZE)) return; |
| 837 | 917 | renderer = build(atlas.bytes); |
| 838 | show(prep, false); | |
| 839 | play.disabled = false; | |
| 918 | ready(); | |
| 840 | 919 | return; |
| 841 | 920 | } |
| 842 | 921 | // The round that has not landed, tried again on the frame its backoff runs |
| @@ -952,6 +1031,14 @@ const browser = (): Host => ({ | ||
| 952 | 1031 | keyboard: (navigator as { keyboard?: KeyboardLock }).keyboard, |
| 953 | 1032 | storage: localStorage, |
| 954 | 1033 | 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 | }, | |
| 955 | 1042 | randomUUID: () => crypto.randomUUID(), |
| 956 | 1043 | origin: location.origin, |
| 957 | 1044 | // 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 @@ | ||
| 28 | 28 | * and it no longer needs to: what the two shared is behind the door now. |
| 29 | 29 | */ |
| 30 | 30 | |
| 31 | import { readFileSync } from "node:fs"; | |
| 32 | import { join } from "node:path"; | |
| 31 | 33 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeKeyboard, type FakePaint, type FakeWindow } from "../engine/index.ts"; |
| 32 | 34 | import type { Audio } from "./audio.ts"; |
| 33 | 35 | import type { Mesh } from "../engine/index.ts"; |
| @@ -97,6 +99,17 @@ export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null | ||
| 97 | 99 | /** Where this stub page is, for the whole URL a copied link has to be. */ |
| 98 | 100 | export const ORIGIN = "https://cs.example"; |
| 99 | 101 | |
| 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 | ||
| 100 | 113 | /** One POST the frame made, as it was made. */ |
| 101 | 114 | export type Post = { url: string; method: string; headers: Record<string, string>; body: string }; |
| 102 | 115 | |
| @@ -149,6 +162,13 @@ export type FakeStage = { | ||
| 149 | 162 | */ |
| 150 | 163 | headings: number[]; |
| 151 | 164 | 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; | |
| 152 | 172 | /** The match ids handed out, newest last. */ |
| 153 | 173 | ids: string[]; |
| 154 | 174 | /** Every text the page put on the clipboard, in order. */ |
| @@ -171,7 +191,7 @@ export type FakeStage = { | ||
| 171 | 191 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 172 | 192 | * served page against this same list rather than a second copy of it. |
| 173 | 193 | */ |
| 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"]; | |
| 175 | 195 | |
| 176 | 196 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 177 | 197 | const HIDDEN = ["pause", "buy", "touch", "full"]; |
| @@ -201,7 +221,18 @@ const GLOBALS = ["document", "window", "localStorage", "fetch", "location"]; | ||
| 201 | 221 | * game's pause has to be honest on. |
| 202 | 222 | */ |
| 203 | 223 | 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 | } = {}, | |
| 205 | 236 | ): FakeStage { |
| 206 | 237 | const page = fakePage({ |
| 207 | 238 | ids: PAGE_IDS, |
| @@ -214,6 +245,9 @@ export function fakeStage( | ||
| 214 | 245 | }); |
| 215 | 246 | // Asked for with `paint` left on, so every canvas has its 2D recorder. |
| 216 | 247 | 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); | |
| 217 | 251 | // The same switch on both sides of the seam: `webgl: false` is a browser with |
| 218 | 252 | // no context to give, whether the frame asks this page's `host` for a renderer |
| 219 | 253 | // or — as the committed bundle does — asks the canvas itself. |
| @@ -226,7 +260,16 @@ export function fakeStage( | ||
| 226 | 260 | const sounds: string[] = []; |
| 227 | 261 | const headings: number[] = []; |
| 228 | 262 | const store = new Map<string, string>(); |
| 263 | const loads: string[] = []; | |
| 264 | const held: (() => void)[] = []; | |
| 229 | 265 | 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; | |
| 230 | 273 | const copied: string[] = []; |
| 231 | 274 | let uuid = 0; |
| 232 | 275 | |
| @@ -270,6 +313,15 @@ export function fakeStage( | ||
| 270 | 313 | posts.push(post); |
| 271 | 314 | return stub.answer(post); |
| 272 | 315 | }, |
| 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 | }, | |
| 273 | 325 | randomUUID: () => { |
| 274 | 326 | uuid += 1; |
| 275 | 327 | ids.push(`00000000-0000-4000-8000-${String(uuid).padStart(12, "0")}`); |
| @@ -301,7 +353,10 @@ export function fakeStage( | ||
| 301 | 353 | plan, |
| 302 | 354 | buttons: page.buttons, |
| 303 | 355 | 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 | }, | |
| 305 | 360 | refuseCopy: false, |
| 306 | 361 | by: page.by, |
| 307 | 362 | step: page.step, |
| @@ -330,7 +385,19 @@ export function fakeStage( | ||
| 330 | 385 | globals.window = page.window; |
| 331 | 386 | globals.localStorage = stub.host.storage; |
| 332 | 387 | 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 | }; | |
| 334 | 401 | return () => { |
| 335 | 402 | for (const { key, there, value } of was) |
| 336 | 403 | 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 filesrc/client/weights.test.ts +2 −1
| @@ -23,7 +23,8 @@ import { expect, test } from "bun:test"; | ||
| 23 | 23 | import { paramCount, tensorsOf, unpack, zeroPolicy } from "../engine/index.ts"; |
| 24 | 24 | import { HEADS } from "./action.ts"; |
| 25 | 25 | 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"; | |
| 27 | 28 | |
| 28 | 29 | /** What `unpack(PACKED)` has dequantised to since the weights were trained. */ |
| 29 | 30 | const DIGEST = "755d03516ba0122e6b30a04ac575c311e652ef12f38276fa0e5eb6289ed34103"; |
src/client/weights.ts +9 −13
Diff not shown — a line 10,883 characters wide (limit 2,000). +9 −13
View filesrc/games.ts +55 −1
| @@ -17,6 +17,28 @@ | ||
| 17 | 17 | /** The bytes a bundle may not pass: raw on disk, and gzipped over the wire. */ |
| 18 | 18 | export type Ceiling = { raw: number; gzip: number }; |
| 19 | 19 | |
| 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 | ||
| 20 | 42 | /** One game, whole: what it is called, where it answers, and what it ships. */ |
| 21 | 43 | export type Game = { |
| 22 | 44 | /** 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 = { | ||
| 29 | 51 | entry: string; |
| 30 | 52 | /** The size that bundle may not pass. Not optional — a game that ships without a number is a game nobody is holding to one. */ |
| 31 | 53 | 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[]; | |
| 32 | 56 | /** 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. */ |
| 33 | 57 | blurb: string; |
| 34 | 58 | }; |
| @@ -357,6 +381,27 @@ export type Game = { | ||
| 357 | 381 | * behind a near plane of six Source units. Both fit, so neither moved. Measured |
| 358 | 382 | * 2026-09-21: app 98,617 raw / 44,304 gzipped, range 34,810 / 14,569, |
| 359 | 383 | * 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. | |
| 360 | 405 | */ |
| 361 | 406 | export const GAMES: readonly Game[] = [ |
| 362 | 407 | { |
| @@ -364,7 +409,14 @@ export const GAMES: readonly Game[] = [ | ||
| 364 | 409 | href: "/", |
| 365 | 410 | label: "cs", |
| 366 | 411 | 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 | ], | |
| 368 | 420 | blurb: |
| 369 | 421 | "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.", |
| 370 | 422 | }, |
| @@ -374,6 +426,7 @@ export const GAMES: readonly Game[] = [ | ||
| 374 | 426 | label: "range", |
| 375 | 427 | entry: "src/range/main.ts", |
| 376 | 428 | ceiling: { raw: 37_000, gzip: 15_500 }, |
| 429 | assets: [], | |
| 377 | 430 | blurb: |
| 378 | 431 | "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.", |
| 379 | 432 | }, |
| @@ -383,6 +436,7 @@ export const GAMES: readonly Game[] = [ | ||
| 383 | 436 | label: "example", |
| 384 | 437 | entry: "src/example/main.ts", |
| 385 | 438 | ceiling: { raw: 23_500, gzip: 10_200 }, |
| 439 | assets: [], | |
| 386 | 440 | blurb: |
| 387 | 441 | "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.", |
| 388 | 442 | }, |
src/pages/game.ts +3 −2
| @@ -69,7 +69,7 @@ export const namedMap = (map: MapChoice): string | null => | ||
| 69 | 69 | map === "de_forge" || map === "generated" ? null : map; |
| 70 | 70 | |
| 71 | 71 | /** 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"> & { | |
| 73 | 73 | /** The forge identity the request carried, verified in `src/who.ts`. */ |
| 74 | 74 | who: Who; |
| 75 | 75 | /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */ |
| @@ -249,7 +249,8 @@ export const policyHtml = (card: Card): string => | ||
| 249 | 249 | ${num(card.maps - 1)} carved from seeds — and on ${num(card.unseenMaps)} more it was never trained on it |
| 250 | 250 | still wins ${num(card.unseenWinRate)}% against the same bots' ${num(card.unseenBaselineWinRate)}%, which is |
| 251 | 251 | 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 | |
| 253 | 254 | bot's own skill still decides how fast it can turn, how steady its hands are and how long it |
| 254 | 255 | takes to react, so there is still a ladder from fodder to uncomfortable.</p>`; |
| 255 | 256 |
src/pages/shell.ts +31 −1
| @@ -15,6 +15,16 @@ | ||
| 15 | 15 | * in none and gets no script element at all. Everything else in the document is |
| 16 | 16 | * the same for both, which is the point of the shell. |
| 17 | 17 | * |
| 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 | * | |
| 18 | 28 | * Escaping is the contract at this boundary. A parameter that carries text is |
| 19 | 29 | * escaped here (`title`, `heading`, `env`, `commit`, `ssh`, a touch label, an |
| 20 | 30 | * id, a nav entry's href and label); a parameter that carries markup the |
| @@ -135,6 +145,21 @@ const FAVICON = | ||
| 135 | 145 | "%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" + |
| 136 | 146 | "%3Ccircle cx='8' cy='8' r='1.3' fill='%236fa8e8'/%3E%3C/svg%3E"; |
| 137 | 147 | |
| 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 | ||
| 138 | 163 | /** One touch button: the action `src/engine/controls.ts` holds, and its label. */ |
| 139 | 164 | export type TouchButton = { action: string; label: string }; |
| 140 | 165 | |
| @@ -228,6 +253,11 @@ export type PageOptions = { | ||
| 228 | 253 | * nothing leaves this out, which emits no `<script>` element at all. |
| 229 | 254 | */ |
| 230 | 255 | 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[]; | |
| 231 | 261 | ssh: string | undefined; |
| 232 | 262 | /** The sentence in front of the ssh command, when there is one. */ |
| 233 | 263 | terminalLead: string; |
| @@ -251,7 +281,7 @@ export function page(options: PageOptions): string { | ||
| 251 | 281 | <meta charset="utf-8"> |
| 252 | 282 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 253 | 283 | <link rel="icon" href="${FAVICON}"> |
| 254 | <title>${escape(options.title)}</title> | |
| 284 | ${preloads(options.assets)}<title>${escape(options.title)}</title> | |
| 255 | 285 | <meta name="description" content="${escape(options.description)}"> |
| 256 | 286 | <style>${styleFor(options.extraStyle)}</style> |
| 257 | 287 | <header> |
src/rl/arena.ts +6
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | import { decode, toInput, type Intent } from "../client/action.ts"; |
| 16 | 16 | import { makeBrain, botThink, ride, type BotBrain } from "../client/bots.ts"; |
| 17 | import { useShipped } from "../client/shipped.ts"; | |
| 17 | 18 | import { openCells, type Level } from "../client/level.ts"; |
| 18 | 19 | import { DE_FORGE } from "../client/map.ts"; |
| 19 | 20 | import { grip as gripOf, gripRate } from "../client/spray.ts"; |
| @@ -102,6 +103,11 @@ export function policyFoe(think: (arena: Arena) => Intent): (arena: Arena, dt: n | ||
| 102 | 103 | } |
| 103 | 104 | |
| 104 | 105 | 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(); | |
| 105 | 111 | // Two players, one a side: createWorld with a team size of one gives exactly |
| 106 | 112 | // that — the person's slot and one bot — and we drive both ourselves. The |
| 107 | 113 | // 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 @@ | ||
| 18 | 18 | * and the first paint stays two requests, without `'unsafe-inline'` waving in |
| 19 | 19 | * every other inline style with it. `src/policy.ts` writes the sentence. |
| 20 | 20 | * |
| 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. | |
| 24 | 25 | * |
| 25 | 26 | * A result is the browser's word for the numbers and nobody's word for the |
| 26 | 27 | * name, so the name is not taken from it: `src/who.ts` verifies the forge's |
| @@ -31,7 +32,7 @@ | ||
| 31 | 32 | |
| 32 | 33 | import { existsSync, readdirSync } from "node:fs"; |
| 33 | 34 | import { join } from "node:path"; |
| 34 | import { bundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 35 | import { assetOf, bundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 35 | 36 | import { bind } from "./bind.ts"; |
| 36 | 37 | import { measureExample } from "./cost.ts"; |
| 37 | 38 | import { openDatabase, type Player, saveMatch } from "./db.ts"; |
| @@ -44,7 +45,7 @@ import { matchFrom } from "./matchid.ts"; | ||
| 44 | 45 | import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; |
| 45 | 46 | import { rangePage } from "./pages/range.ts"; |
| 46 | 47 | import { policyFor } from "./policy.ts"; |
| 47 | import { styleFor } from "./pages/shell.ts"; | |
| 48 | import { styleFor, type Linked } from "./pages/shell.ts"; | |
| 48 | 49 | import { cleanName, parseResult } from "./result.ts"; |
| 49 | 50 | import { who, type Who } from "./who.ts"; |
| 50 | 51 | |
| @@ -231,6 +232,28 @@ export function findScripts(dir = STATIC_DIR, games = GAMES): Map<string, string | ||
| 231 | 232 | } |
| 232 | 233 | const scripts = findScripts(); |
| 233 | 234 | |
| 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 | ||
| 234 | 257 | /** |
| 235 | 258 | * What `/example` says a third game cost, weighed and counted once, here, |
| 236 | 259 | * against the files this process serves: the committed bundles in static/ and |
| @@ -259,7 +282,7 @@ type Drawn = { body: string; extra?: Headers }; | ||
| 259 | 282 | * be, so this is the map, and one entry here is the whole of what a third game |
| 260 | 283 | * adds to this file. |
| 261 | 284 | */ |
| 262 | type Render = { extraStyle?: string; draw: (request: Request, script: string) => Drawn }; | |
| 285 | type Render = { extraStyle?: string; draw: (request: Request, script: string, linked: Linked[]) => Drawn }; | |
| 263 | 286 | |
| 264 | 287 | /** |
| 265 | 288 | * What a link to the shooter asked for. `/?match=<id>` plays that match again |
| @@ -297,11 +320,14 @@ const RENDER: Record<string, Render> = { | ||
| 297 | 320 | // session cookie for a browser that arrives without one. |
| 298 | 321 | app: { |
| 299 | 322 | extraStyle: GAME_STYLE, |
| 300 | draw: (request, script) => { | |
| 323 | draw: (request, script, linked) => { | |
| 301 | 324 | const session = readSession(request); |
| 302 | 325 | const extra: Headers = session === null ? { "set-cookie": sessionCookie(newSession()) } : {}; |
| 303 | 326 | 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 | }), | |
| 305 | 331 | extra, |
| 306 | 332 | }; |
| 307 | 333 | }, |
| @@ -325,15 +351,17 @@ const READ = new Map<string, (request: Request) => Response>( | ||
| 325 | 351 | GAMES.map((game) => { |
| 326 | 352 | const render = RENDER[game.name]; |
| 327 | 353 | 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. | |
| 329 | 356 | const script = scripts.get(game.name) as string; |
| 357 | const linked = assets.get(game.name) as Linked[]; | |
| 330 | 358 | // This game's floor, hashed here and not again: the style block it serves |
| 331 | 359 | // is the same on every request, so its policy is too. |
| 332 | 360 | const security = documentFloor(render.extraStyle); |
| 333 | 361 | return [ |
| 334 | 362 | game.href, |
| 335 | 363 | (request: Request) => { |
| 336 | const { body, extra } = render.draw(request, script); | |
| 364 | const { body, extra } = render.draw(request, script, linked); | |
| 337 | 365 | return html(body, security, 200, extra); |
| 338 | 366 | }, |
| 339 | 367 | ]; |
static/app.1519a8ba7e.js deleted −58
Diff not shown — a line 79,254 characters wide (limit 2,000). −58
View filestatic/app.b71cd5e5b8.js added +58
Diff not shown — a line 69,665 characters wide (limit 2,000). +58
View filestatic/weights.f18906194d.json added +1
Diff not shown — a line 11,119 characters wide (limit 2,000). +1
View file