Fresh 2 + Deno: append-only JSONL log, referee with frontier gating and reward by cell count, /mcp (start_mosaic, observe, reveal, commit, retract), SSE live fold and replay, viewer with value-blind overview, discs at zoom, shape outlines and holes, scripted client with per-line solver and SSE keepalive handling; v2 logs normalised on load; tests for geometry, colour, delta coding, gating, live==replay and on-disk logs.
- commit
- 8fcb576cde71cc332c49ab9700d14abf17b04e83
- parent
- 4d39f71fc595a33887036143c48a6823ccfc6c3b
- author
- dami <gazey55@gmail.com>
| A | site/ | 3 | + |
| A | site/ | 19 | + |
| A | site/ | 51 | + |
| A | site/ | 1276 | ++++++++++++++++++++++++++++++ |
| A | site/ | 38 | + |
| A | site/ | 545 | +++++++++++++ |
| A | site/ | 192 | +++++ |
| A | site/ | 137 | +++ |
| A | site/ | 712 | +++++++++++++++++ |
| A | site/ | 60 | + |
| A | site/ | 71 | ++ |
| A | site/ | 219 | +++++ |
| A | site/ | 482 | +++++++++++ |
| A | site/ | 7 | + |
| A | site/ | 24 | + |
| A | site/ | 82 | ++ |
| A | site/ | 9 | + |
| A | site/ | 25 | + |
| A | site/ | 32 | + |
| A | site/ | 333 | ++++++++ |
| A | site/ | 268 | ++++++ |
| A | site/ | 237 | ++++++ |
| A | site/ | 282 | +++++++ |
| A | site/ | 6 | + |
| A | site/ | 8 | + |
@@ -0,0 +1,19 @@
1+# mosaic site2+3+Deno 2 + Fresh 2. Owns game state, the append-only JSONL log, `/mcp`, SSE, and4+the spectator. Puzzle rules live only in `../engine` (Go).5+6+ deno install # once: node_modules for vite/preact7+ deno task engine # builds ../engine/bin/mosaic-engine8+ deno task dev # prints the agent prompt, serves 127.0.0.1:80009+ deno task test # fold/replay equality, referee gating, geometry10+ deno task check # fmt, lint, types11+ deno task solve -- --seed a # scripted client plays a whole game via /mcp12+13+Routes: `/` viewer (live; `/?replay=<game_id>` replays from the log), `/mcp`14+streamable HTTP MCP (authless, localhost only), `/events/:id` SSE (`live`15+follows the newest game), `/log/:id` JSONL.16+17+Generation is cached under `data/cache/` by `sha256(seed|X|n|m)`; game logs are18+written to `data/games/<id>.jsonl`. The private solution is never in the log, so19+a restarted server can replay but not continue a game.@@ -0,0 +1,51 @@
1+{2+ "nodeModulesDir": "manual",3+ "tasks": {4+ "check": "deno fmt --check . && deno lint . && deno check main.ts dev.ts islands/Viewer.tsx lib/*.ts routes/**/*.ts routes/*.tsx scripts/*.ts tests/*.ts",5+ "test": "deno test -A tests/",6+ "dev": "deno run -A dev.ts",7+ "build": "vite build",8+ "start": "deno serve -A --host 127.0.0.1 --port 8000 _fresh/server.js",9+ "engine": "cd ../engine && go build -o bin/mosaic-engine .",10+ "solve": "deno run -A scripts/solve.ts"11+ },12+ "lint": {13+ "rules": {14+ "tags": ["fresh", "recommended"]15+ }16+ },17+ "exclude": ["**/_fresh/*", "node_modules/**", "data/**"],18+ "imports": {19+ "@/": "./",20+ "@std/assert": "jsr:@std/assert@^1.0.19",21+ "fresh": "jsr:@fresh/core@^2.2.2",22+ "preact": "npm:preact@^10.27.2",23+ "@preact/signals": "npm:@preact/signals@^2.5.0",24+ "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8",25+ "vite": "npm:vite@^7.1.3",26+ "@modelcontextprotocol/server": "npm:@modelcontextprotocol/server@^2.0.0",27+ "zod": "npm:zod@^4.2.0"28+ },29+ "compilerOptions": {30+ "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns"],31+ "jsx": "precompile",32+ "jsxImportSource": "preact",33+ "jsxPrecompileSkipElements": [34+ "a",35+ "img",36+ "source",37+ "body",38+ "html",39+ "head",40+ "title",41+ "meta",42+ "script",43+ "link",44+ "style",45+ "base",46+ "noscript",47+ "template"48+ ],49+ "types": ["vite/client"]50+ }51+}@@ -0,0 +1,1276 @@
1+{2+ "version": "5",3+ "specifiers": {4+ "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1",5+ "jsr:@deno/loader@~0.3.10": "0.3.14",6+ "jsr:@deno/loader@~0.3.2": "0.3.14",7+ "jsr:@fresh/build-id@1": "1.0.1",8+ "jsr:@fresh/core@2": "2.2.2",9+ "jsr:@fresh/core@^2.2.0": "2.2.2",10+ "jsr:@fresh/core@^2.2.2": "2.2.2",11+ "jsr:@fresh/plugin-vite@^1.0.8": "1.0.8",12+ "jsr:@std/assert@^1.0.19": "1.0.19",13+ "jsr:@std/bytes@^1.0.6": "1.0.6",14+ "jsr:@std/dotenv@~0.225.5": "0.225.7",15+ "jsr:@std/encoding@^1.0.10": "1.0.11",16+ "jsr:@std/encoding@^1.0.11": "1.0.11",17+ "jsr:@std/fmt@^1.0.7": "1.0.10",18+ "jsr:@std/fmt@^1.0.8": "1.0.10",19+ "jsr:@std/fs@^1.0.19": "1.0.24",20+ "jsr:@std/html@^1.0.5": "1.0.7",21+ "jsr:@std/http@^1.0.21": "1.1.2",22+ "jsr:@std/internal@^1.0.12": "1.0.14",23+ "jsr:@std/internal@^1.0.14": "1.0.14",24+ "jsr:@std/json@^1.0.2": "1.0.3",25+ "jsr:@std/jsonc@^1.0.2": "1.0.2",26+ "jsr:@std/media-types@^1.1.0": "1.1.0",27+ "jsr:@std/path@1": "1.1.6",28+ "jsr:@std/path@^1.1.1": "1.1.6",29+ "jsr:@std/path@^1.1.2": "1.1.6",30+ "jsr:@std/path@^1.1.5": "1.1.6",31+ "jsr:@std/semver@^1.0.6": "1.0.8",32+ "jsr:@std/uuid@^1.0.9": "1.1.0",33+ "npm:@babel/core@^7.28.0": "7.29.7",34+ "npm:@babel/preset-react@^7.27.1": "7.29.7_@babel+core@7.29.7",35+ "npm:@mjackson/node-fetch-server@0.7": "0.7.0",36+ "npm:@modelcontextprotocol/server@2": "2.0.0",37+ "npm:@opentelemetry/api@^1.9.0": "1.9.1",38+ "npm:@preact/signals@^2.5.0": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.8",39+ "npm:@preact/signals@^2.5.1": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.8",40+ "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6_preact-render-to-string@6.7.0__preact@10.29.8",41+ "npm:esbuild-wasm@~0.25.11": "0.25.12",42+ "npm:esbuild@0.25.7": "0.25.7",43+ "npm:esbuild@~0.25.5": "0.25.12",44+ "npm:preact-render-to-string@^6.6.3": "6.7.0_preact@10.29.8",45+ "npm:preact@^10.27.2": "10.29.8_preact-render-to-string@6.7.0",46+ "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.7.0",47+ "npm:preact@^10.28.3": "10.29.8_preact-render-to-string@6.7.0",48+ "npm:rollup@^4.50.0": "4.62.5",49+ "npm:vite@^7.1.3": "7.3.6",50+ "npm:vite@^7.1.4": "7.3.6",51+ "npm:zod@^4.2.0": "4.4.3"52+ },53+ "jsr": {54+ "@deno/esbuild-plugin@1.2.1": {55+ "integrity": "df629467913adc1f960149fdfa3a3430ba8c20381c310fba096db244e6c3c9f6",56+ "dependencies": [57+ "jsr:@deno/loader@~0.3.10",58+ "jsr:@std/path@^1.1.1",59+ "npm:esbuild@~0.25.5"60+ ]61+ },62+ "@deno/loader@0.3.14": {63+ "integrity": "97bc63a6cc2d27a60bcdc953f588c5213331d866d44212eebb24cebfb9b011ca"64+ },65+ "@fresh/build-id@1.0.1": {66+ "integrity": "12a2ec25fd52ae9ec68c26848a5696cd1c9b537f7c983c7e56e4fb1e7e816c20",67+ "dependencies": [68+ "jsr:@std/encoding@^1.0.10"69+ ]70+ },71+ "@fresh/core@2.2.2": {72+ "integrity": "c34873df58457720f7b915a65fef6fe5cd7215f237cf4d1064d1925b320de96c",73+ "dependencies": [74+ "jsr:@deno/esbuild-plugin",75+ "jsr:@fresh/build-id",76+ "jsr:@std/encoding@^1.0.10",77+ "jsr:@std/fmt@^1.0.8",78+ "jsr:@std/fs",79+ "jsr:@std/html",80+ "jsr:@std/http",81+ "jsr:@std/jsonc",82+ "jsr:@std/media-types",83+ "jsr:@std/path@^1.1.2",84+ "jsr:@std/semver",85+ "jsr:@std/uuid",86+ "npm:@opentelemetry/api",87+ "npm:@preact/signals@^2.5.1",88+ "npm:esbuild-wasm",89+ "npm:esbuild@0.25.7",90+ "npm:preact-render-to-string",91+ "npm:preact@^10.28.2",92+ "npm:preact@^10.28.3"93+ ]94+ },95+ "@fresh/plugin-vite@1.0.8": {96+ "integrity": "5780d842ed82e4cbccd93dd8ba2d54bf59dff5aee65921134aab15a4cd457c56",97+ "dependencies": [98+ "jsr:@deno/loader@~0.3.2",99+ "jsr:@fresh/core@2",100+ "jsr:@fresh/core@^2.2.0",101+ "jsr:@std/dotenv",102+ "jsr:@std/fmt@^1.0.7",103+ "jsr:@std/path@1",104+ "npm:@babel/core",105+ "npm:@babel/preset-react",106+ "npm:@mjackson/node-fetch-server",107+ "npm:@prefresh/vite",108+ "npm:rollup",109+ "npm:vite@^7.1.4"110+ ]111+ },112+ "@std/assert@1.0.19": {113+ "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",114+ "dependencies": [115+ "jsr:@std/internal@^1.0.12"116+ ]117+ },118+ "@std/bytes@1.0.6": {119+ "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"120+ },121+ "@std/dotenv@0.225.7": {122+ "integrity": "11d8db03ca4ad5aba9eba809f2e8058b2a4f320b7b09fea4b360e162928329e3"123+ },124+ "@std/encoding@1.0.11": {125+ "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385"126+ },127+ "@std/fmt@1.0.10": {128+ "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5"129+ },130+ "@std/fs@1.0.24": {131+ "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877",132+ "dependencies": [133+ "jsr:@std/path@^1.1.5"134+ ]135+ },136+ "@std/html@1.0.7": {137+ "integrity": "175c818905a6e75743c69c251395e273d82698e58cfe7dd76268d70e28b8d8fe"138+ },139+ "@std/http@1.1.2": {140+ "integrity": "1c0473590d5e5b75b30b2be4833c08047dd799aeea35624fd34ec58d67fc9240",141+ "dependencies": [142+ "jsr:@std/encoding@^1.0.11"143+ ]144+ },145+ "@std/internal@1.0.14": {146+ "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"147+ },148+ "@std/json@1.0.3": {149+ "integrity": "97d5710996293a027b7aa5f0d1f4fa29f246f269e6b5597e08807613f37d426c"150+ },151+ "@std/jsonc@1.0.2": {152+ "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7",153+ "dependencies": [154+ "jsr:@std/json"155+ ]156+ },157+ "@std/media-types@1.1.0": {158+ "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"159+ },160+ "@std/path@1.1.6": {161+ "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",162+ "dependencies": [163+ "jsr:@std/internal@^1.0.14"164+ ]165+ },166+ "@std/semver@1.0.8": {167+ "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7"168+ },169+ "@std/uuid@1.1.0": {170+ "integrity": "6268db2ccf172849c9be80763354ca305d49ef4af41fe995623d44fcc3f7457c",171+ "dependencies": [172+ "jsr:@std/bytes"173+ ]174+ }175+ },176+ "npm": {177+ "@babel/code-frame@7.29.7": {178+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",179+ "dependencies": [180+ "@babel/helper-validator-identifier",181+ "js-tokens",182+ "picocolors"183+ ]184+ },185+ "@babel/compat-data@7.29.7": {186+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="187+ },188+ "@babel/core@7.29.7": {189+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",190+ "dependencies": [191+ "@babel/code-frame",192+ "@babel/generator",193+ "@babel/helper-compilation-targets",194+ "@babel/helper-module-transforms",195+ "@babel/helpers",196+ "@babel/parser",197+ "@babel/template",198+ "@babel/traverse",199+ "@babel/types",200+ "@jridgewell/remapping",201+ "convert-source-map",202+ "debug",203+ "gensync",204+ "json5",205+ "semver"206+ ]207+ },208+ "@babel/generator@7.29.8": {209+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",210+ "dependencies": [211+ "@babel/parser",212+ "@babel/types",213+ "@jridgewell/gen-mapping",214+ "@jridgewell/trace-mapping",215+ "jsesc"216+ ]217+ },218+ "@babel/helper-annotate-as-pure@7.29.7": {219+ "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",220+ "dependencies": [221+ "@babel/types"222+ ]223+ },224+ "@babel/helper-compilation-targets@7.29.7": {225+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",226+ "dependencies": [227+ "@babel/compat-data",228+ "@babel/helper-validator-option",229+ "browserslist",230+ "lru-cache",231+ "semver"232+ ]233+ },234+ "@babel/helper-globals@7.29.7": {235+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="236+ },237+ "@babel/helper-module-imports@7.29.7": {238+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",239+ "dependencies": [240+ "@babel/traverse",241+ "@babel/types"242+ ]243+ },244+ "@babel/helper-module-transforms@7.29.7_@babel+core@7.29.7": {245+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",246+ "dependencies": [247+ "@babel/core",248+ "@babel/helper-module-imports",249+ "@babel/helper-validator-identifier",250+ "@babel/traverse"251+ ]252+ },253+ "@babel/helper-plugin-utils@7.29.7": {254+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="255+ },256+ "@babel/helper-string-parser@7.29.7": {257+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="258+ },259+ "@babel/helper-validator-identifier@7.29.7": {260+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="261+ },262+ "@babel/helper-validator-option@7.29.7": {263+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="264+ },265+ "@babel/helpers@7.29.7": {266+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",267+ "dependencies": [268+ "@babel/template",269+ "@babel/types"270+ ]271+ },272+ "@babel/parser@7.29.8": {273+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",274+ "dependencies": [275+ "@babel/types"276+ ],277+ "bin": true278+ },279+ "@babel/plugin-syntax-jsx@7.29.7_@babel+core@7.29.7": {280+ "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==",281+ "dependencies": [282+ "@babel/core",283+ "@babel/helper-plugin-utils"284+ ]285+ },286+ "@babel/plugin-transform-react-display-name@7.29.7_@babel+core@7.29.7": {287+ "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==",288+ "dependencies": [289+ "@babel/core",290+ "@babel/helper-plugin-utils"291+ ]292+ },293+ "@babel/plugin-transform-react-jsx-development@7.29.7_@babel+core@7.29.7": {294+ "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==",295+ "dependencies": [296+ "@babel/core",297+ "@babel/plugin-transform-react-jsx"298+ ]299+ },300+ "@babel/plugin-transform-react-jsx@7.29.7_@babel+core@7.29.7": {301+ "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==",302+ "dependencies": [303+ "@babel/core",304+ "@babel/helper-annotate-as-pure",305+ "@babel/helper-module-imports",306+ "@babel/helper-plugin-utils",307+ "@babel/plugin-syntax-jsx",308+ "@babel/types"309+ ]310+ },311+ "@babel/plugin-transform-react-pure-annotations@7.29.7_@babel+core@7.29.7": {312+ "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==",313+ "dependencies": [314+ "@babel/core",315+ "@babel/helper-annotate-as-pure",316+ "@babel/helper-plugin-utils"317+ ]318+ },319+ "@babel/preset-react@7.29.7_@babel+core@7.29.7": {320+ "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==",321+ "dependencies": [322+ "@babel/core",323+ "@babel/helper-plugin-utils",324+ "@babel/helper-validator-option",325+ "@babel/plugin-transform-react-display-name",326+ "@babel/plugin-transform-react-jsx",327+ "@babel/plugin-transform-react-jsx-development",328+ "@babel/plugin-transform-react-pure-annotations"329+ ]330+ },331+ "@babel/template@7.29.7": {332+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",333+ "dependencies": [334+ "@babel/code-frame",335+ "@babel/parser",336+ "@babel/types"337+ ]338+ },339+ "@babel/traverse@7.29.8": {340+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",341+ "dependencies": [342+ "@babel/code-frame",343+ "@babel/generator",344+ "@babel/helper-globals",345+ "@babel/parser",346+ "@babel/template",347+ "@babel/types",348+ "debug"349+ ]350+ },351+ "@babel/types@7.29.8": {352+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",353+ "dependencies": [354+ "@babel/helper-string-parser",355+ "@babel/helper-validator-identifier"356+ ]357+ },358+ "@esbuild/aix-ppc64@0.25.12": {359+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",360+ "os": ["aix"],361+ "cpu": ["ppc64"]362+ },363+ "@esbuild/aix-ppc64@0.25.7": {364+ "integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==",365+ "os": ["aix"],366+ "cpu": ["ppc64"]367+ },368+ "@esbuild/aix-ppc64@0.28.2": {369+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",370+ "os": ["aix"],371+ "cpu": ["ppc64"]372+ },373+ "@esbuild/android-arm64@0.25.12": {374+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",375+ "os": ["android"],376+ "cpu": ["arm64"]377+ },378+ "@esbuild/android-arm64@0.25.7": {379+ "integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==",380+ "os": ["android"],381+ "cpu": ["arm64"]382+ },383+ "@esbuild/android-arm64@0.28.2": {384+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",385+ "os": ["android"],386+ "cpu": ["arm64"]387+ },388+ "@esbuild/android-arm@0.25.12": {389+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",390+ "os": ["android"],391+ "cpu": ["arm"]392+ },393+ "@esbuild/android-arm@0.25.7": {394+ "integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==",395+ "os": ["android"],396+ "cpu": ["arm"]397+ },398+ "@esbuild/android-arm@0.28.2": {399+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",400+ "os": ["android"],401+ "cpu": ["arm"]402+ },403+ "@esbuild/android-x64@0.25.12": {404+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",405+ "os": ["android"],406+ "cpu": ["x64"]407+ },408+ "@esbuild/android-x64@0.25.7": {409+ "integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==",410+ "os": ["android"],411+ "cpu": ["x64"]412+ },413+ "@esbuild/android-x64@0.28.2": {414+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",415+ "os": ["android"],416+ "cpu": ["x64"]417+ },418+ "@esbuild/darwin-arm64@0.25.12": {419+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",420+ "os": ["darwin"],421+ "cpu": ["arm64"]422+ },423+ "@esbuild/darwin-arm64@0.25.7": {424+ "integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==",425+ "os": ["darwin"],426+ "cpu": ["arm64"]427+ },428+ "@esbuild/darwin-arm64@0.28.2": {429+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",430+ "os": ["darwin"],431+ "cpu": ["arm64"]432+ },433+ "@esbuild/darwin-x64@0.25.12": {434+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",435+ "os": ["darwin"],436+ "cpu": ["x64"]437+ },438+ "@esbuild/darwin-x64@0.25.7": {439+ "integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==",440+ "os": ["darwin"],441+ "cpu": ["x64"]442+ },443+ "@esbuild/darwin-x64@0.28.2": {444+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",445+ "os": ["darwin"],446+ "cpu": ["x64"]447+ },448+ "@esbuild/freebsd-arm64@0.25.12": {449+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",450+ "os": ["freebsd"],451+ "cpu": ["arm64"]452+ },453+ "@esbuild/freebsd-arm64@0.25.7": {454+ "integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==",455+ "os": ["freebsd"],456+ "cpu": ["arm64"]457+ },458+ "@esbuild/freebsd-arm64@0.28.2": {459+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",460+ "os": ["freebsd"],461+ "cpu": ["arm64"]462+ },463+ "@esbuild/freebsd-x64@0.25.12": {464+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",465+ "os": ["freebsd"],466+ "cpu": ["x64"]467+ },468+ "@esbuild/freebsd-x64@0.25.7": {469+ "integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==",470+ "os": ["freebsd"],471+ "cpu": ["x64"]472+ },473+ "@esbuild/freebsd-x64@0.28.2": {474+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",475+ "os": ["freebsd"],476+ "cpu": ["x64"]477+ },478+ "@esbuild/linux-arm64@0.25.12": {479+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",480+ "os": ["linux"],481+ "cpu": ["arm64"]482+ },483+ "@esbuild/linux-arm64@0.25.7": {484+ "integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==",485+ "os": ["linux"],486+ "cpu": ["arm64"]487+ },488+ "@esbuild/linux-arm64@0.28.2": {489+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",490+ "os": ["linux"],491+ "cpu": ["arm64"]492+ },493+ "@esbuild/linux-arm@0.25.12": {494+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",495+ "os": ["linux"],496+ "cpu": ["arm"]497+ },498+ "@esbuild/linux-arm@0.25.7": {499+ "integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==",500+ "os": ["linux"],501+ "cpu": ["arm"]502+ },503+ "@esbuild/linux-arm@0.28.2": {504+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",505+ "os": ["linux"],506+ "cpu": ["arm"]507+ },508+ "@esbuild/linux-ia32@0.25.12": {509+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",510+ "os": ["linux"],511+ "cpu": ["ia32"]512+ },513+ "@esbuild/linux-ia32@0.25.7": {514+ "integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==",515+ "os": ["linux"],516+ "cpu": ["ia32"]517+ },518+ "@esbuild/linux-ia32@0.28.2": {519+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",520+ "os": ["linux"],521+ "cpu": ["ia32"]522+ },523+ "@esbuild/linux-loong64@0.25.12": {524+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",525+ "os": ["linux"],526+ "cpu": ["loong64"]527+ },528+ "@esbuild/linux-loong64@0.25.7": {529+ "integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==",530+ "os": ["linux"],531+ "cpu": ["loong64"]532+ },533+ "@esbuild/linux-loong64@0.28.2": {534+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",535+ "os": ["linux"],536+ "cpu": ["loong64"]537+ },538+ "@esbuild/linux-mips64el@0.25.12": {539+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",540+ "os": ["linux"],541+ "cpu": ["mips64el"]542+ },543+ "@esbuild/linux-mips64el@0.25.7": {544+ "integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==",545+ "os": ["linux"],546+ "cpu": ["mips64el"]547+ },548+ "@esbuild/linux-mips64el@0.28.2": {549+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",550+ "os": ["linux"],551+ "cpu": ["mips64el"]552+ },553+ "@esbuild/linux-ppc64@0.25.12": {554+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",555+ "os": ["linux"],556+ "cpu": ["ppc64"]557+ },558+ "@esbuild/linux-ppc64@0.25.7": {559+ "integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==",560+ "os": ["linux"],561+ "cpu": ["ppc64"]562+ },563+ "@esbuild/linux-ppc64@0.28.2": {564+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",565+ "os": ["linux"],566+ "cpu": ["ppc64"]567+ },568+ "@esbuild/linux-riscv64@0.25.12": {569+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",570+ "os": ["linux"],571+ "cpu": ["riscv64"]572+ },573+ "@esbuild/linux-riscv64@0.25.7": {574+ "integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==",575+ "os": ["linux"],576+ "cpu": ["riscv64"]577+ },578+ "@esbuild/linux-riscv64@0.28.2": {579+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",580+ "os": ["linux"],581+ "cpu": ["riscv64"]582+ },583+ "@esbuild/linux-s390x@0.25.12": {584+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",585+ "os": ["linux"],586+ "cpu": ["s390x"]587+ },588+ "@esbuild/linux-s390x@0.25.7": {589+ "integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==",590+ "os": ["linux"],591+ "cpu": ["s390x"]592+ },593+ "@esbuild/linux-s390x@0.28.2": {594+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",595+ "os": ["linux"],596+ "cpu": ["s390x"]597+ },598+ "@esbuild/linux-x64@0.25.12": {599+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",600+ "os": ["linux"],601+ "cpu": ["x64"]602+ },603+ "@esbuild/linux-x64@0.25.7": {604+ "integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==",605+ "os": ["linux"],606+ "cpu": ["x64"]607+ },608+ "@esbuild/linux-x64@0.28.2": {609+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",610+ "os": ["linux"],611+ "cpu": ["x64"]612+ },613+ "@esbuild/netbsd-arm64@0.25.12": {614+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",615+ "os": ["netbsd"],616+ "cpu": ["arm64"]617+ },618+ "@esbuild/netbsd-arm64@0.25.7": {619+ "integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==",620+ "os": ["netbsd"],621+ "cpu": ["arm64"]622+ },623+ "@esbuild/netbsd-arm64@0.28.2": {624+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",625+ "os": ["netbsd"],626+ "cpu": ["arm64"]627+ },628+ "@esbuild/netbsd-x64@0.25.12": {629+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",630+ "os": ["netbsd"],631+ "cpu": ["x64"]632+ },633+ "@esbuild/netbsd-x64@0.25.7": {634+ "integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==",635+ "os": ["netbsd"],636+ "cpu": ["x64"]637+ },638+ "@esbuild/netbsd-x64@0.28.2": {639+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",640+ "os": ["netbsd"],641+ "cpu": ["x64"]642+ },643+ "@esbuild/openbsd-arm64@0.25.12": {644+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",645+ "os": ["openbsd"],646+ "cpu": ["arm64"]647+ },648+ "@esbuild/openbsd-arm64@0.25.7": {649+ "integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==",650+ "os": ["openbsd"],651+ "cpu": ["arm64"]652+ },653+ "@esbuild/openbsd-arm64@0.28.2": {654+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",655+ "os": ["openbsd"],656+ "cpu": ["arm64"]657+ },658+ "@esbuild/openbsd-x64@0.25.12": {659+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",660+ "os": ["openbsd"],661+ "cpu": ["x64"]662+ },663+ "@esbuild/openbsd-x64@0.25.7": {664+ "integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==",665+ "os": ["openbsd"],666+ "cpu": ["x64"]667+ },668+ "@esbuild/openbsd-x64@0.28.2": {669+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",670+ "os": ["openbsd"],671+ "cpu": ["x64"]672+ },673+ "@esbuild/openharmony-arm64@0.25.12": {674+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",675+ "os": ["openharmony"],676+ "cpu": ["arm64"]677+ },678+ "@esbuild/openharmony-arm64@0.25.7": {679+ "integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==",680+ "os": ["openharmony"],681+ "cpu": ["arm64"]682+ },683+ "@esbuild/openharmony-arm64@0.28.2": {684+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",685+ "os": ["openharmony"],686+ "cpu": ["arm64"]687+ },688+ "@esbuild/sunos-x64@0.25.12": {689+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",690+ "os": ["sunos"],691+ "cpu": ["x64"]692+ },693+ "@esbuild/sunos-x64@0.25.7": {694+ "integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==",695+ "os": ["sunos"],696+ "cpu": ["x64"]697+ },698+ "@esbuild/sunos-x64@0.28.2": {699+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",700+ "os": ["sunos"],701+ "cpu": ["x64"]702+ },703+ "@esbuild/win32-arm64@0.25.12": {704+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",705+ "os": ["win32"],706+ "cpu": ["arm64"]707+ },708+ "@esbuild/win32-arm64@0.25.7": {709+ "integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==",710+ "os": ["win32"],711+ "cpu": ["arm64"]712+ },713+ "@esbuild/win32-arm64@0.28.2": {714+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",715+ "os": ["win32"],716+ "cpu": ["arm64"]717+ },718+ "@esbuild/win32-ia32@0.25.12": {719+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",720+ "os": ["win32"],721+ "cpu": ["ia32"]722+ },723+ "@esbuild/win32-ia32@0.25.7": {724+ "integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==",725+ "os": ["win32"],726+ "cpu": ["ia32"]727+ },728+ "@esbuild/win32-ia32@0.28.2": {729+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",730+ "os": ["win32"],731+ "cpu": ["ia32"]732+ },733+ "@esbuild/win32-x64@0.25.12": {734+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",735+ "os": ["win32"],736+ "cpu": ["x64"]737+ },738+ "@esbuild/win32-x64@0.25.7": {739+ "integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==",740+ "os": ["win32"],741+ "cpu": ["x64"]742+ },743+ "@esbuild/win32-x64@0.28.2": {744+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",745+ "os": ["win32"],746+ "cpu": ["x64"]747+ },748+ "@jridgewell/gen-mapping@0.3.13": {749+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",750+ "dependencies": [751+ "@jridgewell/sourcemap-codec",752+ "@jridgewell/trace-mapping"753+ ]754+ },755+ "@jridgewell/remapping@2.3.5": {756+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",757+ "dependencies": [758+ "@jridgewell/gen-mapping",759+ "@jridgewell/trace-mapping"760+ ]761+ },762+ "@jridgewell/resolve-uri@3.1.2": {763+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="764+ },765+ "@jridgewell/sourcemap-codec@1.5.5": {766+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="767+ },768+ "@jridgewell/trace-mapping@0.3.31": {769+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",770+ "dependencies": [771+ "@jridgewell/resolve-uri",772+ "@jridgewell/sourcemap-codec"773+ ]774+ },775+ "@mjackson/node-fetch-server@0.7.0": {776+ "integrity": "sha512-un8diyEBKU3BTVj3GzlTPA1kIjCkGdD+AMYQy31Gf9JCkfoZzwgJ79GUtHrF2BN3XPNMLpubbzPcxys+a3uZEw=="777+ },778+ "@modelcontextprotocol/core@2.0.0": {779+ "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==",780+ "dependencies": [781+ "zod"782+ ]783+ },784+ "@modelcontextprotocol/server@2.0.0": {785+ "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==",786+ "dependencies": [787+ "@modelcontextprotocol/core",788+ "zod"789+ ]790+ },791+ "@napi-rs/lzma-linux-x64-gnu@1.5.1": {792+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",793+ "os": ["linux"],794+ "cpu": ["x64"]795+ },796+ "@opentelemetry/api@1.9.1": {797+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="798+ },799+ "@preact/signals-core@1.14.4": {800+ "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="801+ },802+ "@preact/signals@2.11.1_preact@10.29.8__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.8": {803+ "integrity": "sha512-uYoD+USkacTNU02kfCBkJEV7xabKTmA78W5pnT2GymsuGEC9n/ZO+UT4S96l0dIL6KLDk77VJyUFuOMBvftVHg==",804+ "dependencies": [805+ "@preact/signals-core",806+ "preact"807+ ]808+ },809+ "@prefresh/babel-plugin@0.5.3": {810+ "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ=="811+ },812+ "@prefresh/core@1.5.10_preact@10.29.8__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.8": {813+ "integrity": "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w==",814+ "dependencies": [815+ "preact"816+ ]817+ },818+ "@prefresh/utils@1.2.1": {819+ "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="820+ },821+ "@prefresh/vite@2.4.12_preact@10.29.8__preact-render-to-string@6.7.0_vite@7.3.6_preact-render-to-string@6.7.0__preact@10.29.8": {822+ "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",823+ "dependencies": [824+ "@babel/core",825+ "@prefresh/babel-plugin",826+ "@prefresh/core",827+ "@prefresh/utils",828+ "@rollup/pluginutils",829+ "preact",830+ "vite"831+ ]832+ },833+ "@rollup/pluginutils@4.2.1": {834+ "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",835+ "dependencies": [836+ "estree-walker",837+ "picomatch@2.3.2"838+ ]839+ },840+ "@rollup/rollup-android-arm-eabi@4.62.5": {841+ "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==",842+ "os": ["android"],843+ "cpu": ["arm"]844+ },845+ "@rollup/rollup-android-arm64@4.62.5": {846+ "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==",847+ "os": ["android"],848+ "cpu": ["arm64"]849+ },850+ "@rollup/rollup-darwin-arm64@4.62.5": {851+ "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==",852+ "os": ["darwin"],853+ "cpu": ["arm64"]854+ },855+ "@rollup/rollup-darwin-x64@4.62.5": {856+ "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==",857+ "os": ["darwin"],858+ "cpu": ["x64"]859+ },860+ "@rollup/rollup-freebsd-arm64@4.62.5": {861+ "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==",862+ "os": ["freebsd"],863+ "cpu": ["arm64"]864+ },865+ "@rollup/rollup-freebsd-x64@4.62.5": {866+ "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==",867+ "os": ["freebsd"],868+ "cpu": ["x64"]869+ },870+ "@rollup/rollup-linux-arm-gnueabihf@4.62.5": {871+ "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==",872+ "os": ["linux"],873+ "cpu": ["arm"]874+ },875+ "@rollup/rollup-linux-arm-musleabihf@4.62.5": {876+ "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==",877+ "os": ["linux"],878+ "cpu": ["arm"]879+ },880+ "@rollup/rollup-linux-arm64-gnu@4.62.5": {881+ "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==",882+ "os": ["linux"],883+ "cpu": ["arm64"]884+ },885+ "@rollup/rollup-linux-arm64-musl@4.62.5": {886+ "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==",887+ "os": ["linux"],888+ "cpu": ["arm64"]889+ },890+ "@rollup/rollup-linux-loong64-gnu@4.62.5": {891+ "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==",892+ "os": ["linux"],893+ "cpu": ["loong64"]894+ },895+ "@rollup/rollup-linux-loong64-musl@4.62.5": {896+ "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==",897+ "os": ["linux"],898+ "cpu": ["loong64"]899+ },900+ "@rollup/rollup-linux-ppc64-gnu@4.62.5": {901+ "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==",902+ "os": ["linux"],903+ "cpu": ["ppc64"]904+ },905+ "@rollup/rollup-linux-ppc64-musl@4.62.5": {906+ "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==",907+ "os": ["linux"],908+ "cpu": ["ppc64"]909+ },910+ "@rollup/rollup-linux-riscv64-gnu@4.62.5": {911+ "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==",912+ "os": ["linux"],913+ "cpu": ["riscv64"]914+ },915+ "@rollup/rollup-linux-riscv64-musl@4.62.5": {916+ "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==",917+ "os": ["linux"],918+ "cpu": ["riscv64"]919+ },920+ "@rollup/rollup-linux-s390x-gnu@4.62.5": {921+ "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==",922+ "os": ["linux"],923+ "cpu": ["s390x"]924+ },925+ "@rollup/rollup-linux-x64-gnu@4.62.5": {926+ "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==",927+ "os": ["linux"],928+ "cpu": ["x64"]929+ },930+ "@rollup/rollup-linux-x64-musl@4.62.5": {931+ "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==",932+ "os": ["linux"],933+ "cpu": ["x64"]934+ },935+ "@rollup/rollup-openbsd-x64@4.62.5": {936+ "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==",937+ "os": ["openbsd"],938+ "cpu": ["x64"]939+ },940+ "@rollup/rollup-openharmony-arm64@4.62.5": {941+ "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==",942+ "os": ["openharmony"],943+ "cpu": ["arm64"]944+ },945+ "@rollup/rollup-win32-arm64-msvc@4.62.5": {946+ "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==",947+ "os": ["win32"],948+ "cpu": ["arm64"]949+ },950+ "@rollup/rollup-win32-ia32-msvc@4.62.5": {951+ "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==",952+ "os": ["win32"],953+ "cpu": ["ia32"]954+ },955+ "@rollup/rollup-win32-x64-gnu@4.62.5": {956+ "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==",957+ "os": ["win32"],958+ "cpu": ["x64"]959+ },960+ "@rollup/rollup-win32-x64-msvc@4.62.5": {961+ "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==",962+ "os": ["win32"],963+ "cpu": ["x64"]964+ },965+ "@types/estree@1.0.9": {966+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="967+ },968+ "baseline-browser-mapping@2.11.19": {969+ "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",970+ "bin": true971+ },972+ "browserslist@4.28.8": {973+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",974+ "dependencies": [975+ "baseline-browser-mapping",976+ "caniuse-lite",977+ "electron-to-chromium",978+ "node-releases",979+ "update-browserslist-db"980+ ],981+ "bin": true982+ },983+ "caniuse-lite@1.0.30001810": {984+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="985+ },986+ "convert-source-map@2.0.0": {987+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="988+ },989+ "debug@4.4.3": {990+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",991+ "dependencies": [992+ "ms"993+ ]994+ },995+ "electron-to-chromium@1.5.414": {996+ "integrity": "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw=="997+ },998+ "esbuild-wasm@0.25.12": {999+ "integrity": "sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==",1000+ "bin": true1001+ },1002+ "esbuild@0.25.12": {1003+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",1004+ "optionalDependencies": [1005+ "@esbuild/aix-ppc64@0.25.12",1006+ "@esbuild/android-arm@0.25.12",1007+ "@esbuild/android-arm64@0.25.12",1008+ "@esbuild/android-x64@0.25.12",1009+ "@esbuild/darwin-arm64@0.25.12",1010+ "@esbuild/darwin-x64@0.25.12",1011+ "@esbuild/freebsd-arm64@0.25.12",1012+ "@esbuild/freebsd-x64@0.25.12",1013+ "@esbuild/linux-arm@0.25.12",1014+ "@esbuild/linux-arm64@0.25.12",1015+ "@esbuild/linux-ia32@0.25.12",1016+ "@esbuild/linux-loong64@0.25.12",1017+ "@esbuild/linux-mips64el@0.25.12",1018+ "@esbuild/linux-ppc64@0.25.12",1019+ "@esbuild/linux-riscv64@0.25.12",1020+ "@esbuild/linux-s390x@0.25.12",1021+ "@esbuild/linux-x64@0.25.12",1022+ "@esbuild/netbsd-arm64@0.25.12",1023+ "@esbuild/netbsd-x64@0.25.12",1024+ "@esbuild/openbsd-arm64@0.25.12",1025+ "@esbuild/openbsd-x64@0.25.12",1026+ "@esbuild/openharmony-arm64@0.25.12",1027+ "@esbuild/sunos-x64@0.25.12",1028+ "@esbuild/win32-arm64@0.25.12",1029+ "@esbuild/win32-ia32@0.25.12",1030+ "@esbuild/win32-x64@0.25.12"1031+ ],1032+ "scripts": true,1033+ "bin": true1034+ },1035+ "esbuild@0.25.7": {1036+ "integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==",1037+ "optionalDependencies": [1038+ "@esbuild/aix-ppc64@0.25.7",1039+ "@esbuild/android-arm@0.25.7",1040+ "@esbuild/android-arm64@0.25.7",1041+ "@esbuild/android-x64@0.25.7",1042+ "@esbuild/darwin-arm64@0.25.7",1043+ "@esbuild/darwin-x64@0.25.7",1044+ "@esbuild/freebsd-arm64@0.25.7",1045+ "@esbuild/freebsd-x64@0.25.7",1046+ "@esbuild/linux-arm@0.25.7",1047+ "@esbuild/linux-arm64@0.25.7",1048+ "@esbuild/linux-ia32@0.25.7",1049+ "@esbuild/linux-loong64@0.25.7",1050+ "@esbuild/linux-mips64el@0.25.7",1051+ "@esbuild/linux-ppc64@0.25.7",1052+ "@esbuild/linux-riscv64@0.25.7",1053+ "@esbuild/linux-s390x@0.25.7",1054+ "@esbuild/linux-x64@0.25.7",1055+ "@esbuild/netbsd-arm64@0.25.7",1056+ "@esbuild/netbsd-x64@0.25.7",1057+ "@esbuild/openbsd-arm64@0.25.7",1058+ "@esbuild/openbsd-x64@0.25.7",1059+ "@esbuild/openharmony-arm64@0.25.7",1060+ "@esbuild/sunos-x64@0.25.7",1061+ "@esbuild/win32-arm64@0.25.7",1062+ "@esbuild/win32-ia32@0.25.7",1063+ "@esbuild/win32-x64@0.25.7"1064+ ],1065+ "scripts": true,1066+ "bin": true1067+ },1068+ "esbuild@0.28.2": {1069+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",1070+ "optionalDependencies": [1071+ "@esbuild/aix-ppc64@0.28.2",1072+ "@esbuild/android-arm@0.28.2",1073+ "@esbuild/android-arm64@0.28.2",1074+ "@esbuild/android-x64@0.28.2",1075+ "@esbuild/darwin-arm64@0.28.2",1076+ "@esbuild/darwin-x64@0.28.2",1077+ "@esbuild/freebsd-arm64@0.28.2",1078+ "@esbuild/freebsd-x64@0.28.2",1079+ "@esbuild/linux-arm@0.28.2",1080+ "@esbuild/linux-arm64@0.28.2",1081+ "@esbuild/linux-ia32@0.28.2",1082+ "@esbuild/linux-loong64@0.28.2",1083+ "@esbuild/linux-mips64el@0.28.2",1084+ "@esbuild/linux-ppc64@0.28.2",1085+ "@esbuild/linux-riscv64@0.28.2",1086+ "@esbuild/linux-s390x@0.28.2",1087+ "@esbuild/linux-x64@0.28.2",1088+ "@esbuild/netbsd-arm64@0.28.2",1089+ "@esbuild/netbsd-x64@0.28.2",1090+ "@esbuild/openbsd-arm64@0.28.2",1091+ "@esbuild/openbsd-x64@0.28.2",1092+ "@esbuild/openharmony-arm64@0.28.2",1093+ "@esbuild/sunos-x64@0.28.2",1094+ "@esbuild/win32-arm64@0.28.2",1095+ "@esbuild/win32-ia32@0.28.2",1096+ "@esbuild/win32-x64@0.28.2"1097+ ],1098+ "scripts": true,1099+ "bin": true1100+ },1101+ "escalade@3.2.0": {1102+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="1103+ },1104+ "estree-walker@2.0.2": {1105+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="1106+ },1107+ "fdir@6.5.0_picomatch@4.0.7": {1108+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",1109+ "dependencies": [1110+ "picomatch@4.0.7"1111+ ],1112+ "optionalPeers": [1113+ "picomatch@4.0.7"1114+ ]1115+ },1116+ "fsevents@2.3.3": {1117+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",1118+ "os": ["darwin"],1119+ "scripts": true1120+ },1121+ "gensync@1.0.0-beta.2": {1122+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="1123+ },1124+ "js-tokens@4.0.0": {1125+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="1126+ },1127+ "jsesc@3.1.0": {1128+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",1129+ "bin": true1130+ },1131+ "json5@2.2.3": {1132+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",1133+ "bin": true1134+ },1135+ "lru-cache@5.1.1": {1136+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",1137+ "dependencies": [1138+ "yallist"1139+ ]1140+ },1141+ "ms@2.1.3": {1142+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="1143+ },1144+ "nanoid@3.3.18": {1145+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",1146+ "bin": true1147+ },1148+ "node-releases@2.0.53": {1149+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="1150+ },1151+ "picocolors@1.1.1": {1152+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="1153+ },1154+ "picomatch@2.3.2": {1155+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="1156+ },1157+ "picomatch@4.0.7": {1158+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="1159+ },1160+ "postcss@8.5.26": {1161+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",1162+ "dependencies": [1163+ "nanoid",1164+ "picocolors",1165+ "source-map-js"1166+ ]1167+ },1168+ "preact-render-to-string@6.7.0_preact@10.29.8": {1169+ "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==",1170+ "dependencies": [1171+ "preact"1172+ ]1173+ },1174+ "preact@10.29.8_preact-render-to-string@6.7.0": {1175+ "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",1176+ "dependencies": [1177+ "preact-render-to-string"1178+ ],1179+ "optionalPeers": [1180+ "preact-render-to-string"1181+ ]1182+ },1183+ "rollup@4.62.5": {1184+ "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==",1185+ "dependencies": [1186+ "@types/estree"1187+ ],1188+ "optionalDependencies": [1189+ "@napi-rs/lzma-linux-x64-gnu",1190+ "@rollup/rollup-android-arm-eabi",1191+ "@rollup/rollup-android-arm64",1192+ "@rollup/rollup-darwin-arm64",1193+ "@rollup/rollup-darwin-x64",1194+ "@rollup/rollup-freebsd-arm64",1195+ "@rollup/rollup-freebsd-x64",1196+ "@rollup/rollup-linux-arm-gnueabihf",1197+ "@rollup/rollup-linux-arm-musleabihf",1198+ "@rollup/rollup-linux-arm64-gnu",1199+ "@rollup/rollup-linux-arm64-musl",1200+ "@rollup/rollup-linux-loong64-gnu",1201+ "@rollup/rollup-linux-loong64-musl",1202+ "@rollup/rollup-linux-ppc64-gnu",1203+ "@rollup/rollup-linux-ppc64-musl",1204+ "@rollup/rollup-linux-riscv64-gnu",1205+ "@rollup/rollup-linux-riscv64-musl",1206+ "@rollup/rollup-linux-s390x-gnu",1207+ "@rollup/rollup-linux-x64-gnu",1208+ "@rollup/rollup-linux-x64-musl",1209+ "@rollup/rollup-openbsd-x64",1210+ "@rollup/rollup-openharmony-arm64",1211+ "@rollup/rollup-win32-arm64-msvc",1212+ "@rollup/rollup-win32-ia32-msvc",1213+ "@rollup/rollup-win32-x64-gnu",1214+ "@rollup/rollup-win32-x64-msvc",1215+ "fsevents"1216+ ],1217+ "bin": true1218+ },1219+ "semver@6.3.1": {1220+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",1221+ "bin": true1222+ },1223+ "source-map-js@1.2.1": {1224+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="1225+ },1226+ "tinyglobby@0.2.17": {1227+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",1228+ "dependencies": [1229+ "fdir",1230+ "picomatch@4.0.7"1231+ ]1232+ },1233+ "update-browserslist-db@1.3.1_browserslist@4.28.8": {1234+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",1235+ "dependencies": [1236+ "browserslist",1237+ "escalade",1238+ "picocolors"1239+ ],1240+ "bin": true1241+ },1242+ "vite@7.3.6": {1243+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",1244+ "dependencies": [1245+ "esbuild@0.28.2",1246+ "fdir",1247+ "picomatch@4.0.7",1248+ "postcss",1249+ "rollup",1250+ "tinyglobby"1251+ ],1252+ "optionalDependencies": [1253+ "fsevents"1254+ ],1255+ "bin": true1256+ },1257+ "yallist@3.1.1": {1258+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="1259+ },1260+ "zod@4.4.3": {1261+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="1262+ }1263+ },1264+ "workspace": {1265+ "dependencies": [1266+ "jsr:@fresh/core@^2.2.2",1267+ "jsr:@fresh/plugin-vite@^1.0.8",1268+ "jsr:@std/assert@^1.0.19",1269+ "npm:@modelcontextprotocol/server@2",1270+ "npm:@preact/signals@^2.5.0",1271+ "npm:preact@^10.27.2",1272+ "npm:vite@^7.1.3",1273+ "npm:zod@^4.2.0"1274+ ]1275+ }1276+}@@ -0,0 +1,38 @@
1+// deno task dev: print the copyable prompt, then start the Fresh/Vite dev2+// server on 127.0.0.1:8000.3+const URL_ = "http://127.0.0.1:8000";4+5+export function prompt(): string {6+ return [7+ "Install the mosaic MCP server (streamable HTTP, no auth) and play:",8+ "",9+ ` claude mcp add --transport http mosaic ${URL_}/mcp`,10+ "",11+ "Then paste this into the agent:",12+ "",13+ ` Using the mosaic MCP server at ${URL_}/mcp: start a mosaic (start_mosaic with`,14+ " kind=scatter and G=100: a field of tiles of mixed shape and size that overlap",15+ " by shared cells, with holes; or kind=grid, G=27, n=14 for the 2x2 of 14x14) and",16+ " solve it autonomously with observe, reveal, and commit until the status is",17+ " solved. Read the tool descriptions for the rules (they are per line of a tile).",18+ " Reveal the seed tile first; commit in batches with a one-line intent; when a",19+ " tile alone is ambiguous, expand from a solved neighbour or commit a guess. Do",20+ " not stop until every tile is solved. Do not ask me anything.",21+ "",22+ `Spectate at ${URL_}/ (live). Replay a finished game at ${URL_}/?replay=<game_id>.`,23+ "",24+ ].join("\n");25+}26+27+if (import.meta.main) {28+ console.log(prompt());29+ const cmd = new Deno.Command(Deno.execPath(), {30+ args: ["run", "-A", "npm:vite", ...Deno.args],31+ stdin: "inherit",32+ stdout: "inherit",33+ stderr: "inherit",34+ });35+ const child = cmd.spawn();36+ const status = await child.status;37+ Deno.exit(status.code);38+}@@ -0,0 +1,545 @@
1+// The spectator. One canvas (map, cells, ownership washes, glyphs), a console2+// pane, and a scrubber. Live mode follows /events/live over SSE; replay mode3+// loads /log/:id. Both are the same fold. Nothing is clickable except pan,4+// zoom, and the scrubber.5+import { useEffect, useMemo, useRef, useState } from "preact/hooks";6+import {7+ type ConsoleEntry,8+ emptyState,9+ foldAll,10+ type GameEvent,11+ type Geometry,12+ hueOf,13+ meanSolvedCells,14+ parseLog,15+ type State,16+ type TileSpec,17+ type TileStatus,18+} from "../lib/fold.ts";19+import {20+ cellFill,21+ DARK,22+ LIGHT,23+ type Theme,24+ tileStroke,25+} from "../lib/colour.ts";26+27+type View = { scale: number; ox: number; oy: number };28+29+const RANK: Record<TileStatus, number> = { hidden: 0, revealed: 1, solved: 2 };30+31+class Painter {32+ geo: Geometry | null = null;33+ hues: number[][] = []; // per cell: owner hues34+ fills: string[][] = []; // [id][key]35+ theme: Theme = DARK;36+37+ prepare(geo: Geometry, theme: Theme) {38+ if (this.geo === geo && this.theme === theme) return;39+ if (this.geo !== geo) {40+ const N = geo.G * geo.G;41+ this.hues = new Array(N);42+ for (let id = 0; id < N; id++) this.hues[id] = hueOf(geo, id);43+ }44+ this.geo = geo;45+ this.theme = theme;46+ this.fills = new Array(geo.G * geo.G);47+ for (let i = 0; i < this.fills.length; i++) this.fills[i] = [];48+ }49+50+ /** recency is quantised to RECENCY_STEPS levels so fills stay cacheable */51+ fill(52+ id: number,53+ status: TileStatus,54+ known: boolean,55+ recency: number,56+ ): string {57+ const q = Math.round(recency * RECENCY_STEPS);58+ const key = (RANK[status] * 2 + (known ? 1 : 0)) * (RECENCY_STEPS + 1) + q;59+ let f = this.fills[id][key];60+ if (!f) {61+ f = cellFill(this.hues[id], status, known, this.theme, q / RECENCY_STEPS);62+ this.fills[id][key] = f;63+ }64+ return f;65+ }66+}67+68+const RECENCY_STEPS = 4;69+/** revisions over which a newly known cell's lift fades */70+const RECENCY_REVS = 4;71+/** cells at least this many pixels wide show their value */72+const DETAIL_SCALE = 12;73+74+/** Local mask of a tile: which cells of its bounding box belong to it. */75+function tileMask(t: TileSpec): { w: number; h: number; mask: Uint8Array } {76+ const w = t.w, h = t.h;77+ const mask = new Uint8Array(w * h);78+ for (const [lx, ly, rw, rh] of t.rects) {79+ for (let y = ly; y < ly + rh; y++) {80+ for (let x = lx; x < lx + rw; x++) mask[y * w + x] = 1;81+ }82+ }83+ return { w, h, mask };84+}85+86+/**87+ * Outline of a tile: every edge between one of its cells and a cell that88+ * is not its own. Rectangles, L, T and holed shapes all come out right.89+ */90+function traceOutline(91+ ctx: CanvasRenderingContext2D,92+ t: TileSpec,93+ scale: number,94+ ox: number,95+ oy: number,96+) {97+ const { w, h, mask } = tileMask(t);98+ const inside = (x: number, y: number) =>99+ x >= 0 && y >= 0 && x < w && y < h && mask[y * w + x] === 1;100+ ctx.beginPath();101+ for (let y = 0; y < h; y++) {102+ for (let x = 0; x < w; x++) {103+ if (!inside(x, y)) continue;104+ const px = ox + (t.x0 + x) * scale, py = oy + (t.y0 + y) * scale;105+ if (!inside(x, y - 1)) {106+ ctx.moveTo(px, py);107+ ctx.lineTo(px + scale, py);108+ }109+ if (!inside(x, y + 1)) {110+ ctx.moveTo(px, py + scale);111+ ctx.lineTo(px + scale, py + scale);112+ }113+ if (!inside(x - 1, y)) {114+ ctx.moveTo(px, py);115+ ctx.lineTo(px, py + scale);116+ }117+ if (!inside(x + 1, y)) {118+ ctx.moveTo(px + scale, py);119+ ctx.lineTo(px + scale, py + scale);120+ }121+ }122+ }123+ ctx.stroke();124+}125+126+function draw(127+ canvas: HTMLCanvasElement,128+ s: State,129+ view: View,130+ painter: Painter,131+ theme: Theme,132+) {133+ const ctx = canvas.getContext("2d");134+ if (!ctx) return;135+ const dpr = globalThis.devicePixelRatio || 1;136+ const W = canvas.clientWidth, H = canvas.clientHeight;137+ if (138+ canvas.width !== Math.round(W * dpr) ||139+ canvas.height !== Math.round(H * dpr)140+ ) {141+ canvas.width = Math.round(W * dpr);142+ canvas.height = Math.round(H * dpr);143+ }144+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);145+ ctx.fillStyle = theme.bg;146+ ctx.fillRect(0, 0, W, H);147+ if (!s.manifest) {148+ ctx.fillStyle = theme.muted;149+ ctx.font = "13px system-ui, sans-serif";150+ ctx.fillText("waiting for a game", 16, 24);151+ return;152+ }153+ const G = s.G, geo = s.geo!;154+ painter.prepare(geo, theme);155+ const { scale, ox, oy } = view;156+157+ const x0 = Math.max(0, Math.floor(-ox / scale));158+ const y0 = Math.max(0, Math.floor(-oy / scale));159+ const x1 = Math.min(G - 1, Math.ceil((W - ox) / scale));160+ const y1 = Math.min(G - 1, Math.ceil((H - oy) / scale));161+ const detail = scale >= DETAIL_SCALE;162+ const statusOfCell = (id: number): TileStatus | null => {163+ const a = geo.ownerStart[id], b = geo.ownerStart[id + 1];164+ if (a === b) return null;165+ let best: TileStatus = "hidden";166+ for (let i = a; i < b; i++) {167+ const st = s.tiles[geo.ownerList[i]];168+ if (RANK[st] > RANK[best]) best = st;169+ }170+ return best;171+ };172+173+ // the surface: one flat wash per cell, no borders. Holes are void; a174+ // cell's value never shows here.175+ for (let y = y0; y <= y1; y++) {176+ for (let x = x0; x <= x1; x++) {177+ const id = y * G + x;178+ const st = statusOfCell(id);179+ const c = s.cells[id];180+ if (st === null) {181+ if (!c) continue; // a hole182+ ctx.fillStyle = painter.fill(id, "solved", true, 0); // ownerless known cell (v2 filler)183+ } else {184+ const age = c ? s.revision - c.rev : RECENCY_REVS;185+ const recency = c && age < RECENCY_REVS ? 1 - age / RECENCY_REVS : 0;186+ ctx.fillStyle = painter.fill(id, st, !!c, recency);187+ }188+ const px = ox + x * scale, py = oy + y * scale;189+ ctx.fillRect(px, py, scale + 0.5, scale + 0.5);190+ }191+ }192+ if (detail) {193+ // values, only when a cell is wide enough to read: a 1 is a filled194+ // disc (a pebble), a 0 a ring (a ripple). Givens in the muted ink,195+ // the agent's own cells in the full ink.196+ const r = scale * 0.3;197+ const ring = Math.max(1, scale * 0.09);198+ for (let y = y0; y <= y1; y++) {199+ for (let x = x0; x <= x1; x++) {200+ const c = s.cells[y * G + x];201+ if (!c) continue;202+ const cx = ox + (x + 0.5) * scale, cy = oy + (y + 0.5) * scale;203+ const ink = c.kind === "commit" ? theme.ink : theme.inkGiven;204+ ctx.beginPath();205+ ctx.arc(cx, cy, c.v === 1 ? r : r - ring / 2, 0, Math.PI * 2);206+ if (c.v === 1) {207+ ctx.fillStyle = ink;208+ ctx.fill();209+ } else {210+ ctx.strokeStyle = ink;211+ ctx.lineWidth = ring;212+ ctx.stroke();213+ }214+ }215+ }216+ }217+ // tile edges: a revealed tile (the work in progress) gets a visible218+ // edge in its own hue; a solved tile only a faint one, so the finished219+ // field reads as washes meeting, not as a quilt of frames; a hidden220+ // tile has none. Drawn as the boundary of the tile's cells.221+ ctx.lineJoin = "round";222+ ctx.lineCap = "round";223+ for (const t of s.manifest.tiles) {224+ const st = s.tiles[t.id];225+ if (st === "hidden") continue;226+ const px = ox + t.x0 * scale, py = oy + t.y0 * scale;227+ if (px + t.w * scale < 0 || py + t.h * scale < 0 || px > W || py > H) {228+ continue;229+ }230+ const revealed = st === "revealed";231+ ctx.strokeStyle = tileStroke(geo.hues[t.id], !revealed);232+ ctx.globalAlpha = revealed ? 0.9 : 0.35;233+ ctx.lineWidth = revealed ? Math.min(2, 0.8 + scale * 0.06) : 1;234+ traceOutline(ctx, t, scale, ox, oy);235+ }236+ ctx.globalAlpha = 1;237+}238+239+function fitView(G: number, W: number, H: number): View {240+ const scale = (Math.min(W, H) * 0.94) / G;241+ return { scale, ox: (W - G * scale) / 2, oy: (H - G * scale) / 2 };242+}243+244+function stats(s: State) {245+ const batches = s.console.filter((e) => e.op === "commit");246+ const sizes = batches.map((e) => e.accepted + e.rejected + e.ignored);247+ const mean = (a: number[]) =>248+ a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0;249+ const head = mean(sizes.slice(0, 5)), tail = mean(sizes.slice(-5));250+ const dts = s.console.map((e) => e.dt_ms).filter((d): d is number =>251+ d !== null252+ );253+ return {254+ ratio: s.totals.commits255+ ? (s.totals.reveals / s.totals.commits).toFixed(2)256+ : "-",257+ drift: sizes.length >= 2 ? `${head.toFixed(1)} to ${tail.toFixed(1)}` : "-",258+ dt: dts.length ? `${(mean(dts) / 1000).toFixed(1)}s` : "-",259+ solved: s.tiles.filter((t) => t === "solved").length,260+ rewardPerBatch: s.totals.commits261+ ? (s.totals.reward / s.totals.commits).toFixed(1)262+ : "-",263+ meanCells: meanSolvedCells(s) ? meanSolvedCells(s).toFixed(0) : "-",264+ };265+}266+267+function entryLine(e: ConsoleEntry): string {268+ switch (e.op) {269+ case "start":270+ return "start";271+ case "reveal":272+ return `reveal #${e.tile} ${e.revealed_cells} cells`;273+ case "commit":274+ return `commit ${e.accepted} ok (${e.forced} forced, ${e.guess} guess), ${e.rejected} rejected, ${e.ignored} ignored${275+ e.solved.length276+ ? `, solved ${e.solved.map((i) => `#${i}`).join(" ")} +${e.reward}`277+ : ""278+ }`;279+ case "retract":280+ return `retract ${e.retracted}`;281+ case "end":282+ return "end";283+ }284+}285+286+export default function Viewer(287+ props: { replay: string | null; theme: "dark" | "light" | null },288+) {289+ const canvasRef = useRef<HTMLCanvasElement>(null);290+ const paneRef = useRef<HTMLDivElement>(null);291+ const painter = useRef(new Painter());292+ const [events, setEvents] = useState<GameEvent[]>([]);293+ const [rev, setRev] = useState(-1);294+ const [live, setLive] = useState(props.replay === null);295+ const [gameId, setGameId] = useState<string | null>(null);296+ const [view, setView] = useState<View | null>(null);297+ const [theme, setTheme] = useState<Theme>(DARK);298+ useEffect(() => {299+ // ?theme=dark|light overrides the system scheme (for the page chrome300+ // too, through the data-theme attribute the stylesheet keys on)301+ if (props.theme) {302+ document.documentElement.dataset.theme = props.theme;303+ setTheme(props.theme === "dark" ? DARK : LIGHT);304+ return;305+ }306+ const mq = globalThis.matchMedia?.("(prefers-color-scheme: light)");307+ if (!mq) return;308+ const apply = () => setTheme(mq.matches ? LIGHT : DARK);309+ apply();310+ mq.addEventListener("change", apply);311+ return () => mq.removeEventListener("change", apply);312+ }, [props.theme]);313+ const eventsRef = useRef<GameEvent[]>([]);314+315+ // event source316+ useEffect(() => {317+ if (props.replay) {318+ fetch(`/log/${props.replay}`).then((r) => r.text()).then((t) => {319+ const evs = parseLog(t);320+ setGameId(props.replay);321+ setEvents(evs);322+ setRev(evs.length - 1);323+ });324+ return;325+ }326+ const es = new EventSource("/events/live");327+ es.addEventListener("game", (m) => {328+ const { game_id } = JSON.parse((m as MessageEvent).data);329+ setGameId(game_id);330+ eventsRef.current = [];331+ setEvents([]);332+ setRev(-1);333+ setView(null);334+ setLive(true);335+ });336+ es.addEventListener("event", (m) => {337+ const ev = JSON.parse((m as MessageEvent).data) as GameEvent;338+ eventsRef.current = [...eventsRef.current, ev];339+ setEvents(eventsRef.current);340+ });341+ return () => es.close();342+ }, [props.replay]);343+344+ // one fold per event list, computed during render so replay mode (which345+ // never re-renders on its own) sees it immediately; live pins the scrubber346+ const states = useMemo(() => foldAll(events), [events]);347+ useEffect(() => {348+ if (live) setRev(events.length - 1);349+ }, [events, live]);350+351+ const state: State = rev >= 0 && states[rev]352+ ? states[rev]353+ : (states.length ? states[0] : emptyState());354+355+ // initial fit once the manifest is known356+ useEffect(() => {357+ const c = canvasRef.current;358+ if (!c || !state.manifest || view) return;359+ setView(fitView(state.G, c.clientWidth, c.clientHeight));360+ }, [state.manifest, view]);361+362+ // draw363+ useEffect(() => {364+ const c = canvasRef.current;365+ if (!c) return;366+ let raf = 0;367+ const paint = () =>368+ draw(369+ c,370+ state,371+ view ?? fitView(Math.max(1, state.G), c.clientWidth, c.clientHeight),372+ painter.current,373+ theme,374+ );375+ raf = requestAnimationFrame(paint);376+ const ro = new ResizeObserver(() => requestAnimationFrame(paint));377+ ro.observe(c);378+ return () => {379+ cancelAnimationFrame(raf);380+ ro.disconnect();381+ };382+ }, [state, view, theme]);383+384+ // console follows live385+ useEffect(() => {386+ const p = paneRef.current;387+ if (p && live) p.scrollTop = p.scrollHeight;388+ }, [state.revision, live]);389+390+ // pan and zoom. Handlers read the latest view through a ref so the effect391+ // is not re-registered on every setView, which would drop a drag in392+ // progress after its first move.393+ const viewRef = useRef<View | null>(null);394+ viewRef.current = view;395+ const dragRef = useRef<396+ { x: number; y: number; ox: number; oy: number } | null397+ >(null);398+ useEffect(() => {399+ const c = canvasRef.current;400+ if (!c) return;401+ const down = (e: PointerEvent) => {402+ const v = viewRef.current;403+ if (!v || e.button !== 0) return;404+ dragRef.current = { x: e.clientX, y: e.clientY, ox: v.ox, oy: v.oy };405+ c.setPointerCapture(e.pointerId);406+ e.preventDefault();407+ };408+ const move = (e: PointerEvent) => {409+ const d = dragRef.current, v = viewRef.current;410+ if (!d || !v) return;411+ setView({412+ scale: v.scale,413+ ox: d.ox + e.clientX - d.x,414+ oy: d.oy + e.clientY - d.y,415+ });416+ };417+ const up = () => {418+ dragRef.current = null;419+ };420+ const wheel = (e: WheelEvent) => {421+ const v = viewRef.current;422+ if (!v || !state.manifest) return;423+ e.preventDefault();424+ const rect = c.getBoundingClientRect();425+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;426+ const fit = fitView(state.G, c.clientWidth, c.clientHeight).scale;427+ const minS = fit * 0.5,428+ maxS = Math.min(c.clientWidth, c.clientHeight) / 6;429+ const k = Math.exp(-e.deltaY * 0.0015);430+ const ns = Math.max(minS, Math.min(maxS, v.scale * k));431+ const f = ns / v.scale;432+ setView({433+ scale: ns,434+ ox: mx - (mx - v.ox) * f,435+ oy: my - (my - v.oy) * f,436+ });437+ };438+ c.addEventListener("pointerdown", down);439+ c.addEventListener("pointermove", move);440+ c.addEventListener("pointerup", up);441+ c.addEventListener("pointercancel", up);442+ c.addEventListener("wheel", wheel, { passive: false });443+ return () => {444+ c.removeEventListener("pointerdown", down);445+ c.removeEventListener("pointermove", move);446+ c.removeEventListener("pointerup", up);447+ c.removeEventListener("pointercancel", up);448+ c.removeEventListener("wheel", wheel);449+ };450+ }, [state.manifest, state.G]);451+452+ const st = stats(state);453+ const max = Math.max(0, events.length - 1);454+455+ return (456+ <div style="display:flex;flex-direction:column;height:100vh">457+ <div style="display:flex;flex:1;min-height:0">458+ <canvas459+ ref={canvasRef}460+ style="flex:1;min-width:0;display:block;cursor:grab;touch-action:none"461+ />462+ <div style="width:340px;border-left:1px solid var(--rule);display:flex;flex-direction:column;background:var(--chrome)">463+ <div style="padding:10px 12px;border-bottom:1px solid var(--rule);color:var(--text-dim);font-size:12px;line-height:1.6">464+ <div>465+ game {gameId ? gameId.slice(0, 8) : "-"}{" "}466+ {props.replay ? "replay" : live ? "live" : "scrubbing"}467+ </div>468+ <div>469+ revision {state.revision < 0 ? "-" : state.revision} of{" "}470+ {max < 0 ? "-" : max}471+ </div>472+ <div>473+ {state.manifest474+ ? `${state.manifest.tiles.length} tiles (${state.manifest.packer.kind}), ${state.G}x${state.G} field, ${475+ state.geo?.holes ?? 0476+ } hole cells, seed tile #${state.seed_tile}`477+ : "no manifest"}478+ </div>479+ <div>480+ solved {st.solved}/{state.tiles.length} tiles, status{" "}481+ {state.status}482+ </div>483+ <div>484+ forced {state.totals.forced}, guess {state.totals.guess}, wrong485+ {" "}486+ {state.totals.wrong}, retracted {state.totals.retracted}487+ </div>488+ <div>489+ reveal:commit {st.ratio}, batch size {st.drift}, mean gap {st.dt}490+ </div>491+ <div>492+ reward {state.totals.reward} ({st.rewardPerBatch}{" "}493+ per batch), mean cells per solved tile {st.meanCells}494+ </div>495+ </div>496+ <div497+ ref={paneRef}498+ style="flex:1;overflow:auto;padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px"499+ >500+ {state.console.map((e) => (501+ <div502+ key={e.rev}503+ style="padding:4px 0;border-bottom:1px solid var(--rule)"504+ >505+ <div style="color:var(--muted)">506+ #{e.rev} {e.op}507+ {e.dt_ms !== null ? ` +${(e.dt_ms / 1000).toFixed(1)}s` : ""}508+ </div>509+ <div style="color:var(--text)">{entryLine(e)}</div>510+ {e.intent && e.op !== "start"511+ ? (512+ <div style="color:var(--text-strong);white-space:pre-wrap">513+ {e.intent}514+ </div>515+ )516+ : null}517+ </div>518+ ))}519+ </div>520+ </div>521+ </div>522+ <div style="display:flex;align-items:center;gap:10px;padding:8px 12px;border-top:1px solid var(--rule);background:var(--chrome)">523+ <span style="color:var(--muted);font-size:12px;min-width:60px">524+ rev {rev < 0 ? "-" : rev}525+ </span>526+ <input527+ type="range"528+ min={0}529+ max={max}530+ value={Math.max(0, rev)}531+ disabled={events.length === 0}532+ style="flex:1"533+ onInput={(e) => {534+ const v = Number((e.target as HTMLInputElement).value);535+ setRev(v);536+ setLive(props.replay === null && v >= max);537+ }}538+ />539+ <span style="color:var(--muted);font-size:12px;min-width:40px;text-align:right">540+ {max < 0 ? "-" : max}541+ </span>542+ </div>543+ </div>544+ );545+}@@ -0,0 +1,192 @@
1+// Tile ownership as background blend. Every colour is one opaque sRGB fill:2+// the circular mean of the owners' hues at a fixed OKLCH lightness and3+// chroma, mixed with the panel grey by a strength that encodes status.4+// No alpha is ever stacked, so seams and corners never go lighter.5+6+import { HUES, type TileStatus } from "./fold.ts";7+8+/**9+ * Theme: the chrome greys and the glyph ink. Glyphs are drawn in the theme's10+ * non-colour opposite to the page (black on dark, white on light), which11+ * keeps their intensity low; the tile hues are the same in both.12+ */13+export type Theme = {14+ name: "dark" | "light";15+ bg: string;16+ panel: string;17+ rule: string;18+ ink: string;19+ inkGiven: string; // ink with a lightness bump: lighter on dark, darker on light20+ muted: string;21+ unknownShift: number; // OKLab lightness shift for cells not yet known22+};23+export const DARK: Theme = {24+ name: "dark",25+ bg: "#131316",26+ panel: "#1a1a1e",27+ rule: "#333336",28+ ink: "#0b0b0e",29+ inkGiven: "#3e3e46",30+ muted: "#8c8c92",31+ unknownShift: -0.05,32+};33+export const LIGHT: Theme = {34+ name: "light",35+ bg: "#f3f3f5",36+ panel: "#e7e7ea",37+ rule: "#cfcfd4",38+ ink: "#ffffff",39+ inkGiven: "#d2d2d8",40+ muted: "#6e6e76",41+ unknownShift: 0.045,42+};43+export const PANEL = DARK.panel;44+export const RULE = DARK.rule;45+export const INK = DARK.ink;46+export const MUTED = DARK.muted;47+48+const L = 0.6;49+const C = 0.095;50+51+/**52+ * Status strength of the wash, for a cell whose value is known. A cell53+ * that is not yet known sits lower (see cellFill): the field reads as a54+ * surface that fills in, not as a grid that gets stamped.55+ */56+export const STRENGTH: Record<TileStatus, number> = {57+ solved: 0.6,58+ revealed: 0.4,59+ hidden: 0.12,60+};61+const UNKNOWN_STRENGTH = 0.55; // share of the status strength an unknown cell keeps62+const RANK: Record<TileStatus, number> = { hidden: 0, revealed: 1, solved: 2 };63+64+export function hueAngle(i: number): number {65+ return (360 / HUES) * i + 20;66+}67+68+function oklabToSrgb(69+ l: number,70+ a: number,71+ b: number,72+): [number, number, number] {73+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b;74+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b;75+ const s_ = l - 0.0894841775 * a - 1.2914855480 * b;76+ const l3 = l_ * l_ * l_, m3 = m_ * m_ * m_, s3 = s_ * s_ * s_;77+ const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;78+ const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;79+ const bb = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;80+ const gamma = (c: number) => {81+ const x = Math.max(0, Math.min(1, c));82+ return x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;83+ };84+ return [gamma(r), gamma(g), gamma(bb)];85+}86+87+function srgbToOklab(88+ r: number,89+ g: number,90+ b: number,91+): [number, number, number] {92+ const lin = (93+ c: number,94+ ) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));95+ const rl = lin(r), gl = lin(g), bl = lin(b);96+ const l = Math.cbrt(97+ 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl,98+ );99+ const m = Math.cbrt(100+ 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl,101+ );102+ const s = Math.cbrt(103+ 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl,104+ );105+ return [106+ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,107+ 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,108+ 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,109+ ];110+}111+112+function hexToRgb(hex: string): [number, number, number] {113+ const v = parseInt(hex.slice(1), 16);114+ return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];115+}116+117+function toHex(rgb: [number, number, number]): string {118+ return "#" +119+ rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join("");120+}121+122+/** Mean of a set of hue indexes, in OKLCH at fixed L and C, as OKLab. */123+export function meanHueLab(hues: number[]): [number, number, number] {124+ let sa = 0, sb = 0;125+ for (const hi of hues) {126+ const h = (hueAngle(hi) * Math.PI) / 180;127+ sa += Math.cos(h);128+ sb += Math.sin(h);129+ }130+ const ang = Math.atan2(sb, sa);131+ // constant chroma: the mean direction at the shared C, never a shorter vector132+ return [L, C * Math.cos(ang), C * Math.sin(ang)];133+}134+135+const panelLabCache = new Map<string, [number, number, number]>();136+function panelLabOf(panel: string): [number, number, number] {137+ let v = panelLabCache.get(panel);138+ if (!v) {139+ v = srgbToOklab(...hexToRgb(panel));140+ panelLabCache.set(panel, v);141+ }142+ return v;143+}144+145+/**146+ * One opaque fill: panel grey mixed toward the owners' mean hue by status147+ * strength. The overview is value-blind: nothing here depends on 0 or 1.148+ * `known` lifts a cell to the full status wash; unknown cells keep a share149+ * of it and shift toward the background. `recency` in 0..1 is a short150+ * lightness lift for cells that just became known, so a batch lands like151+ * a ripple and settles.152+ */153+export function cellFill(154+ hues: number[],155+ status: TileStatus,156+ known = true,157+ theme: Theme = DARK,158+ recency = 0,159+): string {160+ const panelLab = panelLabOf(theme.panel);161+ if (hues.length === 0) {162+ // an ownerless known cell (a v2 filler zone): flat panel grey163+ return toHex(oklabToSrgb(panelLab[0], panelLab[1], panelLab[2]));164+ }165+ let k = STRENGTH[status];166+ let shift = 0;167+ if (!known) {168+ k *= UNKNOWN_STRENGTH;169+ shift = theme.unknownShift;170+ }171+ const [l, a, b] = meanHueLab(hues);172+ const lift = recency * 0.07 * (theme.name === "dark" ? 1 : -1);173+ return toHex(oklabToSrgb(174+ panelLab[0] + (l - panelLab[0]) * k + shift + lift,175+ panelLab[1] + (a - panelLab[1]) * k,176+ panelLab[2] + (b - panelLab[2]) * k,177+ ));178+}179+180+/** Solid tile colour, for outlines; `soft` lowers it toward the wash. */181+export function tileStroke(hue: number, soft = false): string {182+ const [l, a, b] = meanHueLab([hue]);183+ return soft184+ ? toHex(oklabToSrgb(l + 0.02, a * 0.7, b * 0.7))185+ : toHex(oklabToSrgb(l + 0.12, a, b));186+}187+188+export function highestStatus(statuses: TileStatus[]): TileStatus {189+ let best: TileStatus = "hidden";190+ for (const s of statuses) if (RANK[s] > RANK[best]) best = s;191+ return best;192+}@@ -0,0 +1,137 @@
1+// Deno.Command wrapper around mosaic-engine. JSON in, JSON out, no state.2+import type { AnyManifest, LocalRect } from "./fold.ts";3+4+/** Private per-tile generator output; never written to the log. */5+export type GenTile = {6+ id: number;7+ givens: [number, 0 | 1][];8+ blank?: number;9+ flip?: number[];10+ needs_unique?: boolean;11+ needs_bifurcation?: boolean;12+ ladder?: Record<string, unknown>;13+};14+15+export type GenOutput = {16+ manifest: AnyManifest;17+ solution: string[]; // G rows; '.' where no tile covers the cell18+ tiles: GenTile[];19+ stats?: Record<string, number>;20+};21+22+/** A revealed tile as the judge sees it: bounding box and rect union. */23+export type JudgeShape = {24+ x0: number;25+ y0: number;26+ w: number;27+ h: number;28+ rects: LocalRect[];29+};30+31+export type JudgeInput = {32+ G: number;33+ shapes: JudgeShape[]; // revealed tiles34+ known: [number, number][];35+ candidates: [number, number][];36+ depth: number;37+};38+39+export type JudgeResult = {40+ id: number;41+ v: number;42+ class: "forced" | "guess" | "contradiction" | "known";43+ rules: string[];44+};45+46+export type JudgeOutput = { ms: number; steps: number; results: JudgeResult[] };47+48+const here = new URL(".", import.meta.url);49+50+export function enginePath(): string {51+ return Deno.env.get("MOSAIC_ENGINE") ??52+ new URL("../../engine/bin/mosaic-engine", here).pathname;53+}54+55+export function cacheDir(): string {56+ return Deno.env.get("MOSAIC_CACHE") ??57+ new URL("../data/cache/", here).pathname;58+}59+60+async function sha256(s: string): Promise<string> {61+ const buf = await crypto.subtle.digest(62+ "SHA-256",63+ new TextEncoder().encode(s),64+ );65+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0"))66+ .join("");67+}68+69+async function run(args: string[], stdin?: string): Promise<string> {70+ const cmd = new Deno.Command(enginePath(), {71+ args,72+ stdin: stdin === undefined ? "null" : "piped",73+ stdout: "piped",74+ stderr: "piped",75+ });76+ const child = cmd.spawn();77+ if (stdin !== undefined) {78+ const w = child.stdin.getWriter();79+ await w.write(new TextEncoder().encode(stdin));80+ await w.close();81+ }82+ const out = await child.output();83+ const text = new TextDecoder().decode(out.stdout);84+ if (!out.success) {85+ const err = new TextDecoder().decode(out.stderr).trim();86+ throw new Error(`mosaic-engine ${args[0]} failed: ${err || text}`);87+ }88+ return text;89+}90+91+/** Packing parameters: the board side in cells and the packer kind. */92+export type GenParams = {93+ seed: string;94+ G: number;95+ kind: "scatter" | "grid" | "big-first" | "bands";96+ n?: number; // grid only97+};98+99+/** The single place the generator's command line is spelled out. */100+export function genArgs(p: GenParams): string[] {101+ const args = ["gen", "--seed", p.seed, "--G", String(p.G), "--kind", p.kind];102+ if (p.kind === "grid") args.push("--n", String(p.n ?? 14));103+ return args;104+}105+106+let engineVersion: Promise<string> | undefined;107+108+/** The generator's version string (`mosaic-engine version`), read once. */109+export function version(): Promise<string> {110+ engineVersion ??= run(["version"]).then((t) => t.trim());111+ return engineVersion;112+}113+114+/** Generation is a batch job: results are cached by parameter hash, which115+ * includes the generator version so a changed generator never serves a116+ * stale board for the same seed. */117+export async function gen(p: GenParams): Promise<GenOutput> {118+ const key = await sha256(119+ `${await version()}|${p.seed}|${p.G}|${p.kind}|${p.n ?? ""}`,120+ );121+ const dir = cacheDir();122+ const file = `${dir}${key}.json`;123+ try {124+ return JSON.parse(await Deno.readTextFile(file)) as GenOutput;125+ } catch {126+ // miss127+ }128+ const text = await run(genArgs(p));129+ await Deno.mkdir(dir, { recursive: true });130+ await Deno.writeTextFile(file, text);131+ return JSON.parse(text) as GenOutput;132+}133+134+export async function judge(input: JudgeInput): Promise<JudgeOutput> {135+ const text = await run(["judge"], JSON.stringify(input));136+ return JSON.parse(text) as JudgeOutput;137+}@@ -0,0 +1,712 @@
1+// Pure game state: manifest v3 types, geometry derived from the tile2+// shapes, and the fold over the append-only log. No Deno or DOM APIs here;3+// the referee, the SSE viewer, and replay all use this same fold, so cells4+// are derived from events and never stored. A v2 manifest (square tiles,5+// seam segments, filler zones) is converted on load, so old logs replay.6+7+/** A rectangle in a tile's local coordinates: [lx, ly, w, h]. */8+export type LocalRect = [number, number, number, number];9+10+/** Cells shared with one neighbour: the partner id and how many. */11+export type Overlap = { with: number; cells: number };12+13+/**14+ * A tile is a shape: the union of `rects` inside a w x h bounding box at15+ * (x0, y0). Every maximal horizontal or vertical run of its cells is a16+ * line the Takuzu rules apply to.17+ */18+export type TileSpec = {19+ id: number;20+ x0: number;21+ y0: number;22+ w: number;23+ h: number;24+ rects: LocalRect[];25+ cells: number;26+ hash?: string;27+ m: number;28+ overlaps: Overlap[];29+ pins?: number[];30+};31+32+/** v2 filler zone: ownerless cells known from the start. Kept for old logs. */33+export type ZoneSpec = {34+ id: number;35+ rects: [number, number, number, number][]; // x0, y0, w, h in cells36+ values?: string; // row-major over the rects in order37+};38+39+export type Manifest = {40+ version: 3;41+ id: string;42+ seed_commit: string;43+ G: number;44+ packer: { kind: string; params?: Record<string, unknown> };45+ m_by_n?: Record<string, number>;46+ rules: Record<string, unknown>;47+ seed_tile: number;48+ tiles: TileSpec[];49+ zones?: ZoneSpec[]; // only on manifests converted from v250+ k: string | number;51+ k_hist?: Record<string, number>;52+ generator: string;53+ library: string | null;54+ source_version?: number; // 2 when converted on load55+};56+57+/** v2 shape, as written by gen-v2..v4 and the two logs on disk. */58+export type ManifestV2 = {59+ version: 2;60+ id: string;61+ seed_commit: string;62+ G: number;63+ packer: { kind: string; params?: Record<string, unknown> };64+ m_by_n: Record<string, number>;65+ rules: Record<string, unknown>;66+ seed_tile: number;67+ tiles: {68+ id: number;69+ x0: number;70+ y0: number;71+ n: number;72+ hash?: string;73+ m: number;74+ seams: { side: string; from: number; len: number; with: number }[];75+ pins?: number[];76+ }[];77+ zones: ZoneSpec[];78+ k: string;79+ generator: string;80+ library: string | null;81+};82+83+export type AnyManifest = Manifest | ManifestV2;84+85+export type GameRules = { guess: "accept" | "reject"; depth: number };86+87+export type Class = "forced" | "guess";88+export type CellKind = "given" | "commit" | "zone";89+90+export type AcceptedCell = {91+ id: number;92+ v: 0 | 1;93+ class: Class;94+ rules: string[];95+};96+97+export type RejectedCell = { id: number; reason: "wrong" | "guess" };98+99+export type ManifestEvent = {100+ rev: 0;101+ t: string;102+ kind: "manifest";103+ game_id: string;104+ manifest: AnyManifest;105+ rules: GameRules;106+};107+108+export type RevealEvent = {109+ rev: number;110+ t: string;111+ kind: "batch";112+ op: "reveal";113+ tile: number;114+ givens: [number, 0 | 1][];115+ seam: [number, 0 | 1][];116+ intent: string;117+};118+119+export type CommitEvent = {120+ rev: number;121+ t: string;122+ kind: "batch";123+ op: "commit";124+ intent: string;125+ accepted: AcceptedCell[];126+ rejected: RejectedCell[];127+ ignored: number[];128+ solved: number[];129+ judge_ms: number;130+};131+132+export type RetractEvent = {133+ rev: number;134+ t: string;135+ kind: "batch";136+ op: "retract";137+ cells: number[];138+ intent: string;139+};140+141+export type EndEvent = {142+ rev: number;143+ t: string;144+ kind: "end";145+ seed?: string;146+ nonce?: string;147+};148+149+export type GameEvent =150+ | ManifestEvent151+ | RevealEvent152+ | CommitEvent153+ | RetractEvent154+ | EndEvent;155+156+export type TileStatus = "hidden" | "revealed" | "solved";157+158+export type CellInfo = {159+ v: 0 | 1;160+ kind: CellKind;161+ class?: Class;162+ rules?: string[];163+ rev: number;164+};165+166+export type ConsoleEntry = {167+ rev: number;168+ t: string;169+ op: "start" | "reveal" | "commit" | "retract" | "end";170+ intent: string;171+ tile?: number;172+ accepted: number;173+ forced: number;174+ guess: number;175+ rejected: number;176+ ignored: number;177+ retracted: number;178+ revealed_cells: number;179+ solved: number[];180+ reward: number; // cells per tile solved by this batch181+ dt_ms: number | null; // wall-clock since the previous batch182+};183+184+/**185+ * Geometry derived once from a manifest: a CSR owner index (cell -> tile ids),186+ * tile cell lists, adjacency, and a hue per tile. Immutable after build and187+ * shared by every state folded from the same manifest.188+ */189+export type Geometry = {190+ G: number;191+ ownerStart: Uint32Array; // length G*G + 1192+ ownerList: Uint16Array; // tile ids, grouped per cell193+ hues: Uint8Array; // per tile id194+ adjacency: number[][]; // per tile id, sorted: tiles sharing a cell195+ cellsOf: number[][]; // per tile id, sorted global cell ids196+ holes: number; // cells no tile covers197+};198+199+export type State = {200+ game_id: string;201+ revision: number;202+ manifest: Manifest | null;203+ geo: Geometry | null;204+ G: number;205+ seed_tile: number;206+ rules: GameRules;207+ tiles: TileStatus[]; // by tile id208+ cells: Record<number, CellInfo>;209+ console: ConsoleEntry[];210+ status: "playing" | "solved";211+ totals: {212+ reveals: number;213+ commits: number;214+ retracts: number;215+ forced: number;216+ guess: number;217+ wrong: number;218+ retracted: number;219+ reward: number;220+ };221+ last_t: string | null;222+};223+224+// ---------------------------------------------------------------- geometry225+226+export const HUES = 7;227+228+export function cellId(x: number, y: number, G: number): number {229+ return y * G + x;230+}231+232+export function cellXY(id: number, G: number): [number, number] {233+ return [id % G, Math.floor(id / G)];234+}235+236+/** Bounding box of a tile: inclusive x0..x1, y0..y1 in global cells. */237+export function tileWindow(238+ t: TileSpec,239+): { x0: number; y0: number; x1: number; y1: number } {240+ return { x0: t.x0, y0: t.y0, x1: t.x0 + t.w - 1, y1: t.y0 + t.h - 1 };241+}242+243+/** Local mask of a tile's bounding box: 1 where the tile has a cell. */244+export function tileMask(t: TileSpec): Uint8Array {245+ const mask = new Uint8Array(t.w * t.h);246+ for (const [lx, ly, rw, rh] of t.rects) {247+ for (let y = ly; y < ly + rh; y++) {248+ for (let x = lx; x < lx + rw; x++) mask[y * t.w + x] = 1;249+ }250+ }251+ return mask;252+}253+254+export function tileContains(t: TileSpec, x: number, y: number): boolean {255+ const lx = x - t.x0, ly = y - t.y0;256+ if (lx < 0 || ly < 0 || lx >= t.w || ly >= t.h) return false;257+ for (const [rx, ry, rw, rh] of t.rects) {258+ if (lx >= rx && lx < rx + rw && ly >= ry && ly < ry + rh) return true;259+ }260+ return false;261+}262+263+/** Global ids of a tile's cells, sorted. */264+export function tileCells(t: TileSpec, G: number): number[] {265+ const mask = tileMask(t);266+ const ids: number[] = [];267+ for (let y = 0; y < t.h; y++) {268+ for (let x = 0; x < t.w; x++) {269+ if (mask[y * t.w + x]) ids.push(cellId(t.x0 + x, t.y0 + y, G));270+ }271+ }272+ return ids;273+}274+275+/**276+ * Convert a v2 manifest (square tiles, seam segments, filler zones) to v3277+ * shapes. Overlaps are recomputed from the geometry; zones are kept so278+ * their cells stay known from revision 0. A v3 manifest is returned as is.279+ */280+export function normaliseManifest(m: AnyManifest): Manifest {281+ if (m.version === 3) return m;282+ const tiles: TileSpec[] = m.tiles.map((t) => ({283+ id: t.id,284+ x0: t.x0,285+ y0: t.y0,286+ w: t.n,287+ h: t.n,288+ rects: [[0, 0, t.n, t.n] as LocalRect],289+ cells: t.n * t.n,290+ hash: t.hash,291+ m: t.m,292+ overlaps: [],293+ pins: t.pins,294+ }));295+ const out: Manifest = {296+ version: 3,297+ id: m.id,298+ seed_commit: m.seed_commit,299+ G: m.G,300+ packer: m.packer,301+ m_by_n: m.m_by_n,302+ rules: m.rules,303+ seed_tile: m.seed_tile,304+ tiles,305+ zones: m.zones,306+ k: m.k,307+ generator: m.generator,308+ library: m.library,309+ source_version: 2,310+ };311+ const geo = buildGeometry(out);312+ for (const t of tiles) t.overlaps = overlapsOf(geo, t.id);313+ return out;314+}315+316+/** Shared-cell counts between a tile and each tile it touches. */317+export function overlapsOf(geo: Geometry, tile: number): Overlap[] {318+ const counts = new Map<number, number>();319+ for (const id of geo.cellsOf[tile]) {320+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {321+ const o = geo.ownerList[i];322+ if (o !== tile) counts.set(o, (counts.get(o) ?? 0) + 1);323+ }324+ }325+ return [...counts.entries()].sort((a, b) => a[0] - b[0]).map((326+ [w, c],327+ ) => ({ with: w, cells: c }));328+}329+330+/** Build the owner index, tile cell lists, adjacency and hues for a manifest. */331+export function buildGeometry(m: Manifest): Geometry {332+ const G = m.G;333+ const N = G * G;334+ const cellsOf = m.tiles.map((t) => tileCells(t, G));335+ const count = new Uint32Array(N + 1);336+ for (const ids of cellsOf) for (const c of ids) count[c + 1]++;337+ const ownerStart = new Uint32Array(N + 1);338+ for (let i = 0; i < N; i++) ownerStart[i + 1] = ownerStart[i] + count[i + 1];339+ const ownerList = new Uint16Array(ownerStart[N]);340+ const fill = new Uint32Array(N);341+ for (const t of m.tiles) {342+ for (const c of cellsOf[t.id]) ownerList[ownerStart[c] + fill[c]++] = t.id;343+ }344+ // adjacency: tiles sharing any cell345+ const adj: Set<number>[] = m.tiles.map(() => new Set<number>());346+ let holes = 0;347+ for (let c = 0; c < N; c++) {348+ const a = ownerStart[c], b = ownerStart[c + 1];349+ if (a === b) holes++;350+ if (b - a < 2) continue;351+ for (let i = a; i < b; i++) {352+ for (let j = a; j < b; j++) {353+ if (i !== j) adj[ownerList[i]].add(ownerList[j]);354+ }355+ }356+ }357+ const adjacency = adj.map((s) => [...s].sort((p, q) => p - q));358+ return {359+ G,360+ ownerStart,361+ ownerList,362+ hues: colourTiles(adjacency),363+ adjacency,364+ cellsOf,365+ holes,366+ };367+}368+369+/**370+ * Greedy 7-colouring in smallest-last order (the contact graph of a square371+ * packing is planar, so this never needs more than 6). Deterministic.372+ */373+export function colourTiles(adjacency: number[][]): Uint8Array {374+ const n = adjacency.length;375+ const hues = new Uint8Array(n).fill(255);376+ const deg = adjacency.map((a) => a.length);377+ const removed = new Uint8Array(n);378+ const order: number[] = [];379+ for (let k = 0; k < n; k++) {380+ let best = -1;381+ for (let i = 0; i < n; i++) {382+ if (!removed[i] && (best < 0 || deg[i] < deg[best])) best = i;383+ }384+ removed[best] = 1;385+ order.push(best);386+ for (const j of adjacency[best]) if (!removed[j]) deg[j]--;387+ }388+ for (let k = order.length - 1; k >= 0; k--) {389+ const i = order[k];390+ const used = new Set<number>();391+ for (const j of adjacency[i]) if (hues[j] !== 255) used.add(hues[j]);392+ let h = 0;393+ while (used.has(h) && h < HUES) h++;394+ if (h === HUES) {395+ // planarity says this cannot happen; degrade to the least-used hue396+ const counts = new Array(HUES).fill(0);397+ for (const j of adjacency[i]) if (hues[j] !== 255) counts[hues[j]]++;398+ h = counts.indexOf(Math.min(...counts));399+ }400+ hues[i] = h;401+ }402+ return hues;403+}404+405+/** Tile ids owning a cell (empty for a hole). */406+export function ownersOf(geo: Geometry, id: number): number[] {407+ const out: number[] = [];408+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {409+ out.push(geo.ownerList[i]);410+ }411+ return out;412+}413+414+export function ownerCount(geo: Geometry, id: number): number {415+ return geo.ownerStart[id + 1] - geo.ownerStart[id];416+}417+418+/** A hole: a cell no tile covers (a v2 filler-zone cell is one too). */419+export function isHole(geo: Geometry, id: number): boolean {420+ return ownerCount(geo, id) === 0;421+}422+423+/** Hues of a cell's owners, for the colour mix. */424+export function hueOf(geo: Geometry, id: number): number[] {425+ const out: number[] = [];426+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {427+ out.push(geo.hues[geo.ownerList[i]]);428+ }429+ return out;430+}431+432+/** Tile ids sharing a cell with t, from its overlaps. */433+export function neighboursOf(t: TileSpec): number[] {434+ return t.overlaps.map((o) => o.with).sort((a, b) => a - b);435+}436+437+/** v2 filler-zone cells with their values (none on a v3 manifest). */438+export function zoneCells(m: Manifest): [number, 0 | 1][] {439+ const out: [number, 0 | 1][] = [];440+ for (const z of m.zones ?? []) {441+ let k = 0;442+ for (const [x0, y0, w, h] of z.rects) {443+ for (let y = y0; y < y0 + h; y++) {444+ for (let x = x0; x < x0 + w; x++) {445+ const ch = z.values?.[k++];446+ out.push([cellId(x, y, m.G), ch === "1" ? 1 : 0]);447+ }448+ }449+ }450+ }451+ return out;452+}453+454+// -------------------------------------------------------------------- fold455+456+export function emptyState(): State {457+ return {458+ game_id: "",459+ revision: -1,460+ manifest: null,461+ geo: null,462+ G: 0,463+ seed_tile: 0,464+ rules: { guess: "accept", depth: 0 },465+ tiles: [],466+ cells: {},467+ console: [],468+ status: "playing",469+ totals: {470+ reveals: 0,471+ commits: 0,472+ retracts: 0,473+ forced: 0,474+ guess: 0,475+ wrong: 0,476+ retracted: 0,477+ reward: 0,478+ },479+ last_t: null,480+ };481+}482+483+/** Frontier: unsolved tiles that are the seed tile or share a cell with a solved tile. */484+export function frontierOf(s: State): number[] {485+ if (!s.manifest || !s.geo) return [];486+ const out: number[] = [];487+ for (const t of s.manifest.tiles) {488+ if (s.tiles[t.id] === "solved") continue;489+ const isSeed = t.id === s.seed_tile;490+ const nearSolved = s.geo.adjacency[t.id].some((u) =>491+ s.tiles[u] === "solved"492+ );493+ if (isSeed || nearSolved) out.push(t.id);494+ }495+ return out;496+}497+498+export function isOnFrontier(s: State, tile: number): boolean {499+ return frontierOf(s).includes(tile);500+}501+502+function tileKnownCount(s: State, t: TileSpec): number {503+ let k = 0;504+ for (const id of s.geo!.cellsOf[t.id]) if (s.cells[id]) k++;505+ return k;506+}507+508+/** Recompute solved status; returns tiles newly solved and their reward. */509+function settle(s: State): { newly: number[]; reward: number } {510+ const newly: number[] = [];511+ let reward = 0;512+ if (!s.manifest) return { newly, reward };513+ for (const t of s.manifest.tiles) {514+ if (s.tiles[t.id] !== "revealed") continue;515+ if (tileKnownCount(s, t) === t.cells) {516+ s.tiles[t.id] = "solved";517+ newly.push(t.id);518+ reward += t.cells;519+ }520+ }521+ return { newly, reward };522+}523+524+function dt(s: State, t: string): number | null {525+ if (s.last_t === null) return null;526+ return new Date(t).getTime() - new Date(s.last_t).getTime();527+}528+529+/** Apply one event, returning a new state. The input is not mutated. */530+export function applyEvent(prev: State, ev: GameEvent): State {531+ const s: State = {532+ ...prev,533+ tiles: [...prev.tiles],534+ cells: { ...prev.cells },535+ console: [...prev.console],536+ totals: { ...prev.totals },537+ };538+ if (ev.rev !== prev.revision + 1) {539+ throw new Error(540+ `event rev ${ev.rev} does not follow revision ${prev.revision}`,541+ );542+ }543+ s.revision = ev.rev;544+ const base = {545+ rev: ev.rev,546+ t: ev.t,547+ accepted: 0,548+ forced: 0,549+ guess: 0,550+ rejected: 0,551+ ignored: 0,552+ retracted: 0,553+ revealed_cells: 0,554+ solved: [] as number[],555+ reward: 0,556+ dt_ms: dt(prev, ev.t),557+ };558+ switch (ev.kind) {559+ case "manifest": {560+ const m = normaliseManifest(ev.manifest);561+ s.game_id = ev.game_id;562+ s.manifest = m;563+ s.geo = buildGeometry(m);564+ s.G = m.G;565+ s.seed_tile = m.seed_tile;566+ s.rules = ev.rules;567+ s.tiles = new Array(m.tiles.length).fill("hidden");568+ for (const [id, v] of zoneCells(m)) {569+ s.cells[id] = { v, kind: "zone", rev: 0 };570+ }571+ const sizes = m.tiles.map((t) => t.cells);572+ s.console.push({573+ ...base,574+ op: "start",575+ intent: `${m.tiles.length} tiles of ${Math.min(...sizes)}..${576+ Math.max(...sizes)577+ } cells (${m.packer.kind}), ${s.G}x${s.G} field, ${s.geo.holes} hole cells, seed tile #${s.seed_tile}`,578+ });579+ break;580+ }581+ case "batch": {582+ if (ev.op === "reveal") {583+ s.tiles[ev.tile] = "revealed";584+ for (const [id, v] of ev.givens) {585+ if (!s.cells[id]) s.cells[id] = { v, kind: "given", rev: ev.rev };586+ }587+ s.totals.reveals++;588+ const { newly, reward } = settle(s);589+ s.totals.reward += reward;590+ s.console.push({591+ ...base,592+ op: "reveal",593+ intent: ev.intent,594+ tile: ev.tile,595+ revealed_cells: ev.givens.length + ev.seam.length,596+ solved: newly,597+ reward,598+ });599+ } else if (ev.op === "commit") {600+ for (const c of ev.accepted) {601+ s.cells[c.id] = {602+ v: c.v,603+ kind: "commit",604+ class: c.class,605+ rules: c.rules,606+ rev: ev.rev,607+ };608+ if (c.class === "forced") s.totals.forced++;609+ else s.totals.guess++;610+ }611+ s.totals.commits++;612+ s.totals.wrong += ev.rejected.filter((r) =>613+ r.reason === "wrong"614+ ).length;615+ const { newly, reward } = settle(s);616+ s.totals.reward += reward;617+ s.console.push({618+ ...base,619+ op: "commit",620+ intent: ev.intent,621+ accepted: ev.accepted.length,622+ forced: ev.accepted.filter((c) => c.class === "forced").length,623+ guess: ev.accepted.filter((c) => c.class === "guess").length,624+ rejected: ev.rejected.length,625+ ignored: ev.ignored.length,626+ solved: newly,627+ reward,628+ });629+ } else if (ev.op === "retract") {630+ let n = 0;631+ for (const id of ev.cells) {632+ if (s.cells[id]?.kind === "commit") {633+ delete s.cells[id];634+ n++;635+ }636+ }637+ s.totals.retracts++;638+ s.totals.retracted += n;639+ s.console.push({640+ ...base,641+ op: "retract",642+ intent: ev.intent,643+ retracted: n,644+ });645+ }646+ break;647+ }648+ case "end": {649+ s.status = "solved";650+ s.console.push({ ...base, op: "end", intent: "all tiles solved" });651+ break;652+ }653+ }654+ s.last_t = ev.t;655+ return s;656+}657+658+export function fold(events: GameEvent[]): State {659+ let s = emptyState();660+ for (const ev of events) s = applyEvent(s, ev);661+ return s;662+}663+664+/** States at every revision 0..N, for scrubbing. */665+export function foldAll(events: GameEvent[]): State[] {666+ const out: State[] = [];667+ let s = emptyState();668+ for (const ev of events) {669+ s = applyEvent(s, ev);670+ out.push(s);671+ }672+ return out;673+}674+675+export function allSolved(s: State): boolean {676+ return s.tiles.length > 0 && s.tiles.every((t) => t === "solved");677+}678+679+/** Mean cell count of the solved tiles (0 when none). */680+export function meanSolvedCells(s: State): number {681+ if (!s.manifest) return 0;682+ let sum = 0, k = 0;683+ for (const t of s.manifest.tiles) {684+ if (s.tiles[t.id] === "solved") {685+ sum += t.cells;686+ k++;687+ }688+ }689+ return k ? sum / k : 0;690+}691+692+/** Delta-code a sorted id list (cumulative sum decodes it). */693+export function deltaEncode(ids: number[]): number[] {694+ const sorted = [...ids].sort((a, b) => a - b);695+ let prev = 0;696+ return sorted.map((id) => {697+ const d = id - prev;698+ prev = id;699+ return d;700+ });701+}702+703+export function deltaDecode(deltas: number[]): number[] {704+ let acc = 0;705+ return deltas.map((d) => (acc += d));706+}707+708+export function parseLog(text: string): GameEvent[] {709+ return text.split("\n").filter((l) => l.trim().length > 0).map((l) =>710+ JSON.parse(l) as GameEvent711+ );712+}@@ -0,0 +1,60 @@
1+// In-memory registry of live games. State is rebuilt from the JSONL on2+// restart only for spectating (the private solution is not in the log, so a3+// restarted server cannot continue a game; the demo is one process).4+import { type GameRules } from "./fold.ts";5+import { gen, type GenParams, judge } from "./engine.ts";6+import { Referee } from "./referee.ts";7+8+type NewGameListener = (id: string) => void;9+type Registry = {10+ games: Map<string, Referee>;11+ latest: string | null;12+ listeners: Set<NewGameListener>;13+};14+15+// Kept on globalThis so a dev-server module re-evaluation (HMR after an16+// edit) does not lose the live games.17+const g = globalThis as unknown as { __mosaicGames?: Registry };18+const reg: Registry = g.__mosaicGames ??= {19+ games: new Map(),20+ latest: null,21+ listeners: new Set(),22+};23+const games = reg.games;24+const newGameListeners = reg.listeners;25+26+export function getGame(id: string): Referee | undefined {27+ return games.get(id);28+}29+30+export function latestGameId(): string | null {31+ return reg.latest;32+}33+34+export function onNewGame(l: NewGameListener): () => void {35+ newGameListeners.add(l);36+ return () => newGameListeners.delete(l);37+}38+39+export type StartParams = {40+ G: number;41+ kind: GenParams["kind"];42+ n?: number;43+ seed?: string;44+ rules?: Partial<GameRules>;45+};46+47+export async function startGame(p: StartParams): Promise<Referee> {48+ const gameId = crypto.randomUUID();49+ const seed = p.seed ?? gameId;50+ const out = await gen({ seed, G: p.G, kind: p.kind, n: p.n });51+ const rules: GameRules = {52+ guess: p.rules?.guess ?? "accept",53+ depth: p.rules?.depth ?? 0,54+ };55+ const ref = await Referee.create(gameId, out, rules, judge, { seed });56+ games.set(gameId, ref);57+ reg.latest = gameId;58+ for (const l of newGameListeners) l(gameId);59+ return ref;60+}@@ -0,0 +1,71 @@
1+// Append-only JSONL log per game, plus an in-process broadcast so the SSE2+// route can follow a game live. The file on disk is the replay source.3+import type { GameEvent } from "./fold.ts";4+5+const here = new URL(".", import.meta.url);6+7+export function gamesDir(): string {8+ return Deno.env.get("MOSAIC_GAMES") ??9+ new URL("../data/games/", here).pathname;10+}11+12+export function logPath(gameId: string): string {13+ if (!/^[a-zA-Z0-9_-]{1,80}$/.test(gameId)) throw new Error("bad game id");14+ return `${gamesDir()}${gameId}.jsonl`;15+}16+17+type Listener = (ev: GameEvent) => void;18+19+export class Log {20+ readonly events: GameEvent[] = [];21+ private listeners = new Set<Listener>();22+23+ constructor(readonly gameId: string, readonly persist = true) {}24+25+ async append(ev: GameEvent): Promise<void> {26+ if (ev.rev !== this.events.length) {27+ throw new Error(28+ `log rev ${ev.rev} out of order (have ${this.events.length})`,29+ );30+ }31+ if (this.persist) {32+ await Deno.mkdir(gamesDir(), { recursive: true });33+ await Deno.writeTextFile(34+ logPath(this.gameId),35+ JSON.stringify(ev) + "\n",36+ { append: true },37+ );38+ }39+ this.events.push(ev);40+ for (const l of this.listeners) l(ev);41+ }42+43+ subscribe(l: Listener): () => void {44+ this.listeners.add(l);45+ return () => this.listeners.delete(l);46+ }47+48+ text(): string {49+ return this.events.map((e) => JSON.stringify(e)).join("\n") + "\n";50+ }51+}52+53+export async function readLog(gameId: string): Promise<string | null> {54+ try {55+ return await Deno.readTextFile(logPath(gameId));56+ } catch {57+ return null;58+ }59+}60+61+export async function listLogs(): Promise<string[]> {62+ const out: string[] = [];63+ try {64+ for await (const e of Deno.readDir(gamesDir())) {65+ if (e.isFile && e.name.endsWith(".jsonl")) out.push(e.name.slice(0, -6));66+ }67+ } catch {68+ // no games yet69+ }70+ return out.sort();71+}@@ -0,0 +1,219 @@
1+// The /mcp tool surface. Mirrors mcpsweeper: expected_revision on every2+// write, cell arrays, a short intent string shown verbatim in the spectator.3+import { McpServer } from "@modelcontextprotocol/server";4+import { z } from "zod";5+import { getGame, startGame } from "./games.ts";6+import { RefereeError } from "./referee.ts";7+8+const RULES_TEXT =9+ `Rules of a mosaic. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), that differ on some cells it shares with a neighbour; a solved neighbour fixes those cells and pins it. Tiles are referred to by id; observe lists each tile's id, x0, y0, w, h, rects, cells, m, status and neighbours. Givens are hidden until you reveal a tile, and reveal is only accepted on the frontier: the seed tile at first, then hidden tiles sharing a cell with a solved tile. The seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and your correct commits); solving a tile earns its cell count as reward. Commits are checked per cell: wrong values are rejected (and reveal only that they were wrong); correct ones are accepted and classified forced or guess. Forced means the referee's own propagation of the three rules over the revealed cells (single-cell deductions to a fixpoint, plus bifurcation only to the game's configured depth, default 0) reaches that value; a cell that holds in every solution but needs deeper search is still recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Use observe to read the state, reveal to earn givens, commit to fill cells in batches with a one-line intent, retract to withdraw your own commits. Every write carries expected_revision; on conflict, observe again. Writing code to do the deduction is allowed; where to expand (small tiles open the field, large tiles pin more) and how much to commit is your strategy.`;10+11+const gameIdSchema = z.string().uuid();12+13+function textResult(text: string, structuredContent: Record<string, unknown>) {14+ return { content: [{ type: "text" as const, text }], structuredContent };15+}16+17+function errorResult(error: unknown) {18+ const message = error instanceof Error ? error.message : "Unknown error";19+ return {20+ isError: true as const,21+ content: [{ type: "text" as const, text: message }],22+ };23+}24+25+function requireGame(id: string) {26+ const g = getGame(id);27+ if (!g) throw new RefereeError("Game not found.", "missing");28+ return g;29+}30+31+export function createServer(): McpServer {32+ const server = new McpServer({ name: "mosaic", version: "0.2.0" });33+34+ server.registerTool(35+ "start_mosaic",36+ {37+ title: "Start a mosaic",38+ description:39+ `Create a new mosaic and open it in the spectator at http://127.0.0.1:8000/ . Defaults: kind=scatter with G=100, a field of tiles of random shapes and sizes scattered with overlaps and holes; kind=grid with n=14 and G=27 is the classic 2x2 of 14x14 squares (G-1 must be a multiple of n-1). G up to 1000. Then play autonomously with observe, reveal, commit, retract until status is solved. ${RULES_TEXT}`,40+ inputSchema: z.object({41+ G: z.number().int().min(6).max(1000).default(100).describe(42+ "Cells per side of the field. For kind=grid, G-1 must be a multiple of n-1 (27 for a 2x2 of 14x14).",43+ ),44+ kind: z.enum(["scatter", "grid", "big-first", "bands"]).default(45+ "scatter",46+ ).describe(47+ "Layout: scatter (shapes of random size dropped with overlaps and holes; the default), grid (all tiles the same n, squares), big-first and bands (square packings).",48+ ),49+ n: z.number().int().min(6).max(16).default(14).describe(50+ "Tile size for kind=grid, even; 14 is the standard.",51+ ),52+ seed: z.string().min(1).max(80).optional().describe(53+ "Seed string; omitted means a fresh random mosaic.",54+ ),55+ rules: z.object({56+ guess: z.enum(["accept", "reject"]).default("accept").describe(57+ "Whether unforced but correct commits are accepted.",58+ ),59+ depth: z.number().int().min(0).max(2).default(0).describe(60+ "Bifurcation depth the referee uses to call a cell forced.",61+ ),62+ }).optional(),63+ }),64+ },65+ async ({ G, kind, n, seed, rules }) => {66+ try {67+ if (n % 2 !== 0) return errorResult(new Error("n must be even."));68+ if (kind === "grid" && (G - 1) % (n - 1) !== 0) {69+ return errorResult(70+ new Error(`kind=grid needs G-1 to be a multiple of n-1.`),71+ );72+ }73+ const ref = await startGame({ G, kind, n, seed, rules });74+ const obs = ref.observe();75+ const m = ref.manifest;76+ return textResult(77+ `Started mosaic ${ref.gameId}: ${m.tiles.length} tiles on a ${obs.G}x${obs.G} field (${obs.holes} hole cells). Seed tile is #${obs.seed_tile}; reveal it first. Revision ${obs.revision}.`,78+ {79+ game_id: ref.gameId,80+ revision: obs.revision,81+ manifest: m,82+ seed_tile: obs.seed_tile,83+ frontier: obs.frontier,84+ layout: obs.encoding,85+ spectator_url: "http://127.0.0.1:8000/",86+ },87+ );88+ } catch (error) {89+ return errorResult(error);90+ }91+ },92+ );93+94+ server.registerTool(95+ "observe",96+ {97+ title: "Observe the mosaic",98+ description:99+ "Read the current revision, every tile (id, x0, y0, w, h, rects, cells, m, status, neighbours), the frontier, and every known cell (delta-coded ids plus a value string; decode by cumulative sum). Known cells are givens of revealed tiles, cells fixed by solved neighbours, and your accepted commits. The payload is under structuredContent.observation; cell id = y*G + x.",100+ inputSchema: z.object({ game_id: gameIdSchema }),101+ annotations: { readOnlyHint: true },102+ },103+ ({ game_id }) => {104+ try {105+ const ref = requireGame(game_id);106+ const obs = ref.observe();107+ const solved = obs.tiles.filter((t) => t.status === "solved").length;108+ return textResult(109+ `Game ${game_id} is ${obs.status} at revision ${obs.revision}; ${solved}/${obs.tiles.length} tiles solved; reward ${obs.totals.reward}; frontier ${110+ JSON.stringify(obs.frontier)111+ }.`,112+ { observation: obs },113+ );114+ } catch (error) {115+ return errorResult(error);116+ }117+ },118+ );119+120+ server.registerTool(121+ "reveal",122+ {123+ title: "Reveal a tile",124+ description:125+ "Reveal the givens of a hidden frontier tile, by tile id. Returns the tile's shape (x0, y0, w, h, rects, cells), its givens, and any of its cells already known (fixed by solved neighbours), as [cell_id, value] pairs. Appends one batch event.",126+ inputSchema: z.object({127+ game_id: gameIdSchema,128+ expected_revision: z.number().int().nonnegative(),129+ tile: z.number().int().nonnegative().describe("Tile id."),130+ intent: z.string().max(160).default(""),131+ }),132+ annotations: { destructiveHint: false, idempotentHint: false },133+ },134+ async ({ game_id, expected_revision, tile, intent }) => {135+ try {136+ const ref = requireGame(game_id);137+ const r = await ref.reveal(expected_revision, tile, intent);138+ return textResult(139+ `Revealed tile #${r.tile} (${r.cells} cells in a ${r.w}x${r.h} box at ${r.x0},${r.y0}): ${r.givens.length} givens, ${r.seam.length} cells already known. Revision ${r.revision}.`,140+ { ...r },141+ );142+ } catch (error) {143+ return errorResult(error);144+ }145+ },146+ );147+148+ server.registerTool(149+ "commit",150+ {151+ title: "Commit cells",152+ description:153+ "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected (wrong value, or an unforced guess when the game rejects guesses), or ignored (already known, a given, a hole, duplicate, or outside revealed tiles). Reports newly solved tile ids, the reward earned, and the new frontier.",154+ inputSchema: z.object({155+ game_id: gameIdSchema,156+ expected_revision: z.number().int().nonnegative(),157+ cells: z.array(158+ z.tuple([159+ z.number().int().nonnegative(),160+ z.number().int().min(0).max(1),161+ ]),162+ ).min(1).max(8192),163+ intent: z.string().max(160).default(""),164+ }),165+ annotations: { destructiveHint: true, idempotentHint: false },166+ },167+ async ({ game_id, expected_revision, cells, intent }) => {168+ try {169+ const ref = requireGame(game_id);170+ const r = await ref.commit(171+ expected_revision,172+ cells as [number, number][],173+ intent,174+ );175+ const forced = r.accepted.filter((c) => c.class === "forced").length;176+ const text = r.status === "solved"177+ ? `Mosaic solved at revision ${r.revision}: ${r.accepted.length} accepted, reward ${r.reward}.`178+ : `Batch ${r.revision}: ${r.accepted.length} accepted (${forced} forced, ${179+ r.accepted.length - forced180+ } guess), ${r.rejected.length} rejected, ${r.ignored.length} ignored; solved ${181+ JSON.stringify(r.solved)182+ } for ${r.reward} reward; frontier ${JSON.stringify(r.frontier)}.`;183+ return textResult(text, { ...r });184+ } catch (error) {185+ return errorResult(error);186+ }187+ },188+ );189+190+ server.registerTool(191+ "retract",192+ {193+ title: "Retract commits",194+ description:195+ "Withdraw cells you committed earlier (by cell id). Givens and cells fixed by neighbours cannot be retracted. Counted by the analyzer.",196+ inputSchema: z.object({197+ game_id: gameIdSchema,198+ expected_revision: z.number().int().nonnegative(),199+ cells: z.array(z.number().int().nonnegative()).min(1).max(8192),200+ intent: z.string().max(160).default(""),201+ }),202+ annotations: { destructiveHint: true, idempotentHint: false },203+ },204+ async ({ game_id, expected_revision, cells, intent }) => {205+ try {206+ const ref = requireGame(game_id);207+ const r = await ref.retract(expected_revision, cells, intent);208+ return textResult(209+ `Retracted ${r.retracted.length} cells. Revision ${r.revision}.`,210+ { ...r },211+ );212+ } catch (error) {213+ return errorResult(error);214+ }215+ },216+ );217+218+ return server;219+}@@ -0,0 +1,482 @@
1+// The referee orders the world. It holds the private generator output, gates2+// reveals by the frontier, validates commits against the solution, asks the3+// engine whether each accepted cell was forced, and appends exactly one event4+// per accepted call. Refusals throw RefereeError and append nothing.5+import {6+ type AcceptedCell,7+ allSolved,8+ applyEvent,9+ cellId,10+ type CommitEvent,11+ deltaEncode,12+ emptyState,13+ type EndEvent,14+ frontierOf,15+ type GameEvent,16+ type GameRules,17+ isHole,18+ isOnFrontier,19+ type LocalRect,20+ type ManifestEvent,21+ neighboursOf,22+ normaliseManifest,23+ ownersOf,24+ type RejectedCell,25+ type RetractEvent,26+ type RevealEvent,27+ type State,28+ type TileSpec,29+} from "./fold.ts";30+import type { GenOutput, JudgeInput, JudgeOutput } from "./engine.ts";31+import type { Manifest } from "./fold.ts";32+import { Log } from "./log.ts";33+34+export class RefereeError extends Error {35+ constructor(message: string, readonly code: string) {36+ super(message);37+ }38+}39+40+export type Judge = (input: JudgeInput) => Promise<JudgeOutput>;41+42+export type CommitResult = {43+ revision: number;44+ accepted: AcceptedCell[];45+ rejected: RejectedCell[];46+ ignored: { id: number; reason: string }[];47+ solved: number[];48+ reward: number;49+ frontier: number[];50+ status: State["status"];51+};52+53+export type RevealResult = {54+ revision: number;55+ tile: number;56+ x0: number;57+ y0: number;58+ w: number;59+ h: number;60+ rects: LocalRect[];61+ cells: number;62+ givens: [number, 0 | 1][];63+ seam: [number, 0 | 1][];64+ frontier: number[];65+};66+67+export type Observation = {68+ game_id: string;69+ revision: number;70+ status: State["status"];71+ G: number;72+ seed_tile: number;73+ frontier: number[];74+ tiles: {75+ id: number;76+ x0: number;77+ y0: number;78+ w: number;79+ h: number;80+ rects: LocalRect[];81+ cells: number;82+ m: number;83+ status: string;84+ known: number;85+ neighbours: number[];86+ }[];87+ holes: number;88+ known_deltas: number[];89+ known_values: string;90+ committed_deltas: number[];91+ totals: State["totals"];92+ encoding: Record<string, string>;93+};94+95+export class Referee {96+ state: State = emptyState();97+ readonly log: Log;98+ private solution: Uint8Array;99+ private givensByTile = new Map<number, [number, 0 | 1][]>();100+ private specs = new Map<number, TileSpec>();101+102+ /** the manifest as v3 (converted when the generator wrote v2) */103+ readonly manifest: Manifest;104+105+ private constructor(106+ readonly gameId: string,107+ readonly gen: GenOutput,108+ readonly rules: GameRules,109+ private judgeFn: Judge,110+ persist: boolean,111+ ) {112+ this.log = new Log(gameId, persist);113+ this.manifest = normaliseManifest(gen.manifest);114+ const G = this.manifest.G;115+ this.solution = new Uint8Array(G * G);116+ for (let y = 0; y < G; y++) {117+ for (let x = 0; x < G; x++) {118+ this.solution[cellId(x, y, G)] = gen.solution[y][x] === "1" ? 1 : 0;119+ }120+ }121+ for (const t of gen.tiles) this.givensByTile.set(t.id, t.givens);122+ for (const t of this.manifest.tiles) this.specs.set(t.id, t);123+ }124+125+ static async create(126+ gameId: string,127+ gen: GenOutput,128+ rules: GameRules,129+ judgeFn: Judge,130+ opts: { persist?: boolean; seed?: string } = {},131+ ): Promise<Referee> {132+ if (gen.manifest.version !== 2 && gen.manifest.version !== 3) {133+ throw new RefereeError(134+ "Generator output is not manifest v2 or v3.",135+ "version",136+ );137+ }138+ const r = new Referee(gameId, gen, rules, judgeFn, opts.persist ?? true);139+ r.seed = opts.seed;140+ const ev: ManifestEvent = {141+ rev: 0,142+ t: new Date().toISOString(),143+ kind: "manifest",144+ game_id: gameId,145+ manifest: gen.manifest,146+ rules,147+ };148+ await r.apply(ev);149+ return r;150+ }151+152+ private seed?: string;153+154+ private async apply(ev: GameEvent): Promise<void> {155+ const next = applyEvent(this.state, ev);156+ await this.log.append(ev);157+ this.state = next;158+ }159+160+ private checkRevision(expected: number): void {161+ if (expected !== this.state.revision) {162+ throw new RefereeError(163+ `Revision conflict: expected ${expected}, current revision is ${this.state.revision}. Observe again.`,164+ "revision",165+ );166+ }167+ if (this.state.status === "solved") {168+ throw new RefereeError("The mosaic is already solved.", "ended");169+ }170+ }171+172+ get revision(): number {173+ return this.state.revision;174+ }175+176+ private spec(tile: number): TileSpec {177+ const t = Number.isInteger(tile) ? this.specs.get(tile) : undefined;178+ if (!t) {179+ throw new RefereeError(180+ `Tile ${tile} does not exist; ids are 0..${this.specs.size - 1}.`,181+ "bounds",182+ );183+ }184+ return t;185+ }186+187+ /** Known cells of a tile that are not its own givens. */188+ private seamKnown(t: TileSpec): [number, 0 | 1][] {189+ const out: [number, 0 | 1][] = [];190+ const givens = new Set(this.givensByTile.get(t.id)!.map(([id]) => id));191+ for (const id of this.state.geo!.cellsOf[t.id]) {192+ const c = this.state.cells[id];193+ if (c && !givens.has(id)) out.push([id, c.v]);194+ }195+ return out;196+ }197+198+ async reveal(199+ expected: number,200+ tile: number,201+ intent = "",202+ ): Promise<RevealResult> {203+ this.checkRevision(expected);204+ const t = this.spec(tile);205+ if (this.state.tiles[t.id] !== "hidden") {206+ throw new RefereeError(207+ `Tile ${t.id} is already ${this.state.tiles[t.id]}.`,208+ "not_hidden",209+ );210+ }211+ if (!isOnFrontier(this.state, t.id)) {212+ throw new RefereeError(213+ `Tile ${t.id} is not on the frontier. Frontier: ${214+ JSON.stringify(frontierOf(this.state))215+ }.`,216+ "frontier",217+ );218+ }219+ const givens = this.givensByTile.get(t.id) ?? [];220+ const seam = this.seamKnown(t);221+ const ev: RevealEvent = {222+ rev: this.state.revision + 1,223+ t: new Date().toISOString(),224+ kind: "batch",225+ op: "reveal",226+ tile: t.id,227+ givens,228+ seam,229+ intent: clip(intent),230+ };231+ await this.apply(ev);232+ return {233+ revision: this.state.revision,234+ tile: t.id,235+ x0: t.x0,236+ y0: t.y0,237+ w: t.w,238+ h: t.h,239+ rects: t.rects,240+ cells: t.cells,241+ givens,242+ seam,243+ frontier: frontierOf(this.state),244+ };245+ }246+247+ private activeShapes(): TileSpec[] {248+ return this.manifest.tiles.filter((t) =>249+ this.state.tiles[t.id] !== "hidden"250+ );251+ }252+253+ private inActiveWindow(id: number): boolean {254+ const geo = this.state.geo!;255+ return ownersOf(geo, id).some((t) => this.state.tiles[t] !== "hidden");256+ }257+258+ async commit(259+ expected: number,260+ cells: [number, number][],261+ intent = "",262+ ): Promise<CommitResult> {263+ this.checkRevision(expected);264+ const G = this.state.G;265+ const geo = this.state.geo!;266+ const ignored: { id: number; reason: string }[] = [];267+ const seen = new Set<number>();268+ const candidates: [number, 0 | 1][] = [];269+ for (const cell of cells) {270+ if (!Array.isArray(cell) || cell.length !== 2) {271+ throw new RefereeError("Each cell is [id, value].", "shape");272+ }273+ const [id, v] = cell;274+ if (!Number.isInteger(id) || id < 0 || id >= G * G) {275+ throw new RefereeError(276+ `Cell id ${id} is out of range 0..${G * G - 1}.`,277+ "bounds",278+ );279+ }280+ if (v !== 0 && v !== 1) {281+ throw new RefereeError(`Cell ${id}: value must be 0 or 1.`, "value");282+ }283+ if (seen.has(id)) {284+ ignored.push({ id, reason: "duplicate" });285+ continue;286+ }287+ seen.add(id);288+ if (isHole(geo, id)) {289+ ignored.push({ id, reason: "hole" });290+ continue;291+ }292+ if (this.state.cells[id]) {293+ ignored.push({294+ id,295+ reason: this.state.cells[id].kind === "given" ? "given" : "known",296+ });297+ continue;298+ }299+ if (!this.inActiveWindow(id)) {300+ ignored.push({ id, reason: "not_revealed" });301+ continue;302+ }303+ candidates.push([id, v]);304+ }305+ if (candidates.length === 0) {306+ throw new RefereeError(307+ `No cell to process (${ignored.length} ignored: ${308+ summarise(ignored)309+ }). Nothing was recorded.`,310+ "empty",311+ );312+ }313+ const known: [number, number][] = Object.entries(this.state.cells).map((314+ [id, c],315+ ) => [Number(id), c.v]);316+ const judged = await this.judgeFn({317+ G,318+ shapes: this.activeShapes().map((t) => ({319+ x0: t.x0,320+ y0: t.y0,321+ w: t.w,322+ h: t.h,323+ rects: t.rects,324+ })),325+ known,326+ candidates,327+ depth: this.rules.depth,328+ });329+ const byId = new Map(judged.results.map((r) => [r.id, r]));330+ const accepted: AcceptedCell[] = [];331+ const rejected: RejectedCell[] = [];332+ for (const [id, v] of candidates) {333+ if (this.solution[id] !== v) {334+ rejected.push({ id, reason: "wrong" });335+ continue;336+ }337+ const j = byId.get(id);338+ const cls = j?.class === "forced" ? "forced" : "guess";339+ const rules = j?.class === "forced" ? j.rules : [];340+ if (cls === "guess" && this.rules.guess === "reject") {341+ rejected.push({ id, reason: "guess" });342+ continue;343+ }344+ accepted.push({ id, v, class: cls, rules });345+ }346+ const before = [...this.state.tiles];347+ const ev: CommitEvent = {348+ rev: this.state.revision + 1,349+ t: new Date().toISOString(),350+ kind: "batch",351+ op: "commit",352+ intent: clip(intent),353+ accepted,354+ rejected,355+ ignored: ignored.map((i) => i.id),356+ solved: [],357+ judge_ms: judged.ms,358+ };359+ // solved tiles are derived by the fold; record them on the event for readers360+ const preview = applyEvent(this.state, ev);361+ ev.solved = preview.tiles.map((s, i) => [s, i] as const)362+ .filter(([s, i]) => s === "solved" && before[i] !== "solved")363+ .map(([, i]) => i);364+ await this.apply(ev);365+ const reward = ev.solved.reduce((a, i) => a + this.specs.get(i)!.cells, 0);366+ if (allSolved(this.state)) {367+ const end: EndEvent = {368+ rev: this.state.revision + 1,369+ t: new Date().toISOString(),370+ kind: "end",371+ seed: this.seed,372+ };373+ await this.apply(end);374+ }375+ return {376+ revision: this.state.revision,377+ accepted,378+ rejected,379+ ignored,380+ solved: ev.solved,381+ reward,382+ frontier: frontierOf(this.state),383+ status: this.state.status,384+ };385+ }386+387+ async retract(388+ expected: number,389+ ids: number[],390+ intent = "",391+ ): Promise<{ revision: number; retracted: number[]; ignored: number[] }> {392+ this.checkRevision(expected);393+ const retracted: number[] = [];394+ const ignored: number[] = [];395+ for (const id of ids) {396+ if (!Number.isInteger(id)) {397+ throw new RefereeError("Cell ids are integers.", "shape");398+ }399+ if (this.state.cells[id]?.kind === "commit") retracted.push(id);400+ else ignored.push(id);401+ }402+ if (retracted.length === 0) {403+ throw new RefereeError(404+ "None of those cells is a commit of yours. Nothing was recorded.",405+ "empty",406+ );407+ }408+ const ev: RetractEvent = {409+ rev: this.state.revision + 1,410+ t: new Date().toISOString(),411+ kind: "batch",412+ op: "retract",413+ cells: retracted,414+ intent: clip(intent),415+ };416+ await this.apply(ev);417+ return { revision: this.state.revision, retracted, ignored };418+ }419+420+ observe(): Observation {421+ const s = this.state;422+ const ids = Object.keys(s.cells).map(Number).sort((a, b) => a - b);423+ const committed = ids.filter((id) => s.cells[id].kind === "commit");424+ const tiles = [];425+ for (const t of this.manifest.tiles) {426+ const st = s.tiles[t.id];427+ let known = 0;428+ if (st !== "hidden") {429+ for (const id of s.geo!.cellsOf[t.id]) if (s.cells[id]) known++;430+ }431+ tiles.push({432+ id: t.id,433+ x0: t.x0,434+ y0: t.y0,435+ w: t.w,436+ h: t.h,437+ rects: t.rects,438+ cells: t.cells,439+ m: t.m,440+ status: st,441+ known,442+ neighbours: neighboursOf(t),443+ });444+ }445+ return {446+ game_id: this.gameId,447+ revision: s.revision,448+ status: s.status,449+ G: s.G,450+ seed_tile: s.seed_tile,451+ frontier: frontierOf(s),452+ tiles,453+ holes: s.geo!.holes,454+ known_deltas: deltaEncode(ids),455+ known_values: ids.map((id) => String(s.cells[id].v)).join(""),456+ committed_deltas: deltaEncode(committed),457+ totals: s.totals,458+ encoding: {459+ cell_id: "id = y * G + x; x and y are global cell coordinates, 0-based",460+ tile_shape:461+ "tile {id, x0, y0, w, h, rects} is the union of rects [lx, ly, rw, rh] in local coordinates inside the w x h box at (x0, y0); cells is its cell count; tiles overlap neighbours by rectangles of shared cells",462+ neighbours:463+ "tile ids sharing at least one cell; a solved neighbour fixes the shared cells and puts this tile on the frontier",464+ known_deltas:465+ "cumulative sum recovers the sorted ids of every known cell; known_values[i] is the value of the i-th id",466+ committed_deltas: "same coding; the subset you committed (retractable)",467+ holes: "cells no tile covers; never playable",468+ },469+ };470+ }471+}472+473+function clip(s: string): string {474+ return (s ?? "").slice(0, 160);475+}476+477+function summarise(ignored: { id: number; reason: string }[]): string {478+ const counts: Record<string, number> = {};479+ for (const i of ignored) counts[i.reason] = (counts[i.reason] ?? 0) + 1;480+ return Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ") ||481+ "none";482+}@@ -0,0 +1,24 @@
1+import { define } from "../utils.ts";2+3+export default define.page(function App({ Component }) {4+ return (5+ <html lang="en">6+ <head>7+ <meta charset="utf-8" />8+ <meta name="viewport" content="width=device-width, initial-scale=1" />9+ <title>mosaic</title>10+ <style>11+ {`:root{color-scheme:dark light;--bg:#141416;--chrome:#161618;--rule:#2a2a2e;--text:#d8d8dc;--text-dim:#b9b9c0;--text-strong:#e8e8ea;--muted:#8c8c92}12+@media (prefers-color-scheme: light){:root{--bg:#f2f2f4;--chrome:#ebebee;--rule:#d4d4d9;--text:#2a2a2e;--text-dim:#4a4a52;--text-strong:#141416;--muted:#6e6e76}}13+:root[data-theme=dark]{--bg:#141416;--chrome:#161618;--rule:#2a2a2e;--text:#d8d8dc;--text-dim:#b9b9c0;--text-strong:#e8e8ea;--muted:#8c8c92}14+:root[data-theme=light]{--bg:#f2f2f4;--chrome:#ebebee;--rule:#d4d4d9;--text:#2a2a2e;--text-dim:#4a4a52;--text-strong:#141416;--muted:#6e6e76}15+html,body{margin:0;height:100%;background:var(--bg);color:var(--text-strong);font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}16+*{box-sizing:border-box}`}17+ </style>18+ </head>19+ <body>20+ <Component />21+ </body>22+ </html>23+ );24+});@@ -0,0 +1,82 @@
1+// SSE: the live fold. Replays every event so far, then streams new ones.2+// id "live" follows the newest game and announces new games with a `game`3+// message; the client resets when the game id changes.4+import { define } from "../../utils.ts";5+import { getGame, latestGameId, onNewGame } from "../../lib/games.ts";6+import type { GameEvent } from "../../lib/fold.ts";7+import type { Referee } from "../../lib/referee.ts";8+9+type Send = (event: string, data: unknown) => void;10+11+function sse(fn: (send: Send) => () => void): Response {12+ const enc = new TextEncoder();13+ let cleanup = () => {};14+ const stream = new ReadableStream<Uint8Array>({15+ start(controller) {16+ let closed = false;17+ const send: Send = (event, data) => {18+ if (closed) return;19+ try {20+ controller.enqueue(21+ enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),22+ );23+ } catch {24+ closed = true;25+ }26+ };27+ const off = fn(send);28+ const ping = setInterval(() => send("ping", Date.now()), 15000);29+ cleanup = () => {30+ closed = true;31+ clearInterval(ping);32+ off();33+ };34+ },35+ cancel() {36+ cleanup();37+ },38+ });39+ return new Response(stream, {40+ headers: {41+ "Content-Type": "text/event-stream",42+ "Cache-Control": "no-store",43+ "Connection": "keep-alive",44+ "X-Accel-Buffering": "no",45+ },46+ });47+}48+49+function follow(ref: Referee, send: Send): () => void {50+ send("game", { game_id: ref.gameId });51+ for (const ev of ref.log.events) send("event", ev);52+ return ref.log.subscribe((ev: GameEvent) => send("event", ev));53+}54+55+export const handler = define.handlers({56+ GET(ctx) {57+ const id = ctx.params.id;58+ if (id === "live") {59+ return sse((send) => {60+ let unfollow = () => {};61+ const latest = latestGameId();62+ const ref = latest ? getGame(latest) : undefined;63+ if (ref) unfollow = follow(ref, send);64+ else send("waiting", { message: "no game yet" });65+ const off = onNewGame((gid) => {66+ unfollow();67+ const next = getGame(gid);68+ if (next) unfollow = follow(next, send);69+ });70+ return () => {71+ unfollow();72+ off();73+ };74+ });75+ }76+ const ref = getGame(id);77+ if (!ref) {78+ return Response.json({ error: "Game not found." }, { status: 404 });79+ }80+ return sse((send) => follow(ref, send));81+ },82+});@@ -0,0 +1,9 @@
1+import { define } from "../utils.ts";2+import Viewer from "../islands/Viewer.tsx";3+4+export default define.page(function Home(ctx) {5+ const replay = ctx.url.searchParams.get("replay");6+ const t = ctx.url.searchParams.get("theme");7+ const theme = t === "dark" || t === "light" ? t : null;8+ return <Viewer replay={replay} theme={theme} />;9+});@@ -0,0 +1,25 @@
1+// JSONL download for replay: the same events the fold consumes, from disk.2+import { define } from "../../utils.ts";3+import { getGame } from "../../lib/games.ts";4+import { readLog } from "../../lib/log.ts";5+6+export const handler = define.handlers({7+ async GET(ctx) {8+ const id = ctx.params.id;9+ if (!/^[a-zA-Z0-9_-]{1,80}$/.test(id)) {10+ return Response.json({ error: "bad id" }, { status: 400 });11+ }12+ const live = getGame(id);13+ const text = live ? live.log.text() : await readLog(id);14+ if (text === null) {15+ return Response.json({ error: "Game not found." }, { status: 404 });16+ }17+ return new Response(text, {18+ headers: {19+ "Content-Type": "application/x-ndjson; charset=utf-8",20+ "Cache-Control": "no-store",21+ "Content-Disposition": `inline; filename="${id}.jsonl"`,22+ },23+ });24+ },25+});@@ -0,0 +1,32 @@
1+// Streamable HTTP MCP, authless, localhost only.2+import { createMcpHandler } from "@modelcontextprotocol/server";3+import { define } from "../utils.ts";4+import { createServer } from "../lib/mcp.ts";5+6+function isLocal(req: Request): boolean {7+ const host = req.headers.get("Host")?.split(":")[0];8+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";9+}10+11+const mcp = createMcpHandler(() => createServer(), {12+ legacy: "stateless",13+ responseMode: "json",14+ onerror(error: Error) {15+ console.error(16+ JSON.stringify({ message: "MCP request failed", error: error.message }),17+ );18+ },19+});20+21+function serve(req: Request): Promise<Response> | Response {22+ if (!isLocal(req)) {23+ return Response.json({ error: "Invalid Host header." }, { status: 403 });24+ }25+ return mcp.fetch(req);26+}27+28+export const handler = define.handlers({29+ GET: (ctx) => serve(ctx.req),30+ POST: (ctx) => serve(ctx.req),31+ DELETE: (ctx) => serve(ctx.req),32+});@@ -0,0 +1,333 @@
1+// deno-lint-ignore-file no-explicit-any2+// Scripted MCP client: plays a whole mosaic through the public tools only.3+// Reveal a frontier tile, enumerate the tile window's solutions from known4+// cells with the three rules, commit what every solution agrees on, then5+// guess one cell of the first solution and learn from the verdict.6+//7+// deno run -A scripts/solve.ts [--url http://127.0.0.1:8000/mcp] [--G 100] [--kind scatter|grid|big-first|bands] [--n 14] [--seed s] [--limit 6]8+9+const args = Object.fromEntries(10+ Deno.args.map((a, i, all) =>11+ a.startsWith("--") ? [a.slice(2), all[i + 1] ?? ""] : []12+ ).filter((p) => p.length),13+);14+const URL_ = args.url ?? "http://127.0.0.1:8000/mcp";15+const G_ARG = Number(args.G ?? 100);16+const KIND = args.kind ?? "scatter";17+const N_ARG = Number(args.n ?? 14);18+const LIMIT = Number(args.limit ?? 6);19+20+let nextId = 1;21+async function rpc(method: string, params: unknown): Promise<any> {22+ const res = await fetch(URL_, {23+ method: "POST",24+ headers: {25+ "Content-Type": "application/json",26+ "Accept": "application/json, text/event-stream",27+ "MCP-Protocol-Version": "2025-06-18",28+ },29+ body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),30+ });31+ const text = await res.text();32+ if (!res.ok) {33+ throw new Error(`${method}: HTTP ${res.status} ${text.slice(0, 300)}`);34+ }35+ const msg = JSON.parse(unframe(text));36+ if (msg.error) throw new Error(`${method}: ${JSON.stringify(msg.error)}`);37+ return msg.result;38+}39+40+/** The server may answer as JSON or as one SSE frame; take the last data line. */41+function unframe(text: string): string {42+ // SSE: comment lines (": keepalive", sent while a long tool call such as43+ // a generation on cache miss runs), event: and data: lines44+ const t = text.trimStart();45+ if (!t.startsWith("event:") && !t.startsWith("data:") && !t.startsWith(":")) {46+ return text;47+ }48+ const data = text.split("\n").filter((l) => l.startsWith("data:")).map((l) =>49+ l.slice(5).trim()50+ );51+ return data.at(-1) ?? "";52+}53+54+async function call(name: string, a: Record<string, unknown>): Promise<any> {55+ const r = await rpc("tools/call", { name, arguments: a });56+ if (r.isError) throw new Error(`${name}: ${r.content?.[0]?.text}`);57+ return r.structuredContent ?? r;58+}59+60+// ---------------------------------------------------------------- solver61+//62+// A tile is a shape: the union of rects inside a w x h box. The rules apply63+// per line (a maximal horizontal or vertical run of the tile's cells):64+// balance, no three in a row, and no two equal lines of the same65+// orientation and length. Cells outside the shape are absent (-2).66+67+type Grid = Int8Array; // -1 empty, -2 absent, 0/1 known68+type Shape = {69+ x0: number;70+ y0: number;71+ w: number;72+ h: number;73+ rects: [number, number, number, number][];74+};75+76+type Lines = {77+ lines: number[][]; // local indices along each line78+ rowLine: Int32Array; // local index -> line id (or -1)79+ colLine: Int32Array;80+ pos: Int32Array; // position of the cell in its row line81+ cpos: Int32Array; // position in its column line82+ horizontal: boolean[];83+};84+85+function maskOf(t: Shape): Uint8Array {86+ const mask = new Uint8Array(t.w * t.h);87+ for (const [lx, ly, rw, rh] of t.rects) {88+ for (let y = ly; y < ly + rh; y++) {89+ for (let x = lx; x < lx + rw; x++) mask[y * t.w + x] = 1;90+ }91+ }92+ return mask;93+}94+95+function linesOf(t: Shape, mask: Uint8Array): Lines {96+ const lines: number[][] = [];97+ const horizontal: boolean[] = [];98+ const rowLine = new Int32Array(t.w * t.h).fill(-1);99+ const colLine = new Int32Array(t.w * t.h).fill(-1);100+ const pos = new Int32Array(t.w * t.h);101+ const cpos = new Int32Array(t.w * t.h);102+ for (let y = 0; y < t.h; y++) {103+ let run: number[] = [];104+ for (let x = 0; x <= t.w; x++) {105+ const i = y * t.w + x;106+ if (x < t.w && mask[i]) run.push(i);107+ else if (run.length) {108+ run.forEach((c, k) => {109+ rowLine[c] = lines.length;110+ pos[c] = k;111+ });112+ lines.push(run);113+ horizontal.push(true);114+ run = [];115+ }116+ }117+ }118+ for (let x = 0; x < t.w; x++) {119+ let run: number[] = [];120+ for (let y = 0; y <= t.h; y++) {121+ const i = y * t.w + x;122+ if (y < t.h && mask[i]) run.push(i);123+ else if (run.length) {124+ run.forEach((c, k) => {125+ colLine[c] = lines.length;126+ cpos[c] = k;127+ });128+ lines.push(run);129+ horizontal.push(false);130+ run = [];131+ }132+ }133+ }134+ return { lines, rowLine, colLine, pos, cpos, horizontal };135+}136+137+function enumerate(cells: Grid, t: Shape, limit: number): Grid[] {138+ const out: Grid[] = [];139+ const g = Int8Array.from(cells);140+ const L = linesOf(t, maskOf(t));141+ const lineOk = (li: number, at: number, v: number): boolean => {142+ const line = L.lines[li];143+ const n = line.length;144+ const p = L.horizontal[li] ? L.pos[at] : L.cpos[at];145+ const val = (k: number) => (line[k] === at ? v : g[line[k]]);146+ // run147+ if (p >= 2 && val(p - 1) === v && val(p - 2) === v) return false;148+ if (p >= 1 && p < n - 1 && val(p - 1) === v && val(p + 1) === v) {149+ return false;150+ }151+ if (p <= n - 3 && val(p + 1) === v && val(p + 2) === v) return false;152+ // balance153+ let same = 0, filled = 0;154+ for (let k = 0; k < n; k++) {155+ const c = val(k);156+ if (c >= 0) {157+ filled++;158+ if (c === v) same++;159+ }160+ }161+ if (same > n / 2) return false;162+ // uniqueness against complete lines of the same orientation and length163+ if (filled === n) {164+ for (let lj = 0; lj < L.lines.length; lj++) {165+ if (lj === li || L.horizontal[lj] !== L.horizontal[li]) continue;166+ const other = L.lines[lj];167+ if (other.length !== n) continue;168+ let eq = true;169+ for (let k = 0; k < n && eq; k++) {170+ const o = g[other[k]];171+ if (o < 0 || o !== val(k)) eq = false;172+ }173+ if (eq) return false;174+ }175+ }176+ return true;177+ };178+ const canPlace = (i: number, v: number): boolean =>179+ lineOk(L.rowLine[i], i, v) && lineOk(L.colLine[i], i, v);180+ const rec = (): boolean => {181+ if (out.length >= limit) return true;182+ // MRV: the empty cell with the fewest admissible values183+ let bi = -1, bc = 3, bv: number[] = [];184+ for (let i = 0; i < g.length; i++) {185+ if (g[i] !== -1) continue;186+ const vals = [0, 1].filter((v) => canPlace(i, v));187+ if (vals.length === 0) return false;188+ if (vals.length < bc) {189+ bi = i;190+ bc = vals.length;191+ bv = vals;192+ if (bc === 1) break;193+ }194+ }195+ if (bi < 0) {196+ out.push(Int8Array.from(g));197+ return out.length >= limit;198+ }199+ for (const v of bv) {200+ g[bi] = v;201+ if (rec()) return true;202+ g[bi] = -1;203+ }204+ return false;205+ };206+ rec();207+ return out;208+}209+210+// ------------------------------------------------------------------ play211+212+const init = await rpc("initialize", {213+ protocolVersion: "2025-06-18",214+ capabilities: {},215+ clientInfo: { name: "mosaic-solve", version: "0.2.0" },216+});217+console.log("server", init.serverInfo?.name, init.protocolVersion);218+219+const started = await call("start_mosaic", {220+ G: G_ARG,221+ kind: KIND,222+ n: N_ARG,223+ ...(args.seed ? { seed: args.seed } : {}),224+});225+const gameId: string = started.game_id;226+console.log("game", gameId, "seed tile", started.seed_tile);227+228+const t0 = Date.now();229+let obs = (await call("observe", { game_id: gameId })).observation;230+const G: number = obs.G;231+232+function knownMap(o: any): Map<number, number> {233+ const m = new Map<number, number>();234+ let acc = 0;235+ o.known_deltas.forEach((d: number, i: number) => {236+ acc += d;237+ m.set(acc, Number(o.known_values[i]));238+ });239+ return m;240+}241+242+/** Global id per local index of the tile's box; -1 where the shape is absent. */243+const tileIds = (t: Shape): number[] => {244+ const mask = maskOf(t);245+ const ids: number[] = [];246+ for (let y = 0; y < t.h; y++) {247+ for (let x = 0; x < t.w; x++) {248+ ids.push(mask[y * t.w + x] ? (t.y0 + y) * G + t.x0 + x : -1);249+ }250+ }251+ return ids;252+};253+254+let batches = 0, guesses = 0;255+const wrongKnown = new Map<number, number>(); // id -> value we learned by rejection256+while (obs.status !== "solved") {257+ const frontier: number[] = obs.frontier;258+ // prefer a revealed-but-unsolved tile (finish what we started), else reveal one259+ const revealed = obs.tiles.filter((t: any) => t.status === "revealed");260+ let tile: any;261+ if (revealed.length) {262+ tile = revealed[0];263+ } else {264+ const id = frontier[0];265+ await call("reveal", {266+ game_id: gameId,267+ expected_revision: obs.revision,268+ tile: id,269+ intent: `reveal #${id} from the frontier`,270+ });271+ obs = (await call("observe", { game_id: gameId })).observation;272+ continue;273+ }274+ const shape: Shape = tile;275+ const label = `${tile.cells} cells, ${shape.w}x${shape.h}`;276+ const known = knownMap(obs);277+ const ids = tileIds(shape);278+ const cells = new Int8Array(shape.w * shape.h).fill(-1);279+ ids.forEach((id, i) => {280+ if (id < 0) cells[i] = -2;281+ else if (known.has(id)) cells[i] = known.get(id)!;282+ else if (wrongKnown.has(id)) cells[i] = wrongKnown.get(id)!;283+ });284+ const sols = enumerate(cells, shape, LIMIT);285+ if (sols.length === 0) {286+ throw new Error(`tile #${tile.id} has no solution from known cells`);287+ }288+ const common: [number, number][] = [];289+ const disputed: number[] = [];290+ ids.forEach((id, i) => {291+ if (id < 0 || known.has(id)) return;292+ const v = sols[0][i];293+ if (sols.every((s) => s[i] === v)) common.push([id, v]);294+ else disputed.push(i);295+ });296+ let batch: [number, number][];297+ let intent: string;298+ if (common.length) {299+ batch = common;300+ intent =301+ `tile #${tile.id} (${label}): ${sols.length} solution(s) from known cells, committing the ${common.length} cells they agree on`;302+ } else {303+ const i = disputed[0];304+ batch = [[ids[i], sols[0][i]]];305+ guesses++;306+ intent =307+ `tile #${tile.id} (${label}): ${sols.length} solutions disagree on ${disputed.length} cells, guessing one`;308+ }309+ const r = await call("commit", {310+ game_id: gameId,311+ expected_revision: obs.revision,312+ cells: batch,313+ intent,314+ });315+ batches++;316+ for (const rej of r.rejected) {317+ const v = batch.find(([id]) => id === rej.id)![1];318+ wrongKnown.set(rej.id, 1 - v);319+ }320+ const forced = r.accepted.filter((c: any) => c.class === "forced").length;321+ console.log(322+ `#${r.revision} tile ${tile.id} (${label}) ${r.accepted.length} ok (${forced} forced), ${r.rejected.length} rejected, solved ${323+ JSON.stringify(r.solved)324+ } +${r.reward}, status ${r.status}`,325+ );326+ obs = (await call("observe", { game_id: gameId })).observation;327+}328+console.log(329+ `solved ${gameId} in ${batches} batches, ${guesses} guesses, ${330+ ((Date.now() - t0) / 1000).toFixed(1)331+ }s; totals ${JSON.stringify(obs.totals)}`,332+);333+console.log(`replay: http://127.0.0.1:8000/?replay=${gameId}`);@@ -0,0 +1,268 @@
1+// Test fixtures for manifest v3: synthetic layouts (a grid of squares, or a2+// scattered field of shapes with rectangular overlaps and holes) wrapped in3+// a synthetic generator output (random solution, random givens). Enough for4+// fold and referee tests, which never check Takuzu validity; the judge is5+// stubbed. No engine binary is involved.6+import type { GenOutput, JudgeInput, JudgeOutput } from "../lib/engine.ts";7+import {8+ buildGeometry,9+ cellId,10+ type LocalRect,11+ type Manifest,12+ overlapsOf,13+ tileMask,14+ type TileSpec,15+} from "../lib/fold.ts";16+17+export type Layout = {18+ G: number;19+ kind: string;20+ seed_tile: number;21+ tiles: Omit<TileSpec, "overlaps" | "hash">[];22+};23+24+/** Small deterministic PRNG so fixtures are stable per seed. */25+export function rng(seed: string): () => number {26+ let h = 2166136261;27+ for (const ch of seed) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);28+ return () => {29+ h = Math.imul(h ^ (h >>> 15), 2246822507);30+ h = Math.imul(h ^ (h >>> 13), 3266489909);31+ h ^= h >>> 16;32+ return (h >>> 0) / 4294967296;33+ };34+}35+36+export function mFor(cells: number): number {37+ return cells <= 64 ? 2 : cells <= 196 ? 3 : 4;38+}39+40+function countCells(w: number, h: number, rects: LocalRect[]): number {41+ return tileMask({ x0: 0, y0: 0, w, h, rects } as TileSpec).reduce(42+ (a, b) => a + b,43+ 0,44+ );45+}46+47+/** X by X squares of side n on a pitch of n-1 (the phase-1 grid). */48+export function gridLayout(X: number, n: number): Layout {49+ const G = X * (n - 1) + 1;50+ const tiles: Layout["tiles"] = [];51+ for (let ty = 0; ty < X; ty++) {52+ for (let tx = 0; tx < X; tx++) {53+ tiles.push({54+ id: tiles.length,55+ x0: tx * (n - 1),56+ y0: ty * (n - 1),57+ w: n,58+ h: n,59+ rects: [[0, 0, n, n]],60+ cells: n * n,61+ m: mFor(n * n),62+ });63+ }64+ }65+ return { G, kind: "grid", seed_tile: Math.floor(tiles.length / 2), tiles };66+}67+68+/**69+ * Shapes of random even sides 6..16: a rectangle, or an L / T / plus made70+ * of two even rectangles at even offsets so every line stays even.71+ */72+function randomShape(73+ r: () => number,74+): { w: number; h: number; rects: LocalRect[] } {75+ const even = (lo: number, hi: number) =>76+ lo + 2 * Math.floor(r() * ((hi - lo) / 2 + 1));77+ const w = even(6, 16), h = even(6, 16);78+ const roll = r();79+ if (roll < 0.55) return { w, h, rects: [[0, 0, w, h]] };80+ // second rectangle attached to the first81+ const w2 = even(4, Math.max(4, w)), h2 = even(4, 12);82+ const ox = even(0, w - w2);83+ if (roll < 0.8) {84+ // T or L: below the first, at an even offset85+ return {86+ w: Math.max(w, ox + w2),87+ h: h + h2,88+ rects: [[0, 0, w, h], [ox, h, w2, h2]],89+ };90+ }91+ // plus / notched: second rect straddling the first vertically92+ const oy = even(0, Math.max(0, h - 2));93+ const W = Math.max(w, ox + w2), H = Math.max(h, oy + h2);94+ return { w: W, h: H, rects: [[0, 0, w, h], [ox, oy, w2, h2]] };95+}96+97+/**98+ * A scattered field: shapes dropped at random positions, accepted when they99+ * overlap the growing cluster by 4..25% of the smaller tile's cells (and by100+ * at least 4 cells), are contained in no tile and contain none. Holes are101+ * whatever stays uncovered. Deterministic per seed.102+ */103+export function scatterLayout(seed: string, G: number, target = 0.7): Layout {104+ const r = rng(seed);105+ const owners = new Uint8Array(G * G);106+ const tiles: Layout["tiles"] = [];107+ let covered = 0;108+ const cellsOf = (109+ t: { x0: number; y0: number; w: number; h: number; rects: LocalRect[] },110+ ) => {111+ const mask = tileMask(t as TileSpec);112+ const ids: number[] = [];113+ for (let y = 0; y < t.h; y++) {114+ for (let x = 0; x < t.w; x++) {115+ if (mask[y * t.w + x]) ids.push(cellId(t.x0 + x, t.y0 + y, G));116+ }117+ }118+ return ids;119+ };120+ const place = (t: Layout["tiles"][number]) => {121+ tiles.push(t);122+ for (const id of cellsOf(t)) {123+ if (owners[id] === 0) covered++;124+ owners[id]++;125+ }126+ };127+ // seed tile near the middle128+ {129+ const s = randomShape(r);130+ place({131+ id: 0,132+ x0: Math.floor(G / 2 - s.w / 2),133+ y0: Math.floor(G / 2 - s.h / 2),134+ ...s,135+ cells: countCells(s.w, s.h, s.rects),136+ m: mFor(countCells(s.w, s.h, s.rects)),137+ });138+ }139+ let misses = 0;140+ while (covered < target * G * G && misses < 4000) {141+ const s = randomShape(r);142+ if (s.w > G || s.h > G) {143+ misses++;144+ continue;145+ }146+ // drop it near a random tile of the cluster so it can touch something147+ const anchor = tiles[Math.floor(r() * tiles.length)];148+ const jx = Math.floor(r() * (anchor.w + s.w - 2)) - (s.w - 1);149+ const jy = Math.floor(r() * (anchor.h + s.h - 2)) - (s.h - 1);150+ const t = {151+ id: tiles.length,152+ x0: Math.max(0, Math.min(G - s.w, anchor.x0 + jx)),153+ y0: Math.max(0, Math.min(G - s.h, anchor.y0 + jy)),154+ ...s,155+ cells: countCells(s.w, s.h, s.rects),156+ m: 0,157+ };158+ t.m = mFor(t.cells);159+ const ids = cellsOf(t);160+ let shared = 0;161+ for (const id of ids) if (owners[id] > 0) shared++;162+ if (shared < 4 || shared > 0.25 * t.cells) {163+ misses++;164+ continue;165+ }166+ // per neighbour: not contained either way, overlap under a quarter167+ let ok = true;168+ const byTile = new Map<number, number>();169+ const near = tiles.filter((o) =>170+ o.x0 < t.x0 + t.w && o.x0 + o.w > t.x0 && o.y0 < t.y0 + t.h &&171+ o.y0 + o.h > t.y0172+ );173+ const masks = new Map(near.map((o) => [o.id, tileMask(o as TileSpec)]));174+ for (const id of ids) {175+ if (owners[id] === 0) continue;176+ const gx = id % G, gy = Math.floor(id / G);177+ for (const o of near) {178+ const lx = gx - o.x0, ly = gy - o.y0;179+ if (lx < 0 || ly < 0 || lx >= o.w || ly >= o.h) continue;180+ if (masks.get(o.id)![ly * o.w + lx]) {181+ byTile.set(o.id, (byTile.get(o.id) ?? 0) + 1);182+ }183+ }184+ }185+ for (const [oid, n] of byTile) {186+ const o = tiles[oid];187+ if (188+ n === o.cells || n === t.cells || n > 0.25 * Math.min(o.cells, t.cells)189+ ) {190+ ok = false;191+ }192+ }193+ if (!ok) {194+ misses++;195+ continue;196+ }197+ place(t);198+ misses = 0;199+ }200+ return { G, kind: "scatter", seed_tile: 0, tiles };201+}202+203+/** Wrap a layout as generator output: random solution, ~30% givens per tile. */204+export function synthesise(205+ L: Layout,206+ seed = "fixture",207+): GenOutput & { manifest: Manifest } {208+ const r = rng(seed);209+ const G = L.G;210+ const tiles: TileSpec[] = L.tiles.map((t) => ({211+ ...t,212+ hash: `h${t.id}`,213+ overlaps: [],214+ }));215+ const manifest: Manifest = {216+ version: 3,217+ id: `fixture-${L.kind}-${G}`,218+ seed_commit: `fixture-${L.kind}-${G}`,219+ G,220+ packer: { kind: L.kind },221+ rules: { overlaps: true, cross_tile: false },222+ seed_tile: L.seed_tile,223+ tiles,224+ k: "unmeasured",225+ generator: "fixture",226+ library: null,227+ };228+ const geo = buildGeometry(manifest);229+ for (const t of tiles) t.overlaps = overlapsOf(geo, t.id);230+ const solution: string[] = [];231+ for (let y = 0; y < G; y++) {232+ let row = "";233+ for (let x = 0; x < G; x++) {234+ const id = cellId(x, y, G);235+ row += geo.ownerStart[id] === geo.ownerStart[id + 1]236+ ? "."237+ : (r() < 0.5 ? "0" : "1");238+ }239+ solution.push(row);240+ }241+ // givens: 30% of each tile's cells that no other tile shares242+ const genTiles = tiles.map((t) => {243+ const givens: [number, 0 | 1][] = [];244+ for (const id of geo.cellsOf[t.id]) {245+ if (geo.ownerStart[id + 1] - geo.ownerStart[id] > 1) continue;246+ if (r() < 0.3) {247+ givens.push([id, solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0]);248+ }249+ }250+ return { id: t.id, givens };251+ });252+ return { manifest, solution, tiles: genTiles };253+}254+255+/** Deterministic stand-in for the engine judge: even ids forced, odd guess. */256+export function stubJudge(input: JudgeInput): Promise<JudgeOutput> {257+ const known = new Set(input.known.map(([id]) => id));258+ return Promise.resolve({259+ ms: 0,260+ steps: 0,261+ results: input.candidates.map(([id, v]) => ({262+ id,263+ v,264+ class: known.has(id) ? "known" : id % 2 === 0 ? "forced" : "guess",265+ rules: known.has(id) ? ["seam"] : id % 2 === 0 ? ["balance"] : [],266+ })),267+ });268+}@@ -0,0 +1,237 @@
1+// Geometry and colour over manifest v3 layouts: owner index from rect2+// unions, overlap and junction cells, holes, hue colouring of the adjacency3+// graph, v2 conversion, delta coding, and the owner index build time at4+// G=1000.5+import { assert, assertEquals } from "@std/assert";6+import {7+ buildGeometry,8+ cellId,9+ cellXY,10+ colourTiles,11+ deltaDecode,12+ deltaEncode,13+ fold,14+ hueOf,15+ HUES,16+ isHole,17+ type ManifestV2,18+ neighboursOf,19+ normaliseManifest,20+ ownerCount,21+ ownersOf,22+ tileContains,23+ zoneCells,24+} from "../lib/fold.ts";25+import { cellFill, highestStatus, meanHueLab } from "../lib/colour.ts";26+import { gridLayout, scatterLayout, synthesise } from "./fixture.ts";27+28+for (29+ const [name, L] of [30+ ["grid 27", gridLayout(2, 14)],31+ ["scatter 100", scatterLayout("a", 100)],32+ ["scatter 60", scatterLayout("b", 60)],33+ ] as const34+) {35+ Deno.test(`geometry ${name}`, () => {36+ const out = synthesise(L);37+ const m = out.manifest;38+ const G = m.G;39+ const geo = buildGeometry(m);40+ let overlaps = 0, junctions = 0, holes = 0;41+ for (let y = 0; y < G; y++) {42+ for (let x = 0; x < G; x++) {43+ const id = cellId(x, y, G);44+ const owners = ownersOf(geo, id);45+ const expect = m.tiles.filter((t) => tileContains(t, x, y)).map((t) =>46+ t.id47+ );48+ assertEquals(owners, expect, `owners of ${x},${y}`);49+ assertEquals(isHole(geo, id), owners.length === 0);50+ assertEquals(cellXY(id, G), [x, y]);51+ if (owners.length === 0) holes++;52+ if (owners.length === 2) overlaps++;53+ if (owners.length >= 3) junctions++;54+ // tiles sharing a cell never share a hue55+ const hues = hueOf(geo, id);56+ assertEquals(new Set(hues).size, hues.length, `hue clash at ${x},${y}`);57+ for (const h of hues) assert(h >= 0 && h < HUES);58+ }59+ }60+ assertEquals(geo.holes, holes);61+ assert(overlaps > 0, "there are shared cells");62+ if (L.kind === "scatter") {63+ assert(holes > 0, "a scattered field has holes");64+ assert(junctions > 0, "some cell has three owners");65+ assert(66+ m.tiles.some((t) => t.rects.length > 1),67+ "some tile is not a rectangle",68+ );69+ }70+ // the solution marks holes and nothing else with '.'71+ for (let y = 0; y < G; y++) {72+ for (let x = 0; x < G; x++) {73+ assertEquals(out.solution[y][x] === ".", isHole(geo, cellId(x, y, G)));74+ }75+ }76+ // overlaps: partner ids and counts agree with the owner index, and the77+ // adjacency is symmetric78+ for (const t of m.tiles) {79+ assertEquals(neighboursOf(t), geo.adjacency[t.id]);80+ for (const o of t.overlaps) {81+ let n = 0;82+ for (const id of geo.cellsOf[t.id]) {83+ if (ownersOf(geo, id).includes(o.with)) n++;84+ }85+ assertEquals(o.cells, n);86+ assert(geo.adjacency[o.with].includes(t.id));87+ }88+ assertEquals(geo.cellsOf[t.id].length, t.cells);89+ }90+ // the fold starts with nothing known and no tile solved91+ const s = fold([{92+ rev: 0,93+ t: "2026-01-01T00:00:00.000Z",94+ kind: "manifest",95+ game_id: "g",96+ manifest: m,97+ rules: { guess: "accept", depth: 0 },98+ }]);99+ assertEquals(Object.keys(s.cells).length, 0);100+ assert(s.tiles.every((t) => t === "hidden"));101+ assertEquals(s.totals.reward, 0);102+ });103+}104+105+Deno.test("v2 manifest converts to v3 shapes with the same geometry", () => {106+ // a 2x2 of 8x8 with one filler zone, in the v2 shape the old logs carry107+ const v2: ManifestV2 = {108+ version: 2,109+ id: "x",110+ seed_commit: "x",111+ G: 20,112+ packer: { kind: "grid" },113+ m_by_n: { "8": 2 },114+ rules: {},115+ seed_tile: 0,116+ tiles: [117+ {118+ id: 0,119+ x0: 0,120+ y0: 0,121+ n: 8,122+ m: 2,123+ seams: [{ side: "right", from: 0, len: 8, with: 1 }, {124+ side: "bottom",125+ from: 0,126+ len: 8,127+ with: 2,128+ }],129+ },130+ {131+ id: 1,132+ x0: 7,133+ y0: 0,134+ n: 8,135+ m: 2,136+ seams: [{ side: "left", from: 0, len: 8, with: 0 }],137+ },138+ {139+ id: 2,140+ x0: 0,141+ y0: 7,142+ n: 8,143+ m: 2,144+ seams: [{ side: "top", from: 0, len: 8, with: 0 }],145+ },146+ ],147+ zones: [{ id: 0, rects: [[15, 15, 2, 2]], values: "0110" }],148+ k: "unmeasured",149+ generator: "test",150+ library: null,151+ };152+ const m = normaliseManifest(v2);153+ assertEquals(m.version, 3);154+ assertEquals(m.source_version, 2);155+ assertEquals(m.tiles[0].rects, [[0, 0, 8, 8]]);156+ assertEquals(m.tiles[0].cells, 64);157+ assertEquals(m.tiles[0].overlaps, [{ with: 1, cells: 8 }, {158+ with: 2,159+ cells: 8,160+ }]);161+ assertEquals(m.tiles[1].overlaps, [{ with: 0, cells: 8 }, {162+ with: 2,163+ cells: 1,164+ }]);165+ assertEquals(zoneCells(m).length, 4);166+ const geo = buildGeometry(m);167+ assertEquals(ownersOf(geo, cellId(7, 7, 20)), [0, 1, 2]);168+ assert(isHole(geo, cellId(15, 15, 20)));169+ const s = fold([{170+ rev: 0,171+ t: "2026-01-01T00:00:00.000Z",172+ kind: "manifest",173+ game_id: "g",174+ manifest: v2,175+ rules: { guess: "accept", depth: 0 },176+ }]);177+ assertEquals(Object.keys(s.cells).length, 4, "zone cells known from rev 0");178+});179+180+Deno.test("owner index at G=1000 builds fast", () => {181+ const m = synthesise(scatterLayout("big", 1000, 0.6)).manifest;182+ const t0 = performance.now();183+ const geo = buildGeometry(m);184+ const ms = performance.now() - t0;185+ assert(ms < 1500, `geometry took ${ms.toFixed(0)}ms`);186+ assert(m.tiles.length > 1000, `${m.tiles.length} tiles`);187+ let maxOwners = 0;188+ for (let id = 0; id < m.G * m.G; id++) {189+ maxOwners = Math.max(maxOwners, ownerCount(geo, id));190+ }191+ assert(maxOwners >= 2, `max owners ${maxOwners}`);192+ for (const t of m.tiles) {193+ for (const u of geo.adjacency[t.id]) {194+ assert(geo.hues[t.id] !== geo.hues[u], `hue clash ${t.id} ${u}`);195+ }196+ }197+});198+199+Deno.test("colouring is deterministic and collision-free on a dense graph", () => {200+ // a wheel: hub 0 adjacent to everyone, rim a cycle201+ const n = 12;202+ const adj: number[][] = Array.from({ length: n }, () => []);203+ for (let i = 1; i < n; i++) {204+ adj[0].push(i);205+ adj[i].push(0);206+ const j = i === n - 1 ? 1 : i + 1;207+ adj[i].push(j);208+ adj[j].push(i);209+ }210+ const a = colourTiles(adj), b = colourTiles(adj);211+ assertEquals(a, b);212+ for (let i = 0; i < n; i++) {213+ for (const j of adj[i]) assert(a[i] !== a[j]);214+ }215+});216+217+Deno.test("colour: fixed lightness, no pyramid at seams", () => {218+ const single = meanHueLab([0]);219+ const seam = meanHueLab([0, 1]);220+ const corner = meanHueLab([0, 1, 3, 4]);221+ assertEquals(single[0], seam[0]);222+ assertEquals(single[0], corner[0]);223+ const chroma = (lab: number[]) => Math.hypot(lab[1], lab[2]);224+ assert(Math.abs(chroma(single) - chroma(seam)) < 1e-9);225+ assert(Math.abs(chroma(single) - chroma(corner)) < 1e-9);226+ assert(/^#[0-9a-f]{6}$/.test(cellFill([0, 1], "solved")));227+ assert(/^#[0-9a-f]{6}$/.test(cellFill([], "solved")));228+ // an unknown cell is never lighter than a known one on the dark theme229+ assert(cellFill([2], "revealed", false) !== cellFill([2], "revealed", true));230+ assertEquals(highestStatus(["hidden", "solved", "revealed"]), "solved");231+ assertEquals(highestStatus(["hidden", "revealed"]), "revealed");232+});233+234+Deno.test("delta coding round trips", () => {235+ const ids = [5, 1, 900, 901, 42];236+ assertEquals(deltaDecode(deltaEncode(ids)), [1, 5, 42, 900, 901]);237+});@@ -0,0 +1,282 @@
1+// PLAN item 5 on manifest v3: replaying the JSONL reproduces the live run2+// frame for frame. A scripted player (with access to the private solution)3+// plays a scattered field through the referee; the live fold (incremental4+// applyEvent over the event stream) must equal the replay fold (from the5+// JSONL on disk) at every revision, and gating must refuse what the plan6+// says it refuses. The judge is a deterministic stub.7+import { assert, assertEquals, assertRejects } from "@std/assert";8+import {9+ applyEvent,10+ emptyState,11+ foldAll,12+ frontierOf,13+ isHole,14+ neighboursOf,15+ parseLog,16+ type State,17+} from "../lib/fold.ts";18+import type { GenOutput } from "../lib/engine.ts";19+import { Referee, RefereeError } from "../lib/referee.ts";20+import { readLog } from "../lib/log.ts";21+import { gridLayout, scatterLayout, stubJudge, synthesise } from "./fixture.ts";22+23+const tmp = await Deno.makeTempDir();24+Deno.env.set("MOSAIC_GAMES", tmp + "/");25+26+const grid = synthesise(gridLayout(2, 14));27+const packed = synthesise(scatterLayout("a", 100));28+29+function solutionOf(out: GenOutput) {30+ const G = out.manifest.G;31+ return (id: number) =>32+ (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;33+}34+35+async function play(36+ fixture: GenOutput,37+ seed: string,38+ guess: "accept" | "reject" = "accept",39+) {40+ const out = structuredClone(fixture);41+ const gameId = crypto.randomUUID();42+ const ref = await Referee.create(43+ gameId,44+ out,45+ { guess, depth: 0 },46+ stubJudge,47+ { seed },48+ );49+ const live: State[] = [];50+ let acc = emptyState();51+ const stream: State[] = [];52+ ref.log.subscribe((ev) => {53+ acc = applyEvent(acc, ev);54+ stream.push(acc);55+ });56+ acc = applyEvent(acc, ref.log.events[0]);57+ stream.push(acc);58+ live.push(structuredClone(ref.state));59+ const sol = solutionOf(out);60+ const geo = ref.state.geo!;61+ while (ref.state.status === "playing") {62+ const fr = frontierOf(ref.state);63+ assert(fr.length > 0, "frontier never empties before the end");64+ const id = fr[0];65+ if (ref.state.tiles[id] === "hidden") {66+ await ref.reveal(ref.revision, id, `reveal #${id}`);67+ live.push(structuredClone(ref.state));68+ }69+ const unknown = geo.cellsOf[id].filter((c) => !ref.state.cells[c]);70+ const batch = unknown.slice(0, 40).map((c) =>71+ [c, sol(c)] as [number, number]72+ );73+ // a wrong one, a duplicate, and a hole to exercise rejected/ignored74+ if (unknown.length > 40) {75+ batch.push([unknown[40], (1 - sol(unknown[40])) as 0 | 1], [76+ unknown[0],77+ sol(unknown[0]),78+ ]);79+ }80+ const hole = holeOf(ref.state);81+ if (hole >= 0) batch.push([hole, 0]);82+ await ref.commit(ref.revision, batch, `fill #${id}`);83+ live.push(structuredClone(ref.state));84+ }85+ return { ref, live, stream, gameId, out };86+}87+88+function holeOf(s: State): number {89+ const geo = s.geo!;90+ for (let id = 0; id < s.G * s.G; id++) if (isHole(geo, id)) return id;91+ return -1;92+}93+94+for (95+ const [name, fixture] of [["grid 27", grid], [96+ "scatter 100",97+ packed,98+ ]] as const99+) {100+ Deno.test(`live fold equals replay fold at every revision (${name})`, async () => {101+ const { ref, live, stream, gameId } = await play(102+ fixture,103+ "fold-" + name,104+ );105+ const text = await readLog(gameId);106+ assert(text !== null, "log written to disk");107+ const events = parseLog(text);108+ assertEquals(events.length, ref.revision + 1);109+ const replay = foldAll(events);110+ for (let k = 0; k < events.length; k++) {111+ assertEquals(replay[k], stream[k], `stream vs replay at rev ${k}`);112+ }113+ for (const s of live) {114+ assertEquals(replay[s.revision], s, `referee state at rev ${s.revision}`);115+ }116+ assertEquals(ref.state.status, "solved");117+ assertEquals(events.at(-1)!.kind, "end");118+ assert(119+ ref.state.totals.wrong > 0,120+ "a wrong commit was rejected and counted",121+ );122+ assert(ref.state.totals.forced + ref.state.totals.guess > 0);123+ // reward is the sum of cell counts over all tiles once everything is solved124+ assertEquals(125+ ref.state.totals.reward,126+ ref.manifest.tiles.reduce((a, t) => a + t.cells, 0),127+ );128+ // the solution is never in the log129+ assert(!text.includes('"solution"'));130+ });131+}132+133+Deno.test("referee gating: frontier, revision, holes, no event on refusal", async () => {134+ const out = structuredClone(packed);135+ const ref = await Referee.create(136+ crypto.randomUUID(),137+ out,138+ { guess: "accept", depth: 0 },139+ stubJudge,140+ { persist: false },141+ );142+ const seed = ref.manifest.seed_tile;143+ const seedSpec = ref.manifest.tiles[seed];144+ const other = ref.manifest.tiles.find((t) =>145+ t.id !== seed && !neighboursOf(seedSpec).includes(t.id)146+ )!;147+ assertEquals(ref.revision, 0);148+ assertEquals(frontierOf(ref.state), [seed]);149+ await assertRejects(150+ () => ref.reveal(0, other.id),151+ RefereeError,152+ "not on the frontier",153+ );154+ await assertRejects(155+ () => ref.reveal(3, seed),156+ RefereeError,157+ "Revision conflict",158+ );159+ await assertRejects(160+ () => ref.reveal(0, ref.manifest.tiles.length + 5),161+ RefereeError,162+ "does not exist",163+ );164+ // a hole alone is ignored, so nothing is processable165+ const hole = holeOf(ref.state);166+ assert(hole >= 0, "the scattered fixture has a hole");167+ await assertRejects(168+ () => ref.commit(0, [[hole, 0]]),169+ RefereeError,170+ "1 hole",171+ );172+ assertEquals(ref.revision, 0);173+ assertEquals(ref.log.events.length, 1);174+ const r = await ref.reveal(0, seed, "seed");175+ assertEquals(r.revision, 1);176+ assertEquals(r.cells, seedSpec.cells);177+ assertEquals(r.rects, seedSpec.rects);178+ assertEquals(179+ r.givens.length,180+ out.tiles.find((t) => t.id === seed)!.givens.length,181+ );182+ // nothing is known in the seed tile before any neighbour is solved183+ assertEquals(r.seam.length, 0);184+ await assertRejects(185+ () => ref.reveal(1, seed),186+ RefereeError,187+ "already revealed",188+ );189+ // the seed's neighbours are still not on the frontier: nothing is solved190+ for (const u of neighboursOf(seedSpec)) {191+ await assertRejects(192+ () => ref.reveal(1, u),193+ RefereeError,194+ "not on the frontier",195+ );196+ }197+ // a given is ignored, not rejected; nothing processable means refusal198+ const [gid, gv] = r.givens[0];199+ await assertRejects(200+ () => ref.commit(1, [[gid, gv]]),201+ RefereeError,202+ "1 given",203+ );204+ assertEquals(ref.revision, 1);205+ await assertRejects(() => ref.retract(1, [gid]), RefereeError);206+ assertEquals(ref.log.events.length, 2);207+ // solve the seed tile: its neighbours join the frontier, with its cells as reward208+ const sol = solutionOf(out);209+ const unknown = ref.state.geo!.cellsOf[seed].filter((c) =>210+ !ref.state.cells[c]211+ );212+ const c = await ref.commit(1, unknown.map((id) => [id, sol(id)]), "all");213+ assertEquals(c.solved, [seed]);214+ assertEquals(c.reward, seedSpec.cells);215+ for (const u of neighboursOf(seedSpec)) assert(c.frontier.includes(u));216+ assertEquals(ref.state.totals.reward, seedSpec.cells);217+});218+219+Deno.test("guess policy reject: unforced correct cells are rejected, batch still recorded", async () => {220+ const out = structuredClone(grid);221+ const ref = await Referee.create(222+ crypto.randomUUID(),223+ out,224+ { guess: "reject", depth: 0 },225+ stubJudge,226+ { persist: false },227+ );228+ const seed = ref.manifest.seed_tile;229+ await ref.reveal(0, seed, "seed");230+ const sol = solutionOf(out);231+ const unknown = ref.state.geo!.cellsOf[seed].filter((id) =>232+ !ref.state.cells[id]233+ );234+ const r = await ref.commit(235+ 1,236+ unknown.map((id) => [id, sol(id)] as [number, number]),237+ "everything at once",238+ );239+ assertEquals(r.revision, 2);240+ assert(r.accepted.every((c) => c.class === "forced"));241+ assert(r.rejected.some((c) => c.reason === "guess"));242+ assert(r.rejected.every((c) => c.reason === "guess"));243+ assertEquals(ref.state.totals.guess, 0);244+ assertEquals(ref.state.totals.wrong, 0);245+ assertEquals(ref.log.events.length, 3);246+});247+248+// v2 item 1: the logs on disk (including the first real packed board) fold249+// from revision 0 to their end event, contiguously, to a solved state whose250+// reward is the sum of n^2 over the manifest's tiles. Skipped when no logs251+// exist (fresh checkout).252+Deno.test("on-disk game logs replay to a solved end state", async () => {253+ const { listLogs, readLog } = await import("../lib/log.ts");254+ const ids = await listLogs();255+ if (ids.length === 0) return;256+ let packed = 0;257+ for (const id of ids) {258+ const text = await readLog(id);259+ if (text === null) continue;260+ const events = parseLog(text);261+ if (events.at(-1)?.kind !== "end") continue; // unfinished game262+ const manifestEvent = events[0] as { manifest?: { version?: number } };263+ const v = manifestEvent.manifest?.version;264+ if (v !== 2 && v !== 3) continue; // phase-1 shape265+ const replay = foldAll(events);266+ for (let k = 0; k < events.length; k++) {267+ assertEquals(events[k].rev, k, `${id}: contiguous revisions`);268+ assertEquals(replay[k].revision, k, `${id}: fold revision`);269+ }270+ const final = replay.at(-1)!;271+ assertEquals(final.status, "solved", `${id}: solved`);272+ const m = final.manifest!;273+ assertEquals(274+ final.totals.reward,275+ m.tiles.reduce((a, t) => a + t.cells, 0),276+ `${id}: reward`,277+ );278+ if (m.packer.kind !== "grid") packed++;279+ assert(!text.includes('"solution"'), `${id}: no solution in the log`);280+ }281+ assert(packed > 0, "at least one finished packed (non-grid) board on disk");282+});