diff --git a/.gitignore b/.gitignore index 7ff8f742..91c50862 100644 --- a/.gitignore +++ b/.gitignore @@ -194,4 +194,10 @@ target Cargo.lock # Mac crap -.DS_Store \ No newline at end of file +.DS_Store + +# Superpowers brainstorm companion scratch files +.superpowers/ + +# The broad Python lib/ pattern above must not swallow the frontend source dir. +!deep_quoridor/frontend/src/lib/ diff --git a/deep_quoridor/ci_requirements.txt b/deep_quoridor/ci_requirements.txt index 61789f95..26e72f11 100644 --- a/deep_quoridor/ci_requirements.txt +++ b/deep_quoridor/ci_requirements.txt @@ -19,3 +19,6 @@ pyyaml pydantic pydantic-yaml jax +fastapi +uvicorn +httpx diff --git a/deep_quoridor/frontend/.gitignore b/deep_quoridor/frontend/.gitignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/deep_quoridor/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/deep_quoridor/frontend/README.md b/deep_quoridor/frontend/README.md new file mode 100644 index 00000000..fd3099fd --- /dev/null +++ b/deep_quoridor/frontend/README.md @@ -0,0 +1,61 @@ +# Quoridor frontend (Svelte + Web Worker + onnxruntime-web) + +The browser play app. A Web Worker runs the `quoridor-wasm` MCTS and evaluates the +net with onnxruntime-web (WebGPU, wasm-CPU fallback); the main thread renders the +board and streams the AI's "thinking" progress. Served by the Python play server +(`src/run_play_server_web.py`). Design: `docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md`. + +## Build + +``` +# 1. Build the wasm package (Plan 1): +wasm-pack build deep_quoridor/rust/quoridor-wasm --target web --release +# 2. Build the frontend: +npm --prefix deep_quoridor/frontend install +npm --prefix deep_quoridor/frontend run build # -> deep_quoridor/frontend/dist/ +``` + +## Run (play a game) + +``` +# Point the server at a run directory (config.yaml + models/checkpoints/*.onnx) +# and the built SPA: +PYTHONPATH=deep_quoridor/src python deep_quoridor/src/run_play_server_web.py \ + /path/to/runs/ --static-dir deep_quoridor/frontend/dist --port 8080 +# open http://localhost:8080 +``` + +Quick try with the bundled fixture model (5×5, 2 walls): +``` +RUN=$(mktemp -d) +printf 'run_id: play\nquoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\nalphazero:\n mcts_n: 200\n mcts_c_puct: 1.4\nself_play:\n num_processes: 1\n games_per_process: 1\ntraining:\n games_per_training_step: 1.0\n learning_rate: 0.001\n batch_size: 64\n weight_decay: 0.0001\n replay_buffer_size: 1000\n' > "$RUN/config.yaml" +mkdir -p "$RUN/models/checkpoints" +cp deep_quoridor/rust/fixtures/alphazero_B5W2_mv1.onnx "$RUN/models/checkpoints/model_1.onnx" +PYTHONPATH=deep_quoridor/src python deep_quoridor/src/run_play_server_web.py "$RUN" \ + --static-dir deep_quoridor/frontend/dist --port 8080 +# open http://localhost:8080 (needs a WebGPU-capable browser, or it falls back to wasm-CPU) +``` + +Dev mode with HMR: `npm --prefix deep_quoridor/frontend run dev`. Vite serves the +COOP/COEP headers itself, but the `/api/*` and `/models/*` routes come from the +Python server, so for full play run the built app behind the Python server (above) +or add a Vite dev proxy to it. + +## How it fits together + +``` +Browser + main thread (Svelte) ── postMessage ─▶ Web Worker (ai.worker.ts) + board, progress bar, ◀─ state/progress ─ quoridor-wasm (game + MCTS) + undo, config drawer onnxruntime-web (WebGPU) ── eval_batch +Python server: / (SPA) · /api/config · /api/models · /models/*.onnx · /ort/*.wasm +``` + +## Tests +``` +npm --prefix deep_quoridor/frontend run test # vitest: board model, eval marshalling, api client +``` +Unit tests cover the pure logic (board derivation, eval tensor marshalling, API +client). End-to-end gameplay (WebGPU inference, progress bar, clicking) is a manual +browser check — the server's `/api/*`, `/models/*`, and `/ort/*` routes are +integration-verified via curl, but actually playing needs a real browser. diff --git a/deep_quoridor/frontend/index.html b/deep_quoridor/frontend/index.html new file mode 100644 index 00000000..f36aebf2 --- /dev/null +++ b/deep_quoridor/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + Quoridor + + + + +
+ + + diff --git a/deep_quoridor/frontend/package-lock.json b/deep_quoridor/frontend/package-lock.json new file mode 100644 index 00000000..e1bdcd2f --- /dev/null +++ b/deep_quoridor/frontend/package-lock.json @@ -0,0 +1,2195 @@ +{ + "name": "quoridor-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quoridor-frontend", + "dependencies": { + "onnxruntime-web": "^1.27.0", + "quoridor-wasm": "file:../rust/quoridor-wasm/pkg" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vite-plugin-static-copy": "^2.0.0", + "vitest": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==" + }, + "node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz", + "integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==" + }, + "node_modules/onnxruntime-web": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.27.0.tgz", + "integrity": "sha512-ogDLsqIozHZwifPuN37OproAo0byX6t43/bP8GzeZWBWD6MOGExswFAx3up4NS/vvWBOg2u2PXomDt3rMmdQSg==", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.27.0", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/p-map": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", + "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quoridor-wasm": { + "version": "0.1.0", + "resolved": "file:../rust/quoridor-wasm/pkg" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-plugin-static-copy": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.2.tgz", + "integrity": "sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.3", + "fast-glob": "^3.2.11", + "fs-extra": "^11.1.0", + "p-map": "^7.0.3", + "picocolors": "^1.0.0" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true + } + } +} diff --git a/deep_quoridor/frontend/package.json b/deep_quoridor/frontend/package.json new file mode 100644 index 00000000..632b4ea0 --- /dev/null +++ b/deep_quoridor/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "quoridor-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "onnxruntime-web": "^1.27.0", + "quoridor-wasm": "file:../rust/quoridor-wasm/pkg" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vite-plugin-static-copy": "^2.0.0", + "vitest": "^2.0.0" + } +} diff --git a/deep_quoridor/frontend/src/App.svelte b/deep_quoridor/frontend/src/App.svelte new file mode 100644 index 00000000..cda5f50c --- /dev/null +++ b/deep_quoridor/frontend/src/App.svelte @@ -0,0 +1,95 @@ + + +
+
+ {#if error}

Error: {error}

{/if} + {#if view} +
+ {#if view.winner != null} + {view.winner === view.human_player ? "You won! 🎉" : "AI won"} + {:else if thinking} + AI is thinking…{#if progress} {progress.done}/{progress.total} sims{/if} + {:else} + Your move — you are blue, at the bottom, moving up + {/if} +
+ + {:else} +

Loading…

+ {/if} +
+ ai.undo(2)} onnewgame={newGame} /> + { model = o.model; params = o.params; ai.setParams(o.params); }} + onhumanplayer={(p) => { humanPlayer = p; }} /> +
+ + diff --git a/deep_quoridor/frontend/src/ai.worker.ts b/deep_quoridor/frontend/src/ai.worker.ts new file mode 100644 index 00000000..64f6110a --- /dev/null +++ b/deep_quoridor/frontend/src/ai.worker.ts @@ -0,0 +1,97 @@ +/// +import initWasm, { Game, init as installPanicHook } from "quoridor-wasm"; +import * as ort from "onnxruntime-web/webgpu"; +import { runEval, type OrtLikeSession } from "./lib/evalRunner"; +import type { StateView } from "./lib/types"; + +// Serve ORT's wasm/mjs from our own origin (copied there by vite-plugin-static-copy). +ort.env.wasm.wasmPaths = "/ort/"; + +type Params = { + mctsN: number; cPuct: number; leafParallelism: number; virtualLoss: number; +}; + +let game: Game | null = null; +let session: ort.InferenceSession | null = null; +let params: Params = { mctsN: 200, cPuct: 1.4, leafParallelism: 8, virtualLoss: 1 }; +let wasmReady: Promise | null = null; +// Serializes game-mutating commands: while a move is in flight (MCTS + NN eval), +// later move/undo/newGame messages are dropped rather than racing the shared +// `game`/`session` state. The UI also gates on turn+thinking; this is defense-in-depth. +let busy = false; + +function ensureWasm(): Promise { + if (!wasmReady) wasmReady = initWasm().then(() => installPanicHook()); + return wasmReady; +} + +async function loadSession(model: string) { + // Release the old session first so repeated New Game / model switches don't + // leak GPU/WASM memory (ORT has no FinalizationRegistry backstop). + if (session) { await session.release(); session = null; } + session = await ort.InferenceSession.create(`/models/${model}`, { + executionProviders: ["webgpu", "wasm"], + }); + console.log("[ai] session ready:", session.inputNames, "->", session.outputNames); +} + +function post(msg: unknown) { (self as unknown as Worker).postMessage(msg); } +function postState(view: StateView, thinking: boolean) { post({ type: "state", view, thinking }); } + +async function evalBatch(flat: Float32Array, n: number, c: number, h: number, w: number) { + if (!session) throw new Error("no model session"); + return runEval(session as unknown as OrtLikeSession, ort.Tensor as never, flat, n, c, h, w); +} + +function progress(done: number, total: number) { + post({ type: "progress", done, total }); +} + +/** Play one AI move if it's the AI's turn and the game isn't over (turns alternate). */ +async function aiMoveIfNeeded(view: StateView): Promise { + // `!= null` catches both null and undefined (a None winner must not read as "over"). + if (view.winner != null || view.current_player === view.human_player) return view; + console.log(`[ai] searching: mctsN=${params.mctsN} currentPlayer=${view.current_player} human=${view.human_player}`); + const t0 = performance.now(); + const res = await game!.runSearch( + params.mctsN, params.cPuct, params.leafParallelism, params.virtualLoss, + evalBatch, progress, + ); + console.log(`[ai] search done in ${Math.round(performance.now() - t0)}ms -> action ${res.action}`); + return game!.applyAction(res.action) as StateView; +} + +self.onmessage = async (e: MessageEvent) => { + const m = e.data; + // Param edits are safe any time and don't touch the game. + if (m.type === "setParams") { params = m.params; return; } + if (busy) return; + busy = true; + try { + if (m.type === "newGame") { + console.log("[ai] newGame model=", m.model); + await ensureWasm(); + params = m.params; + await loadSession(m.model); + game?.free(); + game = new Game(m.boardSize, m.maxWalls, m.maxSteps, m.humanPlayer); + let view = game.stateView() as StateView; + view = await aiMoveIfNeeded(view); // AI opens if the human is player 2 + postState(view, false); + } else if (m.type === "move") { + let view = game!.applyAction(m.index) as StateView; + postState(view, true); // show the human move immediately; AI is about to think + view = await aiMoveIfNeeded(view); + postState(view, false); + } else if (m.type === "undo") { + let view = game!.undo(m.count) as StateView; + view = await aiMoveIfNeeded(view); // if undo landed on the AI's turn, let it reply + postState(view, false); + } + } catch (err) { + console.error("[ai] error handling", m?.type, err); + post({ type: "error", message: String(err) }); + } finally { + busy = false; + } +}; diff --git a/deep_quoridor/frontend/src/app.css b/deep_quoridor/frontend/src/app.css new file mode 100644 index 00000000..a33a84b3 --- /dev/null +++ b/deep_quoridor/frontend/src/app.css @@ -0,0 +1,2 @@ +:root { font-family: system-ui, sans-serif; } +body { margin: 0; padding: 1rem; } diff --git a/deep_quoridor/frontend/src/lib/Board.svelte b/deep_quoridor/frontend/src/lib/Board.svelte new file mode 100644 index 00000000..3aeaf846 --- /dev/null +++ b/deep_quoridor/frontend/src/lib/Board.svelte @@ -0,0 +1,109 @@ + + +
+ {#each grid.cells as cell (cellKey(cell.gr, cell.gc))} + {@const clickable = cell.legalMoveIndex !== null || cell.wall !== null} + + +
click(cell)} + onmouseenter={() => cell.wall && enter(cell.wall.group)} + onmouseleave={clearHover} + > + {#if cell.pawn !== null} +
+ {/if} +
+ {/each} +
+ + diff --git a/deep_quoridor/frontend/src/lib/ConfigDrawer.svelte b/deep_quoridor/frontend/src/lib/ConfigDrawer.svelte new file mode 100644 index 00000000..44d4bf7f --- /dev/null +++ b/deep_quoridor/frontend/src/lib/ConfigDrawer.svelte @@ -0,0 +1,74 @@ + + +
+

Setup

+ + +
+ You play +
+ + +
+ + {humanPlayer === 0 ? "You move first." : "The AI moves first."} Applies when you click New game. + +
+ + + + +
+ + diff --git a/deep_quoridor/frontend/src/lib/ControlRail.svelte b/deep_quoridor/frontend/src/lib/ControlRail.svelte new file mode 100644 index 00000000..b14041fd --- /dev/null +++ b/deep_quoridor/frontend/src/lib/ControlRail.svelte @@ -0,0 +1,45 @@ + + +
+ {#if thinking} +
+ AI thinking… +
+ {progress?.done ?? 0} / {progress?.total ?? 0} sims +
+ {/if} + {#if view?.winner != null} +
{view.winner === view.human_player ? "You win!" : "AI wins"}
+ {/if} + + + + {#if view} +
+ Walls — You {view.human_player === 0 ? view.p1_walls : view.p2_walls} + · AI {view.human_player === 0 ? view.p2_walls : view.p1_walls} +
+
Moves: {view.move_history.length}
+ {/if} +
+ + diff --git a/deep_quoridor/frontend/src/lib/aiClient.ts b/deep_quoridor/frontend/src/lib/aiClient.ts new file mode 100644 index 00000000..b2751e87 --- /dev/null +++ b/deep_quoridor/frontend/src/lib/aiClient.ts @@ -0,0 +1,33 @@ +import type { StateView } from "./types"; + +type Params = { mctsN: number; cPuct: number; leafParallelism: number; virtualLoss: number }; + +export class AiClient { + private worker: Worker; + onState?: (v: StateView, thinking: boolean) => void; + onProgress?: (done: number, total: number) => void; + onError?: (msg: string) => void; + + constructor() { + this.worker = new Worker(new URL("../ai.worker.ts", import.meta.url), { type: "module" }); + this.worker.onmessage = (e: MessageEvent) => { + const m = e.data; + if (m.type === "state") this.onState?.(m.view, m.thinking); + else if (m.type === "progress") this.onProgress?.(m.done, m.total); + else if (m.type === "error") this.onError?.(m.message); + }; + } + + newGame(o: { + model: string; boardSize: number; maxWalls: number; maxSteps: number; + humanPlayer: number; params: Params; + }) { + // `params` may be a Svelte $state proxy, which postMessage can't structure- + // clone (DataCloneError). Spread into a plain object first. + this.worker.postMessage({ type: "newGame", ...o, params: { ...o.params } }); + } + + move(index: number) { this.worker.postMessage({ type: "move", index }); } + undo(count: number) { this.worker.postMessage({ type: "undo", count }); } + setParams(params: Params) { this.worker.postMessage({ type: "setParams", params: { ...params } }); } +} diff --git a/deep_quoridor/frontend/src/lib/api.test.ts b/deep_quoridor/frontend/src/lib/api.test.ts new file mode 100644 index 00000000..aa165744 --- /dev/null +++ b/deep_quoridor/frontend/src/lib/api.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchConfig, fetchModels } from "./api"; + +afterEach(() => vi.unstubAllGlobals()); + +function stubFetch(body: unknown) { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => body }))); +} + +describe("api", () => { + it("fetchConfig returns the parsed config", async () => { + stubFetch({ board_size: 5, max_walls: 2, max_steps: 50, defaults: { mcts_n: 100 } }); + const cfg = await fetchConfig(); + expect(cfg.board_size).toBe(5); + expect(cfg.defaults.mcts_n).toBe(100); + }); + + it("fetchModels returns models + default", async () => { + stubFetch({ models: ["model_1.onnx", "model_2.onnx"], default: "model_2.onnx" }); + const m = await fetchModels(); + expect(m.models).toEqual(["model_1.onnx", "model_2.onnx"]); + expect(m.default).toBe("model_2.onnx"); + }); +}); diff --git a/deep_quoridor/frontend/src/lib/api.ts b/deep_quoridor/frontend/src/lib/api.ts new file mode 100644 index 00000000..c5bc238f --- /dev/null +++ b/deep_quoridor/frontend/src/lib/api.ts @@ -0,0 +1,25 @@ +export interface ConfigView { + board_size: number; + max_walls: number; + max_steps: number; + defaults: { + mcts_n: number; + mcts_c_puct: number; + temperature: number | null; + mcts_noise_epsilon: number; + mcts_noise_alpha: number | null; + leaf_parallelism: number; + virtual_loss: number; + mcts_worker_threads: number | null; + }; +} +export interface ModelsView { models: string[]; default: string | null } + +async function getJson(url: string): Promise { + const r = await fetch(url); + if (!r.ok) throw new Error(`${url} -> ${r.status}`); + return (await r.json()) as T; +} + +export const fetchConfig = () => getJson("/api/config"); +export const fetchModels = () => getJson("/api/models"); diff --git a/deep_quoridor/frontend/src/lib/boardGrid.test.ts b/deep_quoridor/frontend/src/lib/boardGrid.test.ts new file mode 100644 index 00000000..620c229b --- /dev/null +++ b/deep_quoridor/frontend/src/lib/boardGrid.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { buildBoardGrid, cellKey } from "./boardGrid"; +import type { StateView } from "./types"; + +// Server coords: player 0 starts at row 0 (top), player 1 at row N-1 (bottom). +// Human is player 0, so the grid flips vertically -> P0 shown at the bottom. +function view(over: Partial = {}): StateView { + return { + board_size: 5, max_walls: 2, max_steps: 50, current_player: 0, + p1_pos: [0, 2], p2_pos: [4, 2], p1_walls: 2, p2_walls: 2, + walls: [], legal_actions: [], completed_steps: 0, winner: null, + human_player: 0, last_action: null, move_history: [], + ...over, + }; +} + +function cell(g: ReturnType, gr: number, gc: number) { + return g.cells.find((c) => c.gr === gr && c.gc === gc)!; +} + +describe("buildBoardGrid", () => { + it("produces a (2N-1)^2 grid with the right cell kinds", () => { + const g = buildBoardGrid(view(), true); + expect(g.size).toBe(9); + expect(g.cells).toHaveLength(81); + expect(cell(g, 0, 0).base).toBe("pawn-cell"); + expect(cell(g, 1, 0).base).toBe("wall-h-half"); + expect(cell(g, 0, 1).base).toBe("wall-v-half"); + expect(cell(g, 1, 1).base).toBe("post"); + }); + + it("flips so the human (P0) sits at the visual bottom and the AI (P1) at the top", () => { + const g = buildBoardGrid(view(), true); + // P0 server row 0 -> display row 4 -> grid row 8 (bottom). + expect(cell(g, 8, 4).pawn).toBe(0); + // P1 server row 4 -> display row 0 -> grid row 0 (top). + expect(cell(g, 0, 4).pawn).toBe(1); + }); + + it("maps a legal forward move to the mirrored cell (above the pawn = up)", () => { + const g = buildBoardGrid( + view({ legal_actions: [{ kind: "move", index: 7, to: [1, 2] }] }), + true, + ); + // to (1,2) -> display (3,2) -> grid (6,4), which is above the pawn at (8,4). + expect(cell(g, 6, 4).legalMoveIndex).toBe(7); + }); + + it("puts a legal wall on its anchor cell with the 3-cell hover group", () => { + const g = buildBoardGrid( + view({ legal_actions: [{ kind: "wall", index: 30, row: 1, col: 1, orientation: "v" }] }), + true, + ); + // wall (1,1) -> display (2,1); vertical group anchored at (4,3). + const anchor = cell(g, 4, 3); + expect(anchor.wall?.index).toBe(30); + expect(anchor.wall?.group).toEqual([cellKey(4, 3), cellKey(5, 3), cellKey(6, 3)]); + // Non-anchor cells of the group carry no wall marker. + expect(cell(g, 5, 3).wall).toBeNull(); + }); + + it("marks placed walls across their whole group", () => { + const g = buildBoardGrid(view({ walls: [{ row: 1, col: 1, orientation: "h" }] }), true); + // wall (1,1) h -> display (2,1); horizontal group [(5,2),(5,3),(5,4)]. + expect(cell(g, 5, 2).placed).toBe("h"); + expect(cell(g, 5, 3).placed).toBe("h"); + expect(cell(g, 5, 4).placed).toBe("h"); + }); + + it("omits legal markers when not interactive or not the human's turn", () => { + const acts: StateView["legal_actions"] = [{ kind: "move", index: 7, to: [1, 2] }]; + expect(buildBoardGrid(view({ legal_actions: acts }), false).cells.every((c) => c.legalMoveIndex === null)).toBe(true); + expect( + buildBoardGrid(view({ legal_actions: acts, current_player: 1 }), true).cells.every((c) => c.legalMoveIndex === null), + ).toBe(true); + }); +}); diff --git a/deep_quoridor/frontend/src/lib/boardGrid.ts b/deep_quoridor/frontend/src/lib/boardGrid.ts new file mode 100644 index 00000000..5589ac0e --- /dev/null +++ b/deep_quoridor/frontend/src/lib/boardGrid.ts @@ -0,0 +1,128 @@ +// Pure geometry for rendering a Quoridor board as a (2N-1)x(2N-1) grid of +// pawn cells, wall-half slots, and posts. The human (player 0) is always shown +// with their home row at the visual BOTTOM, moving UP toward the top — so we +// vertically flip server coordinates when the human is player 0. +// +// Adapted from the original Rust play-server's app.js rendering, which used the +// same mirror + wall-group scheme. + +import type { Orientation, StateView } from "./types"; + +export interface GridCell { + gr: number; + gc: number; + base: "pawn-cell" | "wall-h-half" | "wall-v-half" | "post"; + pawn: 0 | 1 | null; + placed: Orientation | null; + /** Set on a legal move's destination pawn-cell. */ + legalMoveIndex: number | null; + /** Set ONLY on a legal wall's anchor (display top-left) cell. */ + wall: { index: number; orientation: Orientation; group: string[] } | null; + lastMove: boolean; + lastWall: boolean; +} + +export interface BoardGrid { + n: number; + size: number; // 2n - 1 + cells: GridCell[]; +} + +export function cellKey(gr: number, gc: number): string { + return `${gr},${gc}`; +} + +/** The 3 grid cells (two halves + the post between) covered by a wall whose + * display top-left corner is (dr, dc). For "h" it extends right, for "v" down. */ +function wallGroup(dr: number, dc: number, o: Orientation): [number, number][] { + if (o === "h") { + return [ + [2 * dr + 1, 2 * dc], + [2 * dr + 1, 2 * dc + 1], + [2 * dr + 1, 2 * dc + 2], + ]; + } + return [ + [2 * dr, 2 * dc + 1], + [2 * dr + 1, 2 * dc + 1], + [2 * dr + 2, 2 * dc + 1], + ]; +} + +/** Build the renderable grid. `interactive` gates legal-action markers (off + * when it's the AI's turn / the board is disabled). */ +export function buildBoardGrid(view: StateView, interactive: boolean): BoardGrid { + const n = view.board_size; + const size = 2 * n - 1; + const flip = view.human_player === 0; // put the human's (P0) home row at the bottom + const mp = (r: number, c: number): [number, number] => (flip ? [n - 1 - r, c] : [r, c]); + // A wall's (top,left) corner spans two pawn rows, so its flipped display row + // is one less than a pawn's: (n-1-r) - 1 = n-2-r. + const mw = (r: number, c: number): [number, number] => (flip ? [n - 2 - r, c] : [r, c]); + + const cells: GridCell[] = []; + const idx = new Map(); + for (let gr = 0; gr < size; gr++) { + for (let gc = 0; gc < size; gc++) { + const rowEven = gr % 2 === 0; + const colEven = gc % 2 === 0; + const base = + rowEven && colEven + ? "pawn-cell" + : !rowEven && colEven + ? "wall-h-half" + : rowEven && !colEven + ? "wall-v-half" + : "post"; + const cell: GridCell = { + gr, gc, base, pawn: null, placed: null, + legalMoveIndex: null, wall: null, lastMove: false, lastWall: false, + }; + cells.push(cell); + idx.set(cellKey(gr, gc), cell); + } + } + const at = (gr: number, gc: number): GridCell => idx.get(cellKey(gr, gc))!; + + const [p1r, p1c] = mp(view.p1_pos[0], view.p1_pos[1]); + const [p2r, p2c] = mp(view.p2_pos[0], view.p2_pos[1]); + at(2 * p1r, 2 * p1c).pawn = 0; + at(2 * p2r, 2 * p2c).pawn = 1; + + for (const w of view.walls) { + const [dr, dc] = mw(w.row, w.col); + for (const [gr, gc] of wallGroup(dr, dc, w.orientation)) at(gr, gc).placed = w.orientation; + } + + if (view.last_action) { + const la = view.last_action; + if (la.kind === "move") { + const [dr, dc] = mp(la.to[0], la.to[1]); + at(2 * dr, 2 * dc).lastMove = true; + } else { + const [dr, dc] = mw(la.row, la.col); + for (const [gr, gc] of wallGroup(dr, dc, la.orientation)) at(gr, gc).lastWall = true; + } + } + + // `== null` catches both null and undefined (older serializers emit undefined). + const humanTurn = view.winner == null && view.current_player === view.human_player; + if (interactive && humanTurn) { + for (const a of view.legal_actions) { + if (a.kind === "move") { + const [dr, dc] = mp(a.to[0], a.to[1]); + at(2 * dr, 2 * dc).legalMoveIndex = a.index; + } else { + const [dr, dc] = mw(a.row, a.col); + const group = wallGroup(dr, dc, a.orientation); + at(group[0][0], group[0][1]).wall = { + index: a.index, + orientation: a.orientation, + group: group.map(([r, c]) => cellKey(r, c)), + }; + } + } + } + + return { n, size, cells }; +} diff --git a/deep_quoridor/frontend/src/lib/evalRunner.test.ts b/deep_quoridor/frontend/src/lib/evalRunner.test.ts new file mode 100644 index 00000000..c33518af --- /dev/null +++ b/deep_quoridor/frontend/src/lib/evalRunner.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { runEval } from "./evalRunner"; + +// A fake ORT-like session: records the input tensor and returns fixed outputs. +function fakeSession(n: number, policy: number) { + return { + lastInput: null as any, + async run(feeds: any) { + this.lastInput = feeds.input; + return { + value: { data: Float32Array.from({ length: n }, (_, i) => i * 0.1) }, + policy_logits: { data: new Float32Array(n * policy) }, + }; + }, + }; +} + +// Minimal Tensor stand-in so runEval doesn't need the real ort in unit tests. +class FakeTensor { + constructor(public type: string, public data: Float32Array, public dims: number[]) {} +} + +describe("runEval", () => { + it("builds an [n,c,h,w] input tensor and splits value/logits", async () => { + const n = 2, c = 5, h = 13, w = 13, policy = 57; + const session = fakeSession(n, policy); + const flat = new Float32Array(n * c * h * w); + const out = await runEval(session as any, FakeTensor as any, flat, n, c, h, w); + + expect(session.lastInput.dims).toEqual([n, c, h, w]); + expect(session.lastInput.data).toBe(flat); + expect(out.values).toHaveLength(n); + expect(out.values[1]).toBeCloseTo(0.1); + expect(out.logits).toHaveLength(n * policy); + }); +}); diff --git a/deep_quoridor/frontend/src/lib/evalRunner.ts b/deep_quoridor/frontend/src/lib/evalRunner.ts new file mode 100644 index 00000000..ffb79690 --- /dev/null +++ b/deep_quoridor/frontend/src/lib/evalRunner.ts @@ -0,0 +1,25 @@ +// Pure marshalling between quoridor-wasm's eval_batch(flat, n, c, h, w) contract +// and an onnxruntime-web InferenceSession. Kept free of a hard `ort` import so it +// is unit-testable with a fake session + tensor. + +export interface OrtLikeSession { + run(feeds: Record): Promise>; +} +export type TensorCtor = new (type: "float32", data: Float32Array, dims: number[]) => unknown; + +export async function runEval( + session: OrtLikeSession, + Tensor: TensorCtor, + flat: Float32Array, + n: number, + c: number, + h: number, + w: number, +): Promise<{ values: Float32Array; logits: Float32Array }> { + const input = new Tensor("float32", flat, [n, c, h, w]); + const out = await session.run({ input }); + return { + values: out.value.data, + logits: out.policy_logits.data, + }; +} diff --git a/deep_quoridor/frontend/src/lib/smoke.test.ts b/deep_quoridor/frontend/src/lib/smoke.test.ts new file mode 100644 index 00000000..fd7b5118 --- /dev/null +++ b/deep_quoridor/frontend/src/lib/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("scaffold", () => { + it("runs vitest", () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/deep_quoridor/frontend/src/lib/types.ts b/deep_quoridor/frontend/src/lib/types.ts new file mode 100644 index 00000000..a2b3c5fb --- /dev/null +++ b/deep_quoridor/frontend/src/lib/types.ts @@ -0,0 +1,31 @@ +export type Orientation = "h" | "v"; + +export type EnrichedAction = + | { kind: "move"; index: number; to: [number, number] } + | { kind: "wall"; index: number; row: number; col: number; orientation: Orientation }; + +export interface WallEntry { row: number; col: number; orientation: Orientation } + +export interface StateView { + board_size: number; + max_walls: number; + max_steps: number; + current_player: number; + p1_pos: [number, number]; + p2_pos: [number, number]; + p1_walls: number; + p2_walls: number; + walls: WallEntry[]; + legal_actions: EnrichedAction[]; + completed_steps: number; + winner: number | null; + human_player: number; + last_action: EnrichedAction | null; + move_history: number[]; +} + +export interface SearchResult { + action: number; + rootValue: number; + children: { actionIndex: number; visitCount: number }[]; +} diff --git a/deep_quoridor/frontend/src/main.ts b/deep_quoridor/frontend/src/main.ts new file mode 100644 index 00000000..2fa7caaa --- /dev/null +++ b/deep_quoridor/frontend/src/main.ts @@ -0,0 +1,6 @@ +import "./app.css"; +import { mount } from "svelte"; +import App from "./App.svelte"; + +const app = mount(App, { target: document.getElementById("app")! }); +export default app; diff --git a/deep_quoridor/frontend/svelte.config.js b/deep_quoridor/frontend/svelte.config.js new file mode 100644 index 00000000..9dae8c31 --- /dev/null +++ b/deep_quoridor/frontend/svelte.config.js @@ -0,0 +1,2 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +export default { preprocess: vitePreprocess() }; diff --git a/deep_quoridor/frontend/tsconfig.json b/deep_quoridor/frontend/tsconfig.json new file mode 100644 index 00000000..5dc2e1c8 --- /dev/null +++ b/deep_quoridor/frontend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "types": ["svelte", "vite/client"], + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/deep_quoridor/frontend/vite.config.ts b/deep_quoridor/frontend/vite.config.ts new file mode 100644 index 00000000..487b146e --- /dev/null +++ b/deep_quoridor/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { viteStaticCopy } from "vite-plugin-static-copy"; + +// Copy onnxruntime-web's wasm/mjs runtime into the build so it is served +// SAME-ORIGIN (COEP require-corp, set by the Python server, blocks cross-origin +// non-CORP resources — a CDN load of ORT would be blocked). We point +// ort.env.wasm.wasmPaths at "/ort/" (see ai.worker.ts). +export default defineConfig({ + plugins: [ + svelte(), + viteStaticCopy({ + targets: [ + { src: "node_modules/onnxruntime-web/dist/*.wasm", dest: "ort" }, + { src: "node_modules/onnxruntime-web/dist/*.mjs", dest: "ort" }, + ], + }), + ], + // Vite serves cross-origin-isolation headers in dev so SharedArrayBuffer / + // the wasm-CPU fallback work locally the same as behind the Python server. + server: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }, + worker: { format: "es" }, +}); diff --git a/deep_quoridor/requirements.txt b/deep_quoridor/requirements.txt index 7972fc94..982027e2 100644 --- a/deep_quoridor/requirements.txt +++ b/deep_quoridor/requirements.txt @@ -18,4 +18,7 @@ wandb sb3_contrib stable_baselines3 pydantic -pydantic-yaml \ No newline at end of file +pydantic-yaml +fastapi +uvicorn +httpx \ No newline at end of file diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml index 90ba1531..65f96bac 100644 --- a/deep_quoridor/rust/Cargo.toml +++ b/deep_quoridor/rust/Cargo.toml @@ -36,10 +36,10 @@ pyo3 = { version = "0.22", features = ["extension-module"], optional = true } numpy = { version = "0.22", optional = true } ndarray = "0.16" rand = "0.8" -dashmap = "6" -rayon = "1.10" -arrow = "53" -parquet = { version = "53", default-features = false, features = ["arrow", "zstd"] } +dashmap = { version = "6", optional = true } +rayon = { version = "1.10", optional = true } +arrow = { version = "53", optional = true } +parquet = { version = "53", default-features = false, features = ["arrow", "zstd"], optional = true } serde = { version = "1", features = ["derive"] } clap = { version = "4.5", features = ["derive"], optional = true } # Pinned exactly: ort is pre-release and makes breaking API changes between @@ -53,16 +53,18 @@ serde_json = { version = "1", optional = true } tiny_http = { version = "0.12", optional = true } ndarray-npy = { version = "0.9", optional = true } zip = { version = "2", optional = true, default-features = false, features = ["deflate"] } -rand_distr = { version = "0.4", optional = true } +rand_distr = "0.4" tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"], optional = true } futures = { version = "0.3", optional = true } smallvec = "1.13" [features] default = ["python"] -python = ["pyo3", "numpy"] -binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures", "tiny_http"] +python = ["pyo3", "numpy", "policy_db", "parallel"] +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "tokio", "futures", "tiny_http", "policy_db", "parallel"] gpu = ["binary", "ort/cuda", "ort/load-dynamic"] +policy_db = ["dep:arrow", "dep:parquet", "dep:dashmap"] +parallel = ["dep:rayon", "dep:dashmap"] [dev-dependencies] tempfile = "3" @@ -78,3 +80,9 @@ serde_json = "1" opt-level = 3 lto = true codegen-units = 1 + +[workspace] +members = ["quoridor-wasm"] +# `cargo build`/`cargo test` at the root operate on quoridor-rs only; the wasm +# crate is built explicitly via wasm-pack (it targets wasm32). +default-members = ["."] diff --git a/deep_quoridor/rust/quoridor-wasm/.gitignore b/deep_quoridor/rust/quoridor-wasm/.gitignore new file mode 100644 index 00000000..394954e2 --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/.gitignore @@ -0,0 +1,2 @@ +/pkg +/target diff --git a/deep_quoridor/rust/quoridor-wasm/Cargo.toml b/deep_quoridor/rust/quoridor-wasm/Cargo.toml new file mode 100644 index 00000000..6c51086a --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "quoridor-wasm" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +quoridor-rs = { path = "..", default-features = false } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6" +anyhow = "1" +ndarray = "0.16" +# wasm needs the js backend for getrandom (pulled transitively by rand). +getrandom = { version = "0.2", features = ["js"] } +console_error_panic_hook = "0.1" + +[dev-dependencies] +wasm-bindgen-test = "0.3" +serde_json = "1" diff --git a/deep_quoridor/rust/quoridor-wasm/README.md b/deep_quoridor/rust/quoridor-wasm/README.md new file mode 100644 index 00000000..588d00a2 --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/README.md @@ -0,0 +1,65 @@ +# quoridor-wasm + +WebAssembly bindings for the Quoridor game core + AlphaZero MCTS, so the AI can +run entirely in the browser. Part of Milestone 1 of the browser-WASM play server +— see `docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md` and +the implementation plan `docs/superpowers/plans/2026-07-05-quoridor-wasm-crate.md`. + +This crate is a thin `wasm-bindgen` layer over `quoridor-rs`: it owns the game +session and drives the leaf-parallel batched MCTS, but the neural-network forward +pass is injected as a JavaScript callback (wired to onnxruntime-web / WebGPU by +the frontend). No `tokio`/`ort` is pulled into the wasm build. + +## Build + +``` +rustup target add wasm32-unknown-unknown +cargo install wasm-pack +wasm-pack build rust/quoridor-wasm --target web --release +``` + +Output lands in `rust/quoridor-wasm/pkg/`: import `quoridor_wasm.js`, which loads +`quoridor_wasm_bg.wasm`. Call the module's default `init()` once on startup +(routes Rust panics to `console.error`). + +## JS API + +- `new Game(board_size, max_walls, max_steps, human_player)` — create a session. +- `game.stateView()` → `StateView` object (board dims, pawn positions, walls, + `legal_actions`, `winner`, `move_history`, …). +- `game.applyAction(actionIndex)` → `StateView` (throws on illegal/over). +- `game.undo(count)` → `StateView` (replays history minus `count` plies). +- `game.runSearch(mctsN, cPuct, leafParallelism, virtualLoss, evalBatch, progress)` + → `Promise<{ action, rootValue, children: [{ actionIndex, visitCount }] }>`. + Rejects if called on a finished game. + +`evalBatch` is the NN forward pass, supplied by the frontend (Plan 3): + +``` +evalBatch(flat: Float32Array, n, c, h, w) => + Promise<{ values: Float32Array /*[n]*/, logits: Float32Array /*[n*policySize]*/ }> +``` + +`flat` is the batch of `n` feature tensors of shape `[n, c, h, w]` in row-major +order (`c == 5`, `h == w == board_size*2 + 3`). Return **raw** policy logits — the +Rust side applies masked softmax + un-rotation. `progress(done, total)` is called +after each search round (drive a progress bar); it is best-effort (a throwing +progress callback is ignored). + +## Tests + +- **Native unit tests** (game/undo logic, view serialization): + `cargo test -p quoridor-wasm` +- **Wasm binding test** (`runSearch` end-to-end with a JS mock eval): + `wasm-pack test --node` + The committed `tests/web.rs` targets Node (CommonJS mock, no `run_in_browser`). + To run it in a headless browser instead, drop the CJS/Node form for the ESM + `export function` mock and add `wasm_bindgen_test_configure!(run_in_browser);`, + then `wasm-pack test --headless --chrome` (see the note in `tests/web.rs`). + +> Note: some sandboxed CI containers ship a Node + `wasm-bindgen-test-runner` +> combination that traps on any wasm test under `--node`. If `wasm-pack test +> --node` aborts with a V8 fatal error unrelated to your code, run the browser +> variant above (with a `chromedriver` whose major version matches the installed +> Chrome). The binding logic itself has been verified end-to-end under headless +> Chrome. diff --git a/deep_quoridor/rust/quoridor-wasm/src/game.rs b/deep_quoridor/rust/quoridor-wasm/src/game.rs new file mode 100644 index 00000000..35500740 --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/src/game.rs @@ -0,0 +1,167 @@ +//! Per-game session exposed to JS. Owns mechanics + state + move history and +//! produces `StateView` snapshots. + +use quoridor_rs::compact::q_bit_repr::{CompactState, WALL_HORIZONTAL, WALL_VERTICAL}; +use quoridor_rs::compact::q_game_mechanics::QGameMechanics; + +use crate::view::{ + EnrichedAction, StateView, WallEntry, WallOrientation, enrich_action, enrich_legal_actions, +}; + +pub struct WasmGame { + mechanics: QGameMechanics, + state: CompactState, + board_size: i32, + max_walls: i32, + max_steps: i32, + human_player: i32, + last_action: Option, + move_history: Vec, +} + +impl WasmGame { + pub fn new(board_size: i32, max_walls: i32, max_steps: i32, human_player: i32) -> Self { + let mechanics = + QGameMechanics::new(board_size as usize, max_walls as usize, max_steps as usize); + let state = mechanics.create_initial_state(); + Self { + mechanics, + state, + board_size, + max_walls, + max_steps, + human_player, + last_action: None, + move_history: Vec::new(), + } + } + + fn current_player(&self) -> i32 { + self.mechanics.repr().get_current_player(self.state) as i32 + } + + /// A win, or hitting the step cap (draw). This is the crate's single source + /// of truth for "game over" — `apply_action` and `run_search_js` both use it. + pub fn is_game_over(&self) -> bool { + self.mechanics.is_game_over(self.state) + || self.mechanics.repr().get_completed_steps(self.state) >= self.max_steps as usize + } + + pub fn legal_mask(&self) -> Vec { + self.mechanics.get_action_mask_immut(self.state) + } + + /// Apply any legal action (human or AI). Returns Err on illegal/over. + pub fn apply_action(&mut self, action_index: u32) -> Result<(), String> { + if self.is_game_over() { + return Err("game is already over".into()); + } + let mask = self.legal_mask(); + let idx = action_index as usize; + if idx >= mask.len() || !mask[idx] { + return Err(format!("action {action_index} is not legal")); + } + self.last_action = Some(enrich_action(self.board_size, idx)); + self.move_history.push(action_index); + self.mechanics.apply_action_index(&mut self.state, idx); + Ok(()) + } + + /// Undo the last `count` plies by replaying history from the initial state. + pub fn undo(&mut self, count: usize) { + let keep = self.move_history.len().saturating_sub(count); + let replay: Vec = self.move_history[..keep].to_vec(); + self.state = self.mechanics.create_initial_state(); + self.move_history.clear(); + self.last_action = None; + for a in replay { + let idx = a as usize; + self.last_action = Some(enrich_action(self.board_size, idx)); + self.move_history.push(a); + self.mechanics.apply_action_index(&mut self.state, idx); + } + } + + pub fn state(&self) -> CompactState { + self.state + } + pub fn mechanics(&self) -> &QGameMechanics { + &self.mechanics + } + + pub fn view(&self) -> StateView { + let mask = self.legal_mask(); + let repr = self.mechanics.repr(); + let (p1r, p1c) = repr.get_player_position(self.state, 0); + let (p2r, p2c) = repr.get_player_position(self.state, 1); + let winner = self.mechanics.winner(self.state).map(|p| p as i32); + StateView { + board_size: self.board_size, + max_walls: self.max_walls, + max_steps: self.max_steps, + current_player: self.current_player(), + p1_pos: [p1r as i32, p1c as i32], + p2_pos: [p2r as i32, p2c as i32], + p1_walls: repr.get_walls_remaining(self.state, 0) as i32, + p2_walls: repr.get_walls_remaining(self.state, 1) as i32, + walls: list_walls(&self.mechanics, self.state, self.board_size), + legal_actions: enrich_legal_actions(self.board_size, &mask), + completed_steps: repr.get_completed_steps(self.state) as i32, + winner, + human_player: self.human_player, + last_action: self.last_action.clone(), + move_history: self.move_history.clone(), + } + } +} + +fn list_walls(mechanics: &QGameMechanics, state: CompactState, board_size: i32) -> Vec { + let mut out = Vec::new(); + let wall_size = (board_size - 1) as usize; + for (orientation_const, orientation) in [ + (WALL_VERTICAL, WallOrientation::V), + (WALL_HORIZONTAL, WallOrientation::H), + ] { + for row in 0..wall_size { + for col in 0..wall_size { + if mechanics + .repr() + .get_wall(state, row, col, orientation_const) + { + out.push(WallEntry { + row: row as i32, + col: col as i32, + orientation, + }); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_then_undo_restores_state() { + let mut g = WasmGame::new(5, 2, 50, 0); + let mask = g.legal_mask(); + let first = mask.iter().position(|&b| b).unwrap() as u32; + g.apply_action(first).unwrap(); + assert_eq!(g.view().move_history, vec![first]); + g.undo(1); + let v = g.view(); + assert!(v.move_history.is_empty()); + assert!(v.last_action.is_none()); + assert_eq!(v.current_player, 0); + } + + #[test] + fn apply_rejects_illegal_action() { + let mut g = WasmGame::new(5, 2, 50, 0); + let err = g.apply_action(u32::MAX).unwrap_err(); + assert!(err.contains("not legal")); + } +} diff --git a/deep_quoridor/rust/quoridor-wasm/src/lib.rs b/deep_quoridor/rust/quoridor-wasm/src/lib.rs new file mode 100644 index 00000000..46919fba --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/src/lib.rs @@ -0,0 +1,77 @@ +use serde::Serialize; +use wasm_bindgen::prelude::*; + +mod game; +mod search; +mod view; + +use game::WasmGame; + +#[wasm_bindgen(start)] +pub fn init() { + console_error_panic_hook::set_once(); +} + +/// JS-facing handle around a game session. +#[wasm_bindgen] +pub struct Game { + inner: WasmGame, +} + +#[wasm_bindgen] +impl Game { + #[wasm_bindgen(constructor)] + pub fn new(board_size: i32, max_walls: i32, max_steps: i32, human_player: i32) -> Game { + Game { + inner: WasmGame::new(board_size, max_walls, max_steps, human_player), + } + } + + /// Returns the `StateView` as a JS object. + #[wasm_bindgen(js_name = stateView)] + pub fn state_view(&self) -> Result { + // Serialize `Option::None` as JS `null` (not `undefined`) so the fields + // the TS types declare as `T | null` (winner, last_action) actually + // arrive as `null` and `=== null` checks on the JS side hold. + let ser = serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true); + self.inner + .view() + .serialize(&ser) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[wasm_bindgen(js_name = applyAction)] + pub fn apply_action(&mut self, action_index: u32) -> Result { + self.inner + .apply_action(action_index) + .map_err(|e| JsValue::from_str(&e))?; + self.state_view() + } + + pub fn undo(&mut self, count: usize) -> Result { + self.inner.undo(count); + self.state_view() + } + + #[wasm_bindgen(js_name = runSearch)] + pub async fn run_search( + &self, + mcts_n: u32, + c_puct: f32, + leaf_parallelism: u32, + virtual_loss: u32, + eval_batch: js_sys::Function, + progress: js_sys::Function, + ) -> Result { + crate::search::run_search_js( + &self.inner, + mcts_n, + c_puct, + leaf_parallelism, + virtual_loss, + eval_batch, + progress, + ) + .await + } +} diff --git a/deep_quoridor/rust/quoridor-wasm/src/search.rs b/deep_quoridor/rust/quoridor-wasm/src/search.rs new file mode 100644 index 00000000..33c94353 --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/src/search.rs @@ -0,0 +1,166 @@ +//! Wires the portable `run_batched_search` driver to JS callbacks: an async +//! batched NN eval (onnxruntime-web) and a progress reporter. + +use js_sys::{Array, Float32Array, Object, Reflect}; +use ndarray::Array4; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use quoridor_rs::agents::alphazero::batched_search::{ + BatchedSearchConfig, EvalOutput, best_action, run_batched_search, +}; +use quoridor_rs::agents::alphazero::mcts::MCTSConfig; + +use crate::game::WasmGame; + +/// Flatten a batch of [1, C, H, W] feature tensors into one contiguous +/// Float32Array of shape [N, C, H, W] plus its dims. +fn flatten_batch(batch: &[Array4]) -> (Float32Array, usize, usize, usize) { + let n = batch.len(); + let (c, h, w) = if n == 0 { + (0, 0, 0) + } else { + let s = batch[0].shape(); + (s[1], s[2], s[3]) + }; + let per = c * h * w; + let flat = Float32Array::new_with_length((n * per) as u32); + for (i, arr) in batch.iter().enumerate() { + let contiguous: Vec = arr.iter().copied().collect(); + flat.subarray((i * per) as u32, ((i + 1) * per) as u32) + .copy_from(&contiguous); + } + (flat, c, h, w) +} + +/// Split the JS result `{ values: Float32Array[N], logits: Float32Array[N*P] }` +/// into per-leaf `EvalOutput`s. +fn split_outputs(result: &JsValue, n: usize) -> Result, JsValue> { + let values: Float32Array = Reflect::get(result, &JsValue::from_str("values"))?.dyn_into()?; + let logits: Float32Array = Reflect::get(result, &JsValue::from_str("logits"))?.dyn_into()?; + let values_v = values.to_vec(); + let logits_v = logits.to_vec(); + if values_v.len() != n { + return Err(JsValue::from_str( + "eval result 'values' length != batch size", + )); + } + if n != 0 && logits_v.len() % n != 0 { + return Err(JsValue::from_str( + "eval result 'logits' length is not a multiple of batch size", + )); + } + let p = if n == 0 { 0 } else { logits_v.len() / n }; + let mut out = Vec::with_capacity(n); + for i in 0..n { + out.push(EvalOutput { + value: values_v[i], + policy_logits: logits_v[i * p..(i + 1) * p].to_vec(), + }); + } + Ok(out) +} + +/// Run MCTS for the current position, calling `eval_batch` (async JS) for NN +/// forward passes and `progress` after each round. Resolves to +/// `{ action, rootValue, children: [{actionIndex, visitCount}] }`. +pub async fn run_search_js( + game: &WasmGame, + mcts_n: u32, + c_puct: f32, + leaf_parallelism: u32, + virtual_loss: u32, + eval_batch: js_sys::Function, + progress: js_sys::Function, +) -> Result { + // Guard the boundary: searching a finished game leaves the root unexpanded, + // so `run_batched_search` returns no children and `best_action` would panic + // (a hard wasm abort under `panic = "abort"`). Surface a catchable JS error. + if game.is_game_over() { + return Err(JsValue::from_str("cannot run search: game is already over")); + } + + let cfg = MCTSConfig { + n: Some(mcts_n), + k: None, + ucb_c: c_puct, + noise_epsilon: 0.0, + noise_alpha: None, + // Play mode terminates on a winner and the JS layer stops at game end, + // so a hard step cap isn't needed here for M1. + max_steps: None, + penalize_visited_states: false, + }; + let bs = BatchedSearchConfig { + leaf_parallelism, + virtual_loss, + }; + + let eval = |batch: Vec>| { + let eval_batch = eval_batch.clone(); + async move { + let n = batch.len(); + let (flat, c, h, w) = flatten_batch(&batch); + let args = Array::new(); + args.push(&flat); + args.push(&JsValue::from_f64(n as f64)); + args.push(&JsValue::from_f64(c as f64)); + args.push(&JsValue::from_f64(h as f64)); + args.push(&JsValue::from_f64(w as f64)); + let promise = eval_batch + .apply(&JsValue::NULL, &args) + .map_err(|e| anyhow::anyhow!("eval_batch threw: {e:?}"))?; + let promise: js_sys::Promise = promise + .dyn_into() + .map_err(|_| anyhow::anyhow!("eval_batch must return a Promise"))?; + let result = JsFuture::from(promise) + .await + .map_err(|e| anyhow::anyhow!("eval_batch rejected: {e:?}"))?; + split_outputs(&result, n).map_err(|e| anyhow::anyhow!("split_outputs: {e:?}")) + } + }; + + let report = |done: u32, total: u32| { + // Progress is best-effort; ignore a throwing JS progress callback. + let _ = progress.call2( + &JsValue::NULL, + &JsValue::from_f64(done as f64), + &JsValue::from_f64(total as f64), + ); + }; + + let (children, root_value) = + run_batched_search(&cfg, &bs, game.state(), game.mechanics(), eval, report) + .await + .map_err(|e| JsValue::from_str(&format!("{e:#}")))?; + + let action = best_action(&children); + let out = Object::new(); + Reflect::set( + &out, + &JsValue::from_str("action"), + &JsValue::from_f64(action as f64), + )?; + Reflect::set( + &out, + &JsValue::from_str("rootValue"), + &JsValue::from_f64(root_value as f64), + )?; + let arr = Array::new(); + for c in &children { + let o = Object::new(); + Reflect::set( + &o, + &JsValue::from_str("actionIndex"), + &JsValue::from_f64(c.action_index as f64), + )?; + Reflect::set( + &o, + &JsValue::from_str("visitCount"), + &JsValue::from_f64(c.visit_count as f64), + )?; + arr.push(&o); + } + Reflect::set(&out, &JsValue::from_str("children"), &arr)?; + Ok(out.into()) +} diff --git a/deep_quoridor/rust/quoridor-wasm/src/view.rs b/deep_quoridor/rust/quoridor-wasm/src/view.rs new file mode 100644 index 00000000..f2ef31e7 --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/src/view.rs @@ -0,0 +1,143 @@ +//! JSON-facing snapshot the JS client renders from. Ported from +//! quoridor-rs `play_server::state` (which is binary-gated and not on wasm). + +use serde::Serialize; + +use quoridor_rs::actions::{ + ACTION_MOVE, ACTION_WALL_HORIZONTAL, ACTION_WALL_VERTICAL, action_index_to_action, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WallOrientation { + H, + V, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum EnrichedAction { + Move { + index: u32, + to: [i32; 2], + }, + Wall { + index: u32, + row: i32, + col: i32, + orientation: WallOrientation, + }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WallEntry { + pub row: i32, + pub col: i32, + pub orientation: WallOrientation, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StateView { + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub current_player: i32, + pub p1_pos: [i32; 2], + pub p2_pos: [i32; 2], + pub p1_walls: i32, + pub p2_walls: i32, + pub walls: Vec, + pub legal_actions: Vec, + pub completed_steps: i32, + pub winner: Option, + pub human_player: i32, + pub last_action: Option, + pub move_history: Vec, +} + +pub fn enrich_action(board_size: i32, index: usize) -> EnrichedAction { + let [row, col, action_type] = action_index_to_action(board_size, index); + match action_type { + ACTION_WALL_VERTICAL => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::V, + }, + ACTION_WALL_HORIZONTAL => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::H, + }, + ACTION_MOVE => EnrichedAction::Move { + index: index as u32, + to: [row, col], + }, + other => panic!("unexpected action type {other} for index {index}"), + } +} + +pub fn enrich_legal_actions(board_size: i32, mask: &[bool]) -> Vec { + mask.iter() + .enumerate() + .filter(|&(_, legal)| *legal) + .map(|(i, _)| enrich_action(board_size, i)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enriched_action_serializes_with_kind_tag() { + let m = EnrichedAction::Move { + index: 3, + to: [4, 5], + }; + let s = serde_json::to_string(&m).unwrap(); + assert_eq!(s, r#"{"kind":"move","index":3,"to":[4,5]}"#); + + let w = EnrichedAction::Wall { + index: 17, + row: 3, + col: 2, + orientation: WallOrientation::H, + }; + let s = serde_json::to_string(&w).unwrap(); + assert_eq!( + s, + r#"{"kind":"wall","index":17,"row":3,"col":2,"orientation":"h"}"# + ); + } + + #[test] + fn enrich_first_vertical_then_first_horizontal_wall() { + let n: i32 = 5; + let nn = (n * n) as usize; + let walls = ((n - 1) * (n - 1)) as usize; + + let v = enrich_action(n, nn); + assert_eq!( + v, + EnrichedAction::Wall { + index: nn as u32, + row: 0, + col: 0, + orientation: WallOrientation::V + } + ); + + let h = enrich_action(n, nn + walls); + assert_eq!( + h, + EnrichedAction::Wall { + index: (nn + walls) as u32, + row: 0, + col: 0, + orientation: WallOrientation::H + } + ); + } +} diff --git a/deep_quoridor/rust/quoridor-wasm/tests/web.rs b/deep_quoridor/rust/quoridor-wasm/tests/web.rs new file mode 100644 index 00000000..e837f084 --- /dev/null +++ b/deep_quoridor/rust/quoridor-wasm/tests/web.rs @@ -0,0 +1,49 @@ +use wasm_bindgen::prelude::*; +use wasm_bindgen_test::*; + +// A JS mock eval that resolves to zero values + zero logits of width 512. +#[wasm_bindgen(inline_js = " +module.exports.makeMockEval = function() { + return function(flat, n, c, h, w) { + const values = new Float32Array(n); + const logits = new Float32Array(n * 512); + return Promise.resolve({ values, logits }); + }; +} +module.exports.makeProgress = function() { return function(_d, _t) {}; } +")] +extern "C" { + fn makeMockEval() -> js_sys::Function; + fn makeProgress() -> js_sys::Function; +} + +#[wasm_bindgen_test] +async fn run_search_returns_a_legal_action() { + quoridor_wasm::init(); + let game = quoridor_wasm::Game::new(5, 2, 50, 0); + let result = game + .run_search(32, 1.4, 8, 1, makeMockEval(), makeProgress()) + .await + .unwrap(); + + let action = js_sys::Reflect::get(&result, &JsValue::from_str("action")).unwrap(); + let action = action.as_f64().unwrap() as u32; + + // Cross-check against the game's own legal mask via stateView.legal_actions. + let view = game.state_view().unwrap(); + let legal = js_sys::Reflect::get(&view, &JsValue::from_str("legal_actions")).unwrap(); + let legal: js_sys::Array = legal.dyn_into().unwrap(); + let mut found = false; + for i in 0..legal.length() { + let a = legal.get(i); + let idx = js_sys::Reflect::get(&a, &JsValue::from_str("index")) + .unwrap() + .as_f64() + .unwrap() as u32; + if idx == action { + found = true; + break; + } + } + assert!(found, "runSearch action must be one of the legal actions"); +} diff --git a/deep_quoridor/rust/src/agents/alphazero/batched_search.rs b/deep_quoridor/rust/src/agents/alphazero/batched_search.rs new file mode 100644 index 00000000..4fb037f7 --- /dev/null +++ b/deep_quoridor/rust/src/agents/alphazero/batched_search.rs @@ -0,0 +1,360 @@ +//! Portable leaf-parallel batched MCTS driver. +//! +//! Reuses the `mcts` tree primitives but takes the neural-net forward pass as an +//! injected async callback (`eval_batch`), so the same driver runs natively (with +//! a synchronous mock) and in wasm (awaiting an onnxruntime-web call). It owns +//! `prepare_eval_input` (features + rotation) and `finalize_policy` (masked +//! softmax + un-rotation); the callback is a pure `features -> (value, logits)`. + +use std::collections::HashMap; +use std::future::Future; + +use anyhow::Result; +use ndarray::Array4; + +use crate::actions::action_index_to_action; +use crate::compact::q_bit_repr::CompactState; +use crate::compact::q_game_mechanics::QGameMechanics; + +use super::evaluator::{finalize_policy, prepare_eval_input}; +use super::mcts::{ + ChildInfo, MCTSConfig, NodeArena, backpropagate, backpropagate_result, expand_node, + select_leaf_with_vl, undo_virtual_loss, +}; + +/// Leaf-parallel batching knobs (play-mode subset of `LeafParallelConfig`). +#[derive(Debug, Clone, Copy)] +pub struct BatchedSearchConfig { + pub leaf_parallelism: u32, + pub virtual_loss: u32, +} + +impl Default for BatchedSearchConfig { + fn default() -> Self { + Self { + leaf_parallelism: 8, + virtual_loss: 1, + } + } +} + +/// Raw network outputs for one leaf. `policy_logits` is in the (possibly rotated) +/// work action space — the driver runs `finalize_policy` to get real priors. +#[derive(Clone, Debug)] +pub struct EvalOutput { + pub value: f32, + pub policy_logits: Vec, +} + +/// Argmax visit count, first max wins on ties (temperature-0 / deterministic). +pub fn best_action(children: &[ChildInfo]) -> usize { + children + .iter() + .max_by_key(|c| c.visit_count) + .map(|c| c.action_index) + .expect("best_action requires at least one child") +} + +/// Run `cfg.n` simulations of leaf-parallel batched MCTS from `root_data`. +/// +/// `eval_batch(features)` returns one `EvalOutput` per input, in order. +/// `progress(done, total)` is called after each backprop round. +/// Returns `(children, root_value)` where `children` carries per-move visit +/// counts (the play policy) and `root_value` is the negated mean root value. +/// +/// Note: this driver ignores `cfg.penalize_visited_states`; it always searches +/// with a clean (empty) visited set. Penalizing visited states is a self-play +/// concern; M1 play-mode leaves it false. +pub async fn run_batched_search( + cfg: &MCTSConfig, + bs_cfg: &BatchedSearchConfig, + root_data: CompactState, + mechanics: &QGameMechanics, + mut eval_batch: E, + mut progress: P, +) -> Result<(Vec, f32)> +where + E: FnMut(Vec>) -> EFut, + EFut: Future>>, + P: FnMut(u32, u32), +{ + let mut arena = NodeArena::new(root_data); + let mut rotation_mappings: HashMap, Vec)> = HashMap::new(); + let visited = std::collections::HashSet::new(); + + let root_mask = mechanics.get_action_mask_immut(root_data); + let num_valid = root_mask.iter().filter(|&&b| b).count() as u32; + let total = cfg + .n + .unwrap_or_else(|| cfg.k.unwrap_or(1) * num_valid) + .max(1); + let k = bs_cfg.leaf_parallelism.max(1); + let vl = bs_cfg.virtual_loss; + + // Pre-expand the root with a single eval so the first parallel round descends + // into real children instead of all `k` leaves colliding on the unexpanded + // root (which would call expand_node k times and duplicate every child). + // Mirrors the root handling in `selfplay_mcts`. + if !mechanics.is_game_over(root_data) && arena.get(0).should_expand() { + let prep = prepare_eval_input(mechanics, root_data, &root_mask, &mut rotation_mappings); + let out = eval_batch(vec![prep.features]) + .await? + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("eval_batch returned no output for root"))?; + let priors = finalize_policy( + &out.policy_logits, + &prep.work_action_mask, + prep.rot_to_orig.as_deref(), + ); + expand_node(&mut arena, 0, &priors, mechanics); + } + + let mut done: u32 = 0; + while done < total { + let outer = (total - done).min(k); + + // Per-leaf bookkeeping for this round. + struct Pending { + path: smallvec::SmallVec<[usize; 32]>, + leaf_idx: usize, + work_action_mask: Vec, + rot_to_orig: Option>, + } + enum Item { + Terminal { + path: smallvec::SmallVec<[usize; 32]>, + value: f64, + }, + Eval(Pending), + } + + let mut items: Vec = Vec::with_capacity(outer as usize); + let mut features_batch: Vec> = Vec::new(); + + for _ in 0..outer { + let path = select_leaf_with_vl(&mut arena, 0, cfg.ucb_c, vl, &visited); + let leaf_idx = *path.last().unwrap(); + let leaf_data = arena.get(leaf_idx).data; + + if mechanics.is_game_over(leaf_data) { + let v = if mechanics.winner(leaf_data).is_some() { + 1.0 + } else { + 0.0 + }; + items.push(Item::Terminal { path, value: v }); + continue; + } + if let Some(max) = cfg.max_steps { + if mechanics.repr().get_completed_steps(leaf_data) >= max as usize { + items.push(Item::Terminal { path, value: 0.0 }); + continue; + } + } + + let leaf_mask = mechanics.get_action_mask_immut(leaf_data); + let prep = prepare_eval_input(mechanics, leaf_data, &leaf_mask, &mut rotation_mappings); + features_batch.push(prep.features); + items.push(Item::Eval(Pending { + path, + leaf_idx, + work_action_mask: prep.work_action_mask, + rot_to_orig: prep.rot_to_orig, + })); + } + + let outputs = if features_batch.is_empty() { + Vec::new() + } else { + eval_batch(features_batch).await? + }; + + let mut out_iter = outputs.into_iter(); + for item in items { + match item { + Item::Terminal { path, value } => { + undo_virtual_loss(&mut arena, &path, vl); + let leaf = *path.last().unwrap(); + backpropagate_result(&mut arena, leaf, value); + } + Item::Eval(p) => { + let out = out_iter + .next() + .ok_or_else(|| anyhow::anyhow!("eval_batch returned too few outputs"))?; + let priors = finalize_policy( + &out.policy_logits, + &p.work_action_mask, + p.rot_to_orig.as_deref(), + ); + undo_virtual_loss(&mut arena, &p.path, vl); + // Guard against intra-round leaf collisions: two leaves selected + // in the same round can be the same not-yet-expanded node. Expand + // it only once; every colliding leaf still backpropagates its value. + if arena.get(p.leaf_idx).should_expand() { + expand_node(&mut arena, p.leaf_idx, &priors, mechanics); + } + backpropagate(&mut arena, p.leaf_idx, -out.value as f64); + } + } + done += 1; + if done >= total { + break; + } + } + progress(done, total); + } + + // Build the child (visit-count) policy and root value. + let bs = mechanics.repr().board_size() as i32; + let root = arena.get(0); + let mut children = Vec::with_capacity(root.children.len()); + for &ci in &root.children { + let c = arena.get(ci); + let ai = c.action_index.expect("child has an action index"); + children.push(ChildInfo { + action: action_index_to_action(bs, ai), + action_index: ai, + visit_count: c.visit_count, + }); + } + let root_value = if root.visit_count > 0 { + -(root.value_sum / root.visit_count as f64) as f32 + } else { + 0.0 + }; + Ok((children, root_value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compact::q_game_mechanics::QGameMechanics; + use std::cell::Cell; + use std::collections::HashSet; + use std::future::Future; + use std::pin::Pin; + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + /// Minimal dependency-free executor for driving an immediately-ready future. + fn block_on(mut fut: F) -> F::Output { + fn noop(_: *const ()) {} + fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop); + let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }; + let mut cx = Context::from_waker(&waker); + // Safety: we never move `fut` after pinning it here. + let mut fut = unsafe { Pin::new_unchecked(&mut fut) }; + loop { + if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { + return v; + } + } + } + + /// Uniform mock: value 0, all-zero logits (→ uniform priors over legal moves). + fn mock_eval(batch: Vec>) -> impl Future>> { + // policy width = channels are irrelevant; we return zero logits sized to + // the policy space. The driver's finalize_policy masks illegal actions, + // and zero logits over the legal set give a uniform prior regardless of + // vector length as long as it covers the action space. We size it to a + // safe upper bound from the feature grid. + let out: Vec = batch + .iter() + .map(|_| EvalOutput { + value: 0.0, + policy_logits: vec![0.0f32; 512], + }) + .collect(); + std::future::ready(Ok(out)) + } + + #[test] + fn search_returns_a_legal_move_and_counts_sum_to_n() { + let mechanics = QGameMechanics::new(5, 2, 50); + let root = mechanics.create_initial_state(); + let cfg = MCTSConfig { + n: Some(64), + noise_epsilon: 0.0, + ..MCTSConfig::default() + }; + let bs = BatchedSearchConfig { + leaf_parallelism: 8, + virtual_loss: 1, + }; + + let progress_calls = Cell::new(0u32); + let last = Cell::new((0u32, 0u32)); + let (children, _root_value) = block_on(run_batched_search( + &cfg, + &bs, + root, + &mechanics, + mock_eval, + |d, t| { + progress_calls.set(progress_calls.get() + 1); + last.set((d, t)); + }, + )) + .unwrap(); + + assert!(!children.is_empty(), "root must have expanded children"); + let mask = mechanics.get_action_mask_immut(root); + let chosen = best_action(&children); + assert!(mask[chosen], "chosen action must be legal"); + + // Each legal action appears exactly once — duplicate action_index values + // are the signature of the leaf-collision / double-expand bug. + let unique: HashSet = children.iter().map(|c| c.action_index).collect(); + assert_eq!( + unique.len(), + children.len(), + "children must have distinct actions" + ); + + // With the root pre-expanded, essentially every simulation lands a visit + // on a child; allow one batch of slack for in-flight rounds. + let total_visits: u32 = children.iter().map(|c| c.visit_count).sum(); + assert!( + total_visits >= 64 - 8, + "child visit sum ({total_visits}) should be ~n=64" + ); + + assert!(progress_calls.get() >= 1, "progress fired at least once"); + assert_eq!(last.get(), (64, 64), "progress ends at (n, n)"); + } + + #[test] + fn search_is_deterministic_without_noise() { + let mechanics = QGameMechanics::new(5, 2, 50); + let root = mechanics.create_initial_state(); + let cfg = MCTSConfig { + n: Some(48), + noise_epsilon: 0.0, + ..MCTSConfig::default() + }; + let bs = BatchedSearchConfig { + leaf_parallelism: 4, + virtual_loss: 1, + }; + + let run = || { + block_on(run_batched_search( + &cfg, + &bs, + root, + &mechanics, + mock_eval, + |_, _| {}, + )) + .unwrap() + .0 + .iter() + .map(|c| (c.action_index, c.visit_count)) + .collect::>() + }; + assert_eq!(run(), run(), "no-noise search must be reproducible"); + } +} diff --git a/deep_quoridor/rust/src/agents/alphazero/evaluator.rs b/deep_quoridor/rust/src/agents/alphazero/evaluator.rs index 4d3d3db4..95a6eede 100644 --- a/deep_quoridor/rust/src/agents/alphazero/evaluator.rs +++ b/deep_quoridor/rust/src/agents/alphazero/evaluator.rs @@ -2,11 +2,13 @@ use std::collections::HashMap; -use anyhow::{Context, Result}; +#[cfg(feature = "binary")] +use anyhow::Context; +use anyhow::Result; use ndarray::Array4; +#[cfg(feature = "binary")] use ort::session::Session; -use crate::agents::onnx_agent::softmax; use crate::compact::q_bit_repr::CompactState; use crate::compact::q_game_mechanics::QGameMechanics; use crate::grid_helpers::compact_state_to_resnet_input; @@ -86,6 +88,7 @@ pub fn finalize_policy( /// /// Loads a neural network model and uses it to evaluate positions, /// returning both a value estimate and policy priors. +#[cfg(feature = "binary")] pub struct OnnxEvaluator { session: Session, rotation_mappings_by_board_size: HashMap, Vec)>, @@ -96,6 +99,7 @@ pub struct OnnxEvaluator { /// Returns value=0.0 and a uniform prior over valid actions. pub struct UniformMockEvaluator; +#[cfg(feature = "binary")] impl OnnxEvaluator { /// Create a new evaluator from an ONNX model file. pub fn new(model_path: &str) -> Result { @@ -128,6 +132,7 @@ impl OnnxEvaluator { } } +#[cfg(feature = "binary")] impl Evaluator for OnnxEvaluator { fn evaluate( &mut self, @@ -208,6 +213,14 @@ pub fn masked_softmax(logits: &[f32], mask: &[bool]) -> Vec { softmax(&masked) } +/// Numerically-stable softmax over a slice. +pub fn softmax(logits: &[f32]) -> Vec { + let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + let exp_values: Vec = logits.iter().map(|&x| (x - max).exp()).collect(); + let sum: f32 = exp_values.iter().sum(); + exp_values.iter().map(|&x| x / sum).collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/deep_quoridor/rust/src/agents/alphazero/mod.rs b/deep_quoridor/rust/src/agents/alphazero/mod.rs index 27e6fbae..5c60fa90 100644 --- a/deep_quoridor/rust/src/agents/alphazero/mod.rs +++ b/deep_quoridor/rust/src/agents/alphazero/mod.rs @@ -3,12 +3,20 @@ //! This module provides MCTS-based action selection using neural network evaluation. //! Only available behind the `binary` feature flag. -pub mod eval_pipeline; +pub mod batched_search; pub mod evaluator; pub mod mcts; + +#[cfg(feature = "binary")] +pub mod agent; +#[cfg(feature = "binary")] +pub mod eval_pipeline; +#[cfg(feature = "binary")] pub mod selfplay_game; +#[cfg(feature = "binary")] pub mod selfplay_mcts; +#[cfg(feature = "binary")] pub mod selfplay_metrics; -pub mod agent; +#[cfg(feature = "binary")] pub use agent::{AlphaZeroAgent, AlphaZeroAgentConfig}; diff --git a/deep_quoridor/rust/src/agents/mod.rs b/deep_quoridor/rust/src/agents/mod.rs index 6e6db4f4..b557abde 100644 --- a/deep_quoridor/rust/src/agents/mod.rs +++ b/deep_quoridor/rust/src/agents/mod.rs @@ -9,7 +9,6 @@ use crate::compact::q_game_mechanics::QGameMechanics; pub mod onnx_agent; pub mod random_agent; -#[cfg(feature = "binary")] pub mod alphazero; #[derive(Debug, Clone, Default)] diff --git a/deep_quoridor/rust/src/agents/onnx_agent.rs b/deep_quoridor/rust/src/agents/onnx_agent.rs index 705c6720..28478996 100644 --- a/deep_quoridor/rust/src/agents/onnx_agent.rs +++ b/deep_quoridor/rust/src/agents/onnx_agent.rs @@ -10,13 +10,7 @@ use crate::compact::q_bit_repr::CompactState; use crate::compact::q_game_mechanics::QGameMechanics; use crate::grid_helpers::compact_state_to_resnet_input; -/// Compute softmax of a slice of logits. -pub fn softmax(logits: &[f32]) -> Vec { - let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max); - let exp_values: Vec = logits.iter().map(|&x| (x - max).exp()).collect(); - let sum: f32 = exp_values.iter().sum(); - exp_values.iter().map(|&x| x / sum).collect() -} +pub use crate::agents::alphazero::evaluator::softmax; /// An agent that uses an ONNX model to select actions (greedy argmax). pub struct OnnxAgent { diff --git a/deep_quoridor/rust/src/compact/mod.rs b/deep_quoridor/rust/src/compact/mod.rs index ae504a5a..859f0aa6 100644 --- a/deep_quoridor/rust/src/compact/mod.rs +++ b/deep_quoridor/rust/src/compact/mod.rs @@ -1,6 +1,8 @@ /// The algorithms in this directory make use of the QBitRepr compact state representation +#[cfg(feature = "policy_db")] pub mod policy_db; pub mod q_bit_repr; mod q_bit_repr_conversions; pub mod q_game_mechanics; +#[cfg(feature = "parallel")] pub mod q_minimax; diff --git a/deep_quoridor/rust/src/lib.rs b/deep_quoridor/rust/src/lib.rs index 5a3855a2..fbf88d18 100644 --- a/deep_quoridor/rust/src/lib.rs +++ b/deep_quoridor/rust/src/lib.rs @@ -19,6 +19,7 @@ pub mod compact; pub mod game_state; pub mod grid; pub mod grid_helpers; +#[cfg(feature = "parallel")] mod minimax; mod pathfinding; pub mod rotation; diff --git a/deep_quoridor/src/run_play_server_web.py b/deep_quoridor/src/run_play_server_web.py new file mode 100644 index 00000000..9d9f1eeb --- /dev/null +++ b/deep_quoridor/src/run_play_server_web.py @@ -0,0 +1,48 @@ +"""Serve the Quoridor browser play app + model files + config API. + +Read-only over a play directory: a directory containing `config.yaml` and a +`models/` subdirectory of `.onnx` files. Every model uses the settings in +`config.yaml`.""" + +import argparse +from pathlib import Path + +import uvicorn + +from v2.play_server_web.app import create_app + + +def main(args) -> int: + play_dir = Path(args.play_dir).resolve() + if not (play_dir / "config.yaml").is_file(): + print(f"error: {play_dir / 'config.yaml'} not found") + return 1 + static_dir = Path(args.static_dir).resolve() if args.static_dir else None + models_dir = Path(args.models_dir).resolve() if args.models_dir else None + app = create_app(play_dir, static_dir=static_dir, models_dir=models_dir) + uvicorn.run(app, host=args.host, port=args.port) + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Serve the Quoridor browser play app + model files + config API." + ) + parser.add_argument( + "play_dir", + type=str, + help="Directory containing config.yaml and a models/ dir of .onnx files.", + ) + parser.add_argument( + "--static-dir", type=str, default=None, help="Directory of the built SPA (frontend build output)." + ) + parser.add_argument( + "--models-dir", + type=str, + default=None, + help="Override the .onnx models dir (default: /models).", + ) + parser.add_argument("--host", type=str, default="127.0.0.1") + parser.add_argument("--port", type=int, default=8080) + args = parser.parse_args() + raise SystemExit(main(args)) diff --git a/deep_quoridor/src/v2/play_server_web/README.md b/deep_quoridor/src/v2/play_server_web/README.md new file mode 100644 index 00000000..bb0d4e6a --- /dev/null +++ b/deep_quoridor/src/v2/play_server_web/README.md @@ -0,0 +1,50 @@ +# play_server_web + +Thin FastAPI server for the browser Quoridor play app. Serves the built SPA + its +`.wasm` and the trained model `.onnx` files (with cross-origin isolation + wasm +MIME), and exposes a small config/model API. The AI runs client-side (see +`rust/quoridor-wasm`). Design: `docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md`. + +## Play directory layout + +Point the server at a **play directory**: + +``` +/ + config.yaml # board size + AlphaZero/MCTS default settings + models/ + model_a.onnx # one or more models; all use config.yaml's settings + model_b.onnx +``` + +`config.yaml` provides the settings for **every** model in `models/` — the UI just +picks which `.onnx` to run. (`config.yaml` is the same schema `v2.config` uses, so +you can copy one from a training run.) + +## Run + +``` +pip install fastapi uvicorn +PYTHONPATH=deep_quoridor/src python deep_quoridor/src/run_play_server_web.py \ + /path/to/play-dir --static-dir /path/to/spa/dist --port 8080 +``` +- `play_dir` (positional): a directory with `config.yaml` and a `models/` dir of `.onnx` files. +- `--static-dir`: the built SPA. Omit to serve a placeholder page. +- `--models-dir`: override where `.onnx` files are read from (default `/models`). +- `--host` / `--port`: bind address (default `127.0.0.1:8080`). + +## API +- `GET /api/config` → `{ board_size, max_walls, max_steps, defaults: { mcts_n, mcts_c_puct, temperature, mcts_noise_epsilon, mcts_noise_alpha, leaf_parallelism, virtual_loss, mcts_worker_threads } }` +- `GET /api/models` → `{ models: ["model_1.onnx", ...], default: "model_N.onnx" }` +- `GET /models/.onnx` → the model file (for onnxruntime-web to fetch). +- `GET /` and other paths → the SPA (or a placeholder until Plan 3 is built). + +All responses carry `Cross-Origin-Opener-Policy: same-origin` + +`Cross-Origin-Embedder-Policy: require-corp`. **Plan 3 must bundle onnxruntime-web +same-origin** — COEP `require-corp` blocks non-CORP cross-origin resources (so a +CDN ` + + +``` + +`deep_quoridor/frontend/src/app.css`: +```css +:root { font-family: system-ui, sans-serif; } +body { margin: 0; padding: 1rem; } +``` + +- [ ] **Step 5: Create a placeholder `src/App.svelte`, `src/main.ts`, and a smoke test** + +`deep_quoridor/frontend/src/App.svelte`: +```svelte + + +
+

Quoridor

+

{ready ? "ready" : "scaffold OK"}

+
+``` + +`deep_quoridor/frontend/src/main.ts`: +```ts +import "./app.css"; +import { mount } from "svelte"; +import App from "./App.svelte"; + +const app = mount(App, { target: document.getElementById("app")! }); +export default app; +``` + +`deep_quoridor/frontend/src/lib/smoke.test.ts`: +```ts +import { describe, expect, it } from "vitest"; + +describe("scaffold", () => { + it("runs vitest", () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 6: Install, test, and build** + +Run: +```bash +npm --prefix deep_quoridor/frontend install 2>&1 | tail -8 +npm --prefix deep_quoridor/frontend run test 2>&1 | tail -8 +npm --prefix deep_quoridor/frontend run build 2>&1 | tail -12 +``` +Expected: install succeeds; vitest passes (1 test); `vite build` emits `deep_quoridor/frontend/dist/`. If `npm install` fails an `engines` check for node 18, pin the offending dep to a node-18-compatible major and retry (record what you pinned). + +- [ ] **Step 7: Commit** + +```bash +git -C deep_quoridor add frontend/package.json frontend/vite.config.ts frontend/tsconfig.json frontend/svelte.config.js frontend/index.html frontend/.gitignore frontend/src/main.ts frontend/src/App.svelte frontend/src/app.css frontend/src/lib/smoke.test.ts +git -C deep_quoridor commit -m "feat(frontend): scaffold Vite+Svelte app (builds, vitest, ORT wasm copy)" +``` +(Do NOT commit `frontend/node_modules` or `frontend/dist` — gitignored. `package-lock.json` may be added if present.) + +--- + +## Task 2: Pure logic — board model + eval marshalling (TDD) + +**Files:** create `src/lib/types.ts`, `src/lib/board.ts`, `src/lib/board.test.ts`, `src/lib/evalRunner.ts`, `src/lib/evalRunner.test.ts`. + +- [ ] **Step 1: Create `src/lib/types.ts`** + +```ts +export type Orientation = "h" | "v"; + +export type EnrichedAction = + | { kind: "move"; index: number; to: [number, number] } + | { kind: "wall"; index: number; row: number; col: number; orientation: Orientation }; + +export interface WallEntry { row: number; col: number; orientation: Orientation } + +export interface StateView { + board_size: number; + max_walls: number; + max_steps: number; + current_player: number; + p1_pos: [number, number]; + p2_pos: [number, number]; + p1_walls: number; + p2_walls: number; + walls: WallEntry[]; + legal_actions: EnrichedAction[]; + completed_steps: number; + winner: number | null; + human_player: number; + last_action: EnrichedAction | null; + move_history: number[]; +} + +export interface SearchResult { + action: number; + rootValue: number; + children: { actionIndex: number; visitCount: number }[]; +} +``` + +- [ ] **Step 2: Write `src/lib/board.test.ts` (failing)** + +```ts +import { describe, expect, it } from "vitest"; +import { deriveBoard } from "./board"; +import type { StateView } from "./types"; + +function baseView(over: Partial = {}): StateView { + return { + board_size: 5, max_walls: 2, max_steps: 50, current_player: 0, + p1_pos: [4, 2], p2_pos: [0, 2], p1_walls: 2, p2_walls: 2, + walls: [], legal_actions: [], completed_steps: 0, winner: null, + human_player: 0, last_action: null, move_history: [], + ...over, + }; +} + +describe("deriveBoard", () => { + it("maps legal move destinations to their action index", () => { + const view = baseView({ + legal_actions: [ + { kind: "move", index: 7, to: [3, 2] }, + { kind: "wall", index: 30, row: 1, col: 1, orientation: "v" }, + ], + }); + const b = deriveBoard(view); + expect(b.moveActionAt(3, 2)).toBe(7); + expect(b.moveActionAt(0, 0)).toBeUndefined(); + expect(b.wallActionAt(1, 1, "v")).toBe(30); + }); + + it("exposes pawn positions and placed walls", () => { + const view = baseView({ walls: [{ row: 2, col: 2, orientation: "h" }] }); + const b = deriveBoard(view); + expect(b.pawns[0]).toEqual([4, 2]); + expect(b.pawns[1]).toEqual([0, 2]); + expect(b.walls).toHaveLength(1); + expect(b.size).toBe(5); + }); +}); +``` + +- [ ] **Step 3: Implement `src/lib/board.ts`** + +```ts +import type { Orientation, StateView, WallEntry } from "./types"; + +export interface BoardModel { + size: number; + pawns: [[number, number], [number, number]]; + walls: WallEntry[]; + /** Action index for the legal move landing on (r,c), or undefined. */ + moveActionAt(r: number, c: number): number | undefined; + /** Action index for the legal wall at (r,c,orientation), or undefined. */ + wallActionAt(r: number, c: number, o: Orientation): number | undefined; +} + +export function deriveBoard(view: StateView): BoardModel { + const moves = new Map(); + const walls = new Map(); + for (const a of view.legal_actions) { + if (a.kind === "move") moves.set(`${a.to[0]},${a.to[1]}`, a.index); + else walls.set(`${a.row},${a.col},${a.orientation}`, a.index); + } + return { + size: view.board_size, + pawns: [view.p1_pos, view.p2_pos], + walls: view.walls, + moveActionAt: (r, c) => moves.get(`${r},${c}`), + wallActionAt: (r, c, o) => walls.get(`${r},${c},${o}`), + }; +} +``` + +- [ ] **Step 4: Write `src/lib/evalRunner.test.ts` (failing)** + +```ts +import { describe, expect, it } from "vitest"; +import { runEval } from "./evalRunner"; + +// A fake ORT-like session: records the input tensor and returns fixed outputs. +function fakeSession(n: number, policy: number) { + return { + lastInput: null as any, + async run(feeds: any) { + this.lastInput = feeds.input; + return { + value: { data: Float32Array.from({ length: n }, (_, i) => i * 0.1) }, + policy_logits: { data: new Float32Array(n * policy) }, + }; + }, + }; +} + +// Minimal Tensor stand-in so runEval doesn't need the real ort in unit tests. +class FakeTensor { + constructor(public type: string, public data: Float32Array, public dims: number[]) {} +} + +describe("runEval", () => { + it("builds an [n,c,h,w] input tensor and splits value/logits", async () => { + const n = 2, c = 5, h = 13, w = 13, policy = 57; + const session = fakeSession(n, policy); + const flat = new Float32Array(n * c * h * w); + const out = await runEval(session as any, FakeTensor as any, flat, n, c, h, w); + + expect(session.lastInput.dims).toEqual([n, c, h, w]); + expect(session.lastInput.data).toBe(flat); + expect(out.values).toHaveLength(n); + expect(out.values[1]).toBeCloseTo(0.1); + expect(out.logits).toHaveLength(n * policy); + }); +}); +``` + +- [ ] **Step 5: Implement `src/lib/evalRunner.ts`** + +```ts +// Pure marshalling between quoridor-wasm's eval_batch(flat, n, c, h, w) contract +// and an onnxruntime-web InferenceSession. Kept free of a hard `ort` import so it +// is unit-testable with a fake session + tensor. + +export interface OrtLikeSession { + run(feeds: Record): Promise>; +} +export type TensorCtor = new (type: "float32", data: Float32Array, dims: number[]) => unknown; + +export async function runEval( + session: OrtLikeSession, + Tensor: TensorCtor, + flat: Float32Array, + n: number, + c: number, + h: number, + w: number, +): Promise<{ values: Float32Array; logits: Float32Array }> { + const input = new Tensor("float32", flat, [n, c, h, w]); + const out = await session.run({ input }); + return { + values: out.value.data, + logits: out.policy_logits.data, + }; +} +``` + +- [ ] **Step 6: Run the tests** + +```bash +npm --prefix deep_quoridor/frontend run test 2>&1 | tail -12 +``` +Expected: all pass (smoke + board 2 + evalRunner 1). + +- [ ] **Step 7: Commit** + +```bash +git -C deep_quoridor add frontend/src/lib/types.ts frontend/src/lib/board.ts frontend/src/lib/board.test.ts frontend/src/lib/evalRunner.ts frontend/src/lib/evalRunner.test.ts +git -C deep_quoridor commit -m "feat(frontend): pure board model + eval marshalling with vitest" +``` + +--- + +## Task 3: API client (TDD) + +**Files:** create `src/lib/api.ts`, `src/lib/api.test.ts`. + +- [ ] **Step 1: Write `src/lib/api.test.ts` (failing)** + +```ts +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchConfig, fetchModels } from "./api"; + +afterEach(() => vi.unstubAllGlobals()); + +function stubFetch(body: unknown) { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => body }))); +} + +describe("api", () => { + it("fetchConfig returns the parsed config", async () => { + stubFetch({ board_size: 5, max_walls: 2, max_steps: 50, defaults: { mcts_n: 100 } }); + const cfg = await fetchConfig(); + expect(cfg.board_size).toBe(5); + expect(cfg.defaults.mcts_n).toBe(100); + }); + + it("fetchModels returns models + default", async () => { + stubFetch({ models: ["model_1.onnx", "model_2.onnx"], default: "model_2.onnx" }); + const m = await fetchModels(); + expect(m.models).toEqual(["model_1.onnx", "model_2.onnx"]); + expect(m.default).toBe("model_2.onnx"); + }); +}); +``` + +- [ ] **Step 2: Implement `src/lib/api.ts`** + +```ts +export interface ConfigView { + board_size: number; + max_walls: number; + max_steps: number; + defaults: { + mcts_n: number; + mcts_c_puct: number; + temperature: number | null; + mcts_noise_epsilon: number; + mcts_noise_alpha: number | null; + leaf_parallelism: number; + virtual_loss: number; + mcts_worker_threads: number | null; + }; +} +export interface ModelsView { models: string[]; default: string | null } + +async function getJson(url: string): Promise { + const r = await fetch(url); + if (!r.ok) throw new Error(`${url} -> ${r.status}`); + return (await r.json()) as T; +} + +export const fetchConfig = () => getJson("/api/config"); +export const fetchModels = () => getJson("/api/models"); +``` + +- [ ] **Step 3: Run tests, then commit** + +```bash +npm --prefix deep_quoridor/frontend run test 2>&1 | tail -8 +git -C deep_quoridor add frontend/src/lib/api.ts frontend/src/lib/api.test.ts +git -C deep_quoridor commit -m "feat(frontend): API client for /api/config and /api/models" +``` +Expected: tests pass. + +--- + +## Task 4: The AI Web Worker + main-thread client + +**Files:** create `src/ai.worker.ts`, `src/lib/aiClient.ts`. (Integration glue — verified by build + the end-to-end run in Task 6; the marshalling it relies on is already unit-tested in Task 2.) + +- [ ] **Step 1: Create `src/ai.worker.ts`** + +```ts +/// +import initWasm, { Game, init as installPanicHook } from "quoridor-wasm"; +import * as ort from "onnxruntime-web/webgpu"; +import { runEval } from "./lib/evalRunner"; +import type { StateView } from "./lib/types"; + +// Serve ORT's wasm/mjs from our own origin (copied there by vite-plugin-static-copy). +ort.env.wasm.wasmPaths = "/ort/"; + +type Params = { + mctsN: number; cPuct: number; leafParallelism: number; virtualLoss: number; +}; + +let game: Game | null = null; +let session: ort.InferenceSession | null = null; +let params: Params = { mctsN: 200, cPuct: 1.4, leafParallelism: 8, virtualLoss: 1 }; +let wasmReady: Promise | null = null; + +function ensureWasm(): Promise { + if (!wasmReady) wasmReady = initWasm().then(() => installPanicHook()); + return wasmReady; +} + +async function loadSession(model: string) { + session = await ort.InferenceSession.create(`/models/${model}`, { + executionProviders: ["webgpu", "wasm"], + }); +} + +function post(msg: unknown) { (self as unknown as Worker).postMessage(msg); } + +async function evalBatch(flat: Float32Array, n: number, c: number, h: number, w: number) { + if (!session) throw new Error("no model session"); + return runEval(session, ort.Tensor as never, flat, n, c, h, w); +} + +function progress(done: number, total: number) { + post({ type: "progress", done, total }); +} + +async function aiMoveIfNeeded(view: StateView): Promise { + if (view.winner !== null || view.current_player === view.human_player) return view; + const res = await game!.runSearch( + params.mctsN, params.cPuct, params.leafParallelism, params.virtualLoss, + evalBatch as unknown as Function, progress as unknown as Function, + ); + return game!.applyAction(res.action) as StateView; +} + +self.onmessage = async (e: MessageEvent) => { + const m = e.data; + try { + if (m.type === "newGame") { + await ensureWasm(); + params = m.params; + await loadSession(m.model); + game = new Game(m.boardSize, m.maxWalls, m.maxSteps, m.humanPlayer); + let view = game.stateView() as StateView; + view = await aiMoveIfNeeded(view); // AI opens if human is player 2 + post({ type: "state", view }); + } else if (m.type === "move") { + let view = game!.applyAction(m.index) as StateView; + post({ type: "state", view }); // show the human move immediately + view = await aiMoveIfNeeded(view); + post({ type: "state", view }); + } else if (m.type === "undo") { + const view = game!.undo(m.count) as StateView; + post({ type: "state", view }); + } else if (m.type === "setParams") { + params = m.params; + } + } catch (err) { + post({ type: "error", message: String(err) }); + } +}; +``` + +- [ ] **Step 2: Create `src/lib/aiClient.ts` (main-thread wrapper)** + +```ts +import type { StateView } from "./types"; + +type Params = { mctsN: number; cPuct: number; leafParallelism: number; virtualLoss: number }; + +export class AiClient { + private worker: Worker; + onState?: (v: StateView) => void; + onProgress?: (done: number, total: number) => void; + onError?: (msg: string) => void; + + constructor() { + this.worker = new Worker(new URL("../ai.worker.ts", import.meta.url), { type: "module" }); + this.worker.onmessage = (e: MessageEvent) => { + const m = e.data; + if (m.type === "state") this.onState?.(m.view); + else if (m.type === "progress") this.onProgress?.(m.done, m.total); + else if (m.type === "error") this.onError?.(m.message); + }; + } + + newGame(o: { + model: string; boardSize: number; maxWalls: number; maxSteps: number; + humanPlayer: number; params: Params; + }) { this.worker.postMessage({ type: "newGame", ...o }); } + + move(index: number) { this.worker.postMessage({ type: "move", index }); } + undo(count: number) { this.worker.postMessage({ type: "undo", count }); } + setParams(params: Params) { this.worker.postMessage({ type: "setParams", params }); } +} +``` + +- [ ] **Step 3: Type-check + build (no new unit tests here — glue is covered by Task 6's run)** + +```bash +npm --prefix deep_quoridor/frontend run build 2>&1 | tail -15 +``` +Expected: `vite build` succeeds (bundles the worker + wasm + ORT). If the worker import of `quoridor-wasm` fails to resolve, confirm Task 1 built `pkg/` and that `package.json`'s `file:../rust/quoridor-wasm/pkg` dependency installed (re-run `npm install`). If Vite errors on the `?worker`/`new URL` worker, ensure `worker.format: "es"` is set (Task 1 vite.config). + +- [ ] **Step 4: Commit** + +```bash +git -C deep_quoridor add frontend/src/ai.worker.ts frontend/src/lib/aiClient.ts +git -C deep_quoridor commit -m "feat(frontend): AI web worker (wasm+ORT) and main-thread client" +``` + +--- + +## Task 5: Svelte UI — board, control rail, config drawer, app shell + +**Files:** create `src/lib/Board.svelte`, `src/lib/ControlRail.svelte`, `src/lib/ConfigDrawer.svelte`; rewrite `src/App.svelte`. + +- [ ] **Step 1: Create `src/lib/Board.svelte`** + +```svelte + + +
+ {#each cells as row} + {#each row as { r, c }} + {@const move = board.moveActionAt(r, c)} + {@const pawn = pawnAt(r, c)} + + {/each} + {/each} +
+ +
+ {#each view.legal_actions.filter((a) => a.kind === "wall") as a} + + {/each} +
+ + +``` + +- [ ] **Step 2: Create `src/lib/ControlRail.svelte`** + +```svelte + + +
+ {#if thinking} +
+ AI thinking… +
+ {progress?.done ?? 0} / {progress?.total ?? 0} sims +
+ {/if} + {#if view?.winner != null} +
{view.winner === view.human_player ? "You win!" : "AI wins"}
+ {/if} + + + {#if view} +
+ Walls — You {view.human_player === 0 ? view.p1_walls : view.p2_walls} + · AI {view.human_player === 0 ? view.p2_walls : view.p1_walls} +
+
Moves: {view.move_history.length}
+ {/if} +
+ + +``` + +- [ ] **Step 3: Create `src/lib/ConfigDrawer.svelte`** + +```svelte + + +
+

Setup

+ + + + +
+ + +``` + +- [ ] **Step 4: Rewrite `src/App.svelte` wiring it all together** + +```svelte + + +
+
+ {#if error}

Error: {error}

{/if} + {#if view} + + {:else} +

Loading…

+ {/if} +
+ ai.undo(1)} onnewgame={newGame} /> + { model = o.model; params = o.params; ai.setParams(o.params); }} /> +
+ + +``` + +- [ ] **Step 5: Type-check + build** + +```bash +npm --prefix deep_quoridor/frontend run build 2>&1 | tail -15 +``` +Expected: `vite build` succeeds and emits `dist/`. Fix any Svelte 5 `$props`/`$state` rune or TS errors it reports. + +- [ ] **Step 6: Commit** + +```bash +git -C deep_quoridor add frontend/src/App.svelte frontend/src/lib/Board.svelte frontend/src/lib/ControlRail.svelte frontend/src/lib/ConfigDrawer.svelte +git -C deep_quoridor commit -m "feat(frontend): board, control rail, config drawer, app shell" +``` + +--- + +## Task 6: End-to-end wiring + run instructions + +**Files:** none new (integration + docs go in Task 7's README). This task proves the whole stack serves and the API contract lines up. + +- [ ] **Step 1: Build both artifacts** + +```bash + wasm-pack build rust/quoridor-wasm --target web --release 2>&1 | tail -3 +npm --prefix deep_quoridor/frontend run build 2>&1 | tail -6 +``` +Expected: `frontend/dist/` contains `index.html`, hashed JS/CSS, the worker chunk, `quoridor_wasm_bg.wasm`, and an `ort/` dir with onnxruntime `.wasm`/`.mjs`. Verify: +```bash +ls deep_quoridor/frontend/dist/ && ls deep_quoridor/frontend/dist/ort/ | head +``` + +- [ ] **Step 2: Prepare a run directory the server can read (config + a model)** + +The server needs a `config.yaml` (5×5, to match the fixture model) and a `models/checkpoints/*.onnx`. Build a scratch run dir pointing the models dir at the fixture: +```bash +RUN=$(mktemp -d) +printf 'run_id: play\nquoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\nalphazero:\n mcts_n: 200\n mcts_c_puct: 1.4\nself_play:\n num_processes: 1\n games_per_process: 1\ntraining:\n games_per_training_step: 1.0\n learning_rate: 0.001\n batch_size: 64\n weight_decay: 0.0001\n replay_buffer_size: 1000\n' > "$RUN/config.yaml" +mkdir -p "$RUN/models/checkpoints" +cp deep_quoridor/rust/fixtures/alphazero_B5W2_mv1.onnx "$RUN/models/checkpoints/model_1.onnx" +echo "$RUN" +``` + +- [ ] **Step 3: Start the server and verify the API + static wiring line up (curl)** + +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src timeout 8 python deep_quoridor/src/run_play_server_web.py "$RUN" \ + --static-dir deep_quoridor/frontend/dist --port 8139 >/tmp/claude-1000/play.log 2>&1 & +sleep 4 +echo "config:"; curl -s http://127.0.0.1:8139/api/config +echo; echo "models:"; curl -s http://127.0.0.1:8139/api/models +echo; echo "index served + COOP:"; curl -s -D - -o /dev/null http://127.0.0.1:8139/ | grep -iE "HTTP/|cross-origin" +echo "model file:"; curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:8139/models/model_1.onnx +echo "an ORT wasm asset:"; curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8139/ort/$(ls deep_quoridor/frontend/dist/ort/ | grep '\.wasm$' | head -1)" +wait 2>/dev/null +``` +Expected: `/api/config` returns board_size 5; `/api/models` lists `model_1.onnx` (default `model_1.onnx`); `/` returns 200 with COOP/COEP headers; `/models/model_1.onnx` is 200; the ORT wasm asset is 200. (This confirms every URL the browser will hit exists and is same-origin. The actual gameplay — WebGPU inference, the progress bar, clicking moves — is a manual browser check, documented in the README, since it needs a real browser with WebGPU/WASM.) + +- [ ] **Step 4: (No commit — this task creates no files.)** Record the curl results in your report. + +--- + +## Task 7: README + final verification sweep + +**Files:** create `deep_quoridor/frontend/README.md`. + +- [ ] **Step 1: Write `deep_quoridor/frontend/README.md`** + +```markdown +# Quoridor frontend (Svelte + Web Worker + onnxruntime-web) + +The browser play app. A Web Worker runs the `quoridor-wasm` MCTS and evaluates the +net with onnxruntime-web (WebGPU, wasm-CPU fallback); the main thread renders the +board and streams the AI's "thinking" progress. Served by the Python play server +(`src/run_play_server_web.py`). Design: `docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md`. + +## Build + +``` +# 1. Build the wasm package (Plan 1): +wasm-pack build rust/quoridor-wasm --target web --release +# 2. Build the frontend: +npm --prefix deep_quoridor/frontend install +npm --prefix deep_quoridor/frontend run build # -> deep_quoridor/frontend/dist/ +``` + +## Run (play a game) + +``` +# Point the server at a run directory (config.yaml + models/checkpoints/*.onnx) +# and the built SPA: +PYTHONPATH=deep_quoridor/src python deep_quoridor/src/run_play_server_web.py \ + /path/to/runs/ --static-dir deep_quoridor/frontend/dist --port 8080 +# open http://localhost:8080 +``` +For a quick try with the bundled fixture model, make a scratch run dir with a 5×5 +`config.yaml` and copy `rust/fixtures/alphazero_B5W2_mv1.onnx` to +`/models/checkpoints/model_1.onnx` (see the plan's Task 6). + +Dev mode with HMR: `npm --prefix deep_quoridor/frontend run dev` (Vite serves the +COOP/COEP headers itself; the model/API still need the Python server, so use a Vite +proxy or run the built app behind the Python server for full play). + +## Tests +``` +npm --prefix deep_quoridor/frontend run test # vitest: board model, eval marshalling, api client +``` +Unit tests cover the pure logic (board derivation, eval tensor marshalling, API +client). End-to-end gameplay (WebGPU inference, progress bar, clicking) is a manual +browser check — modern browser with WebGPU or a wasm-CPU fallback. +``` + +- [ ] **Step 2: Full verification sweep** + +```bash +npm --prefix deep_quoridor/frontend run test 2>&1 | tail -10 +npm --prefix deep_quoridor/frontend run build 2>&1 | tail -6 +``` +Expected: all vitest tests pass; build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git -C deep_quoridor add frontend/README.md +git -C deep_quoridor commit -m "docs(frontend): README with build + run instructions" +``` + +--- + +## Self-review checklist (run after writing all tasks) + +- **Spec coverage:** browser AI via worker + wasm ✓ (Task 4); WebGPU inference via ORT ✓ (Task 4, `executionProviders:["webgpu","wasm"]`); responsive UI + undo + real-time progress bar ✓ (Task 5); model + AlphaZero params exposed/editable ✓ (Task 5 ConfigDrawer, from `/api/config`); ORT served same-origin under COEP ✓ (Task 1 static-copy + Task 4 `wasmPaths`); served by the Python server ✓ (Task 6). +- **Type consistency:** `StateView`/`EnrichedAction` (types.ts) match the wasm `stateView()` shape and Plan 1's `view.rs`; `runEval` output `{values,logits}` matches `search.rs`'s `split_outputs` expectation; `AiClient` message types match `ai.worker.ts`. +- **Deferred (out of M1 scope):** polished wall-placement interaction (M1 lists legal walls as buttons); the full yaml-tree config editor (M1 exposes model + a few key params); IndexedDB model caching; the M2 visualizations (policy heatmap, live MCTS tree) — the worker `state`/`progress` messages and `runSearch`'s `children` are the hooks. + +## Follow-ups (not built here) +- Replace the wall-button list with click-on-edge wall placement. +- Vite dev proxy to the Python server so `npm run dev` gives full play with HMR. +- Retire the old Rust `play_server` now that the browser path is complete end-to-end. diff --git a/docs/superpowers/plans/2026-07-06-quoridor-play-server-python.md b/docs/superpowers/plans/2026-07-06-quoridor-play-server-python.md new file mode 100644 index 00000000..71c20677 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-quoridor-play-server-python.md @@ -0,0 +1,657 @@ +# Python FastAPI play server — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A thin Python FastAPI server that serves the browser play app (the built SPA + its `.wasm`) and the trained model `.onnx` files, with cross-origin-isolation + correct wasm headers, and exposes `GET /api/models` and `GET /api/config` by reusing the project's existing `v2.config` loader. + +**Architecture:** A new package `src/v2/play_server_web/` split by responsibility: pure, unit-testable helpers (`model_listing.py`, `config_view.py`) and a FastAPI **app factory** (`app.py`) wiring routes + COOP/COEP middleware + static mounts. A thin CLI entrypoint `src/run_play_server_web.py` (argparse + uvicorn) mirrors `run_benchmarks_v2.py`. The server touches no AI code — the AI runs in the browser (Plan 1's `quoridor-wasm`). + +**Tech Stack:** FastAPI + uvicorn (new deps), httpx (TestClient, dev), pydantic (existing), reusing `v2.config.load_user_config`. Python 3.12. + +**Spec:** `docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md` (Component D). This is Plan 2 of 3 (Plan 1 = `quoridor-wasm`, done; Plan 3 = Svelte/worker frontend). + +--- + +## Key decisions (design notes) + +- **Input is a run directory, models come from `models/checkpoints/`.** The spec's Component D said `/models/*.onnx`, but the trainer actually writes `/models/checkpoints/model_.onnx` (verified). So the server takes `--run-dir` (an existing run dir with `config.yaml` + `models/checkpoints/`) and lists/serves `.onnx` from there. A `--models-dir` flag can override the models location (e.g. to point at `rust/fixtures/` for a quick model). This reconciles the spec's stale assumption with reality. +- **Config is read-only via `load_user_config`** (returns `UserConfig`, no side effects, no path/run-layout assumptions) — NOT `load_config_and_setup_run` (which creates dirs) or `Config.from_user` (which assumes `/runs//`). +- **`/api/models` and `/api/config` are separate** (per spec): models list is a filesystem glob; config view is parsed yaml. +- **COOP/COEP always sent.** `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` — needed for ORT's multi-threaded WASM-CPU fallback (SharedArrayBuffer); harmless for the WebGPU path. Implication for Plan 3: onnxruntime-web must be bundled **same-origin** (COEP blocks non-CORP cross-origin resources) — a Plan 3 concern, noted here. +- **SPA is optional at this stage.** Plan 3 produces the SPA build; until then `--static-dir` is omitted and the server serves a placeholder page at `/` (API + models still work). + +## File structure + +**Created:** +- `deep_quoridor/src/v2/play_server_web/__init__.py` +- `deep_quoridor/src/v2/play_server_web/model_listing.py` — `list_onnx_models`, `default_model`. +- `deep_quoridor/src/v2/play_server_web/config_view.py` — `build_config_view`. +- `deep_quoridor/src/v2/play_server_web/app.py` — `create_app(...)` factory. +- `deep_quoridor/src/run_play_server_web.py` — CLI entrypoint. +- `deep_quoridor/test/test_play_server_web.py` — pytest (pure fns + TestClient). + +**Modified:** +- `deep_quoridor/requirements.txt`, `deep_quoridor/ci_requirements.txt` — add `fastapi`, `uvicorn`, `httpx`. + +## Conventions (how to run things here) + +- Python imports use `from v2.x import ...` with `PYTHONPATH` = `deep_quoridor/src`. Run tests from the **repo root** (`/home/jbinney/ws/deep_rabbit_hole`): + `PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -v` +- An active virtualenv is at `/home/jbinney/ws/deep_rabbit_hole/.venv` (writable). `pip`'s default cache (`~/.cache/pip`) is read-only in this environment, so install with `--no-cache-dir`. +- Never use `cd` in a compound shell command (permission prompt) — use absolute paths / `PYTHONPATH=...`. + +--- + +## Task 1: Add and install the web dependencies + +**Files:** Modify `deep_quoridor/requirements.txt`, `deep_quoridor/ci_requirements.txt`. + +- [ ] **Step 1: Add deps to `deep_quoridor/requirements.txt`** + +Append these three lines (unpinned, matching the file's existing style): +``` +fastapi +uvicorn +httpx +``` + +- [ ] **Step 2: Add the same deps to `deep_quoridor/ci_requirements.txt`** + +Append the same three lines: +``` +fastapi +uvicorn +httpx +``` + +- [ ] **Step 3: Install into the active venv** + +Run: +```bash +pip install --no-cache-dir fastapi uvicorn httpx 2>&1 | tail -5 +``` +Expected: successful install (downloads from PyPI). If PyPI is unreachable in this environment, STOP and report BLOCKED — the maintainer must `pip install fastapi uvicorn httpx` in their own environment before tests can run. + +- [ ] **Step 4: Verify the imports resolve** + +Run: +```bash +python -c "import fastapi, uvicorn, httpx; from fastapi.testclient import TestClient; print('ok')" +``` +Expected: prints `ok`. + +- [ ] **Step 5: Commit** + +```bash +git -C deep_quoridor add requirements.txt ci_requirements.txt +git -C deep_quoridor commit -m "deps: add fastapi, uvicorn, httpx for the Python play server" +``` + +--- + +## Task 2: `model_listing.py` — list `.onnx` models + +**Files:** Create `deep_quoridor/src/v2/play_server_web/__init__.py` (empty), `deep_quoridor/src/v2/play_server_web/model_listing.py`; Test in `deep_quoridor/test/test_play_server_web.py`. + +- [ ] **Step 1: Create the empty package marker** + +Create `deep_quoridor/src/v2/play_server_web/__init__.py` with a single line: +```python +"""FastAPI play server: serves the browser app + models, exposes config/model APIs.""" +``` + +- [ ] **Step 2: Write the failing test** + +Create `deep_quoridor/test/test_play_server_web.py`: +```python +from pathlib import Path + +from v2.play_server_web.model_listing import default_model, list_onnx_models + + +def _touch(dir_: Path, *names: str) -> None: + dir_.mkdir(parents=True, exist_ok=True) + for n in names: + (dir_ / n).write_bytes(b"") + + +def test_list_onnx_models_natural_sorted(tmp_path): + _touch(tmp_path, "model_2.onnx", "model_10.onnx", "model_1.onnx", "model_0.pt", "notes.txt") + assert list_onnx_models(tmp_path) == ["model_1.onnx", "model_2.onnx", "model_10.onnx"] + + +def test_list_onnx_models_missing_dir_is_empty(tmp_path): + assert list_onnx_models(tmp_path / "nope") == [] + + +def test_default_model_is_highest_version_or_none(): + assert default_model(["model_1.onnx", "model_2.onnx", "model_10.onnx"]) == "model_10.onnx" + assert default_model([]) is None +``` + +- [ ] **Step 3: Run it to confirm it fails** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -q 2>&1 | tail -10 +``` +Expected: import error / failure (`model_listing` not found). + +- [ ] **Step 4: Implement `model_listing.py`** + +Create `deep_quoridor/src/v2/play_server_web/model_listing.py`: +```python +"""List trained ONNX model files for the play server's model picker.""" + +import re +from pathlib import Path + +_INT_RE = re.compile(r"\d+") + + +def _version_key(name: str) -> tuple[int, str]: + ints = _INT_RE.findall(name) + return (int(ints[-1]) if ints else -1, name) + + +def list_onnx_models(models_dir: Path) -> list[str]: + """Basenames of ``*.onnx`` files in ``models_dir``, sorted by the trailing + integer (so ``model_9`` precedes ``model_10``). Empty if the dir is absent.""" + models_dir = Path(models_dir) + if not models_dir.is_dir(): + return [] + names = [p.name for p in models_dir.glob("*.onnx")] + return sorted(names, key=_version_key) + + +def default_model(models: list[str]) -> str | None: + """The highest-version model (last, given the natural sort), or None.""" + return models[-1] if models else None +``` + +- [ ] **Step 5: Run the tests to confirm they pass** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -q 2>&1 | tail -10 +``` +Expected: 3 passed. + +- [ ] **Step 6: Commit** + +```bash +git -C deep_quoridor add src/v2/play_server_web/__init__.py src/v2/play_server_web/model_listing.py test/test_play_server_web.py +git commit -C deep_quoridor -m "feat(play-server): model listing helper" || git -C deep_quoridor commit -m "feat(play-server): model listing helper" +``` + +--- + +## Task 3: `config_view.py` — board dims + AlphaZero defaults from config + +**Files:** Create `deep_quoridor/src/v2/play_server_web/config_view.py`; extend the test file. + +- [ ] **Step 1: Write the failing test** + +Append to `deep_quoridor/test/test_play_server_web.py`: +```python +from v2.config import load_user_config +from v2.play_server_web.config_view import build_config_view + +MINIMAL_CONFIG_YAML = """\ +run_id: test-run +quoridor: + board_size: 5 + max_walls: 2 + max_steps: 50 +alphazero: + mcts_n: 123 + mcts_c_puct: 1.4 +self_play: + num_processes: 1 + games_per_process: 1 +training: + games_per_training_step: 1.0 + learning_rate: 0.001 + batch_size: 64 + weight_decay: 0.0001 + replay_buffer_size: 1000 +""" + + +def _write_config(tmp_path) -> Path: + p = tmp_path / "config.yaml" + p.write_text(MINIMAL_CONFIG_YAML) + return p + + +def test_build_config_view_board_and_defaults(tmp_path): + cfg = load_user_config(str(_write_config(tmp_path))) + view = build_config_view(cfg) + assert view["board_size"] == 5 + assert view["max_walls"] == 2 + assert view["max_steps"] == 50 + d = view["defaults"] + assert d["mcts_n"] == 123 + assert d["mcts_c_puct"] == 1.4 + assert d["leaf_parallelism"] == 16 # SelfPlayConfig default + assert d["virtual_loss"] == 3 # SelfPlayConfig default + # No self_play.alphazero block in the minimal config: + assert d["temperature"] is None + assert d["mcts_noise_epsilon"] == 0.0 + assert d["mcts_worker_threads"] is None +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py::test_build_config_view_board_and_defaults -q 2>&1 | tail -10 +``` +Expected: import/attribute failure (`config_view` not found). + +- [ ] **Step 3: Implement `config_view.py`** + +Create `deep_quoridor/src/v2/play_server_web/config_view.py`: +```python +"""Assemble the /api/config payload (board dims + AlphaZero defaults) from a +loaded UserConfig. The relevant fields live across the alphazero + self_play +sections, so we gather them into one flat 'defaults' block for the UI.""" + +from v2.config import UserConfig + + +def build_config_view(cfg: UserConfig) -> dict: + sp = cfg.self_play + spa = sp.alphazero # Optional[AlphaZeroSelfPlayConfig] + return { + "board_size": cfg.quoridor.board_size, + "max_walls": cfg.quoridor.max_walls, + "max_steps": cfg.quoridor.max_steps, + "defaults": { + "mcts_n": cfg.alphazero.mcts_n, + "mcts_c_puct": cfg.alphazero.mcts_c_puct, + "temperature": spa.temperature if spa else None, + "mcts_noise_epsilon": spa.mcts_noise_epsilon if spa else 0.0, + "mcts_noise_alpha": spa.mcts_noise_alpha if spa else None, + "leaf_parallelism": sp.leaf_parallelism, + "virtual_loss": sp.virtual_loss, + "mcts_worker_threads": sp.mcts_worker_threads, + }, + } +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -q 2>&1 | tail -10 +``` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git -C deep_quoridor add src/v2/play_server_web/config_view.py test/test_play_server_web.py +git -C deep_quoridor commit -m "feat(play-server): config view (board dims + alphazero defaults)" +``` + +--- + +## Task 4: `app.py` — the FastAPI app factory + +**Files:** Create `deep_quoridor/src/v2/play_server_web/app.py`; extend the test file. + +- [ ] **Step 1: Write the failing tests (TestClient)** + +Append to `deep_quoridor/test/test_play_server_web.py`: +```python +from fastapi.testclient import TestClient + +from v2.play_server_web.app import create_app + + +def _make_run_dir(tmp_path): + (tmp_path / "config.yaml").write_text(MINIMAL_CONFIG_YAML) + models = tmp_path / "models" / "checkpoints" + _touch(models, "model_1.onnx", "model_2.onnx") + return tmp_path, models + + +def test_api_config_endpoint(tmp_path): + run_dir, _ = _make_run_dir(tmp_path) + client = TestClient(create_app(run_dir)) + r = client.get("/api/config") + assert r.status_code == 200 + body = r.json() + assert body["board_size"] == 5 + assert body["defaults"]["mcts_n"] == 123 + + +def test_api_models_endpoint(tmp_path): + run_dir, _ = _make_run_dir(tmp_path) + client = TestClient(create_app(run_dir)) + r = client.get("/api/models") + assert r.status_code == 200 + body = r.json() + assert body["models"] == ["model_1.onnx", "model_2.onnx"] + assert body["default"] == "model_2.onnx" + + +def test_static_serving_headers_and_wasm_mime(tmp_path): + run_dir, _ = _make_run_dir(tmp_path) + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("hi") + (static / "app_bg.wasm").write_bytes(b"\x00asm") + client = TestClient(create_app(run_dir, static_dir=static)) + + idx = client.get("/") + assert idx.status_code == 200 + assert idx.headers["cross-origin-opener-policy"] == "same-origin" + assert idx.headers["cross-origin-embedder-policy"] == "require-corp" + + wasm = client.get("/app_bg.wasm") + assert wasm.status_code == 200 + assert wasm.headers["content-type"] == "application/wasm" + + +def test_models_are_served_as_static(tmp_path): + run_dir, models = _make_run_dir(tmp_path) + (models / "model_1.onnx").write_bytes(b"ONNXDATA") + client = TestClient(create_app(run_dir)) + r = client.get("/models/model_1.onnx") + assert r.status_code == 200 + assert r.content == b"ONNXDATA" + + +def test_placeholder_when_no_static_dir(tmp_path): + run_dir, _ = _make_run_dir(tmp_path) + client = TestClient(create_app(run_dir)) + r = client.get("/") + assert r.status_code == 200 + assert "SPA build not found" in r.text +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -q 2>&1 | tail -12 +``` +Expected: import failure (`app` not found). + +- [ ] **Step 3: Implement `app.py`** + +Create `deep_quoridor/src/v2/play_server_web/app.py`: +```python +"""FastAPI app factory for the Quoridor browser play server. + +Serves the built SPA + its .wasm and the trained model .onnx files, with +cross-origin isolation + correct wasm MIME, and exposes /api/config and +/api/models. Owns no AI code — the AI runs in the browser (quoridor-wasm).""" + +import mimetypes +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +from v2.config import load_user_config +from v2.play_server_web.config_view import build_config_view +from v2.play_server_web.model_listing import default_model, list_onnx_models + +# Python's mimetypes doesn't always know .wasm; register it so StaticFiles serves +# wasm with the type browsers require for streaming compilation. +mimetypes.add_type("application/wasm", ".wasm") + +_PLACEHOLDER = ( + "Quoridor" + "

SPA build not found. Build the frontend (Plan 3) and start the server " + "with --static-dir pointing at the build output.

" +) + + +def create_app( + run_dir: Path, + static_dir: Optional[Path] = None, + models_dir: Optional[Path] = None, +) -> FastAPI: + run_dir = Path(run_dir) + config_file = run_dir / "config.yaml" + models_dir = Path(models_dir) if models_dir is not None else run_dir / "models" / "checkpoints" + + app = FastAPI(title="Quoridor play server") + + @app.middleware("http") + async def add_cross_origin_isolation(request, call_next): + # COOP+COEP enable SharedArrayBuffer (ORT's multi-threaded WASM-CPU + # fallback). Harmless for the WebGPU path. + response = await call_next(request) + response.headers["Cross-Origin-Opener-Policy"] = "same-origin" + response.headers["Cross-Origin-Embedder-Policy"] = "require-corp" + return response + + @app.get("/api/config") + def api_config(): + cfg = load_user_config(str(config_file)) + return build_config_view(cfg) + + @app.get("/api/models") + def api_models(): + models = list_onnx_models(models_dir) + return {"models": models, "default": default_model(models)} + + # Model files fetched by onnxruntime-web (same-origin). + if models_dir.is_dir(): + app.mount("/models", StaticFiles(directory=str(models_dir)), name="models") + + # SPA catch-all, mounted LAST so /api and /models win. Placeholder until Plan 3. + if static_dir is not None and Path(static_dir).is_dir(): + app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="spa") + else: + + @app.get("/", response_class=HTMLResponse) + def placeholder(): + return _PLACEHOLDER + + return app +``` + +- [ ] **Step 4: Run the full test file to confirm all pass** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -q 2>&1 | tail -12 +``` +Expected: 9 passed. If the `/models` static mount 404s because the directory is created after mounting, note that `_make_run_dir` creates it first (it does), so the mount sees it. + +- [ ] **Step 5: Commit** + +```bash +git -C deep_quoridor add src/v2/play_server_web/app.py test/test_play_server_web.py +git -C deep_quoridor commit -m "feat(play-server): FastAPI app factory (config/models APIs, static SPA+models, COOP/COEP)" +``` + +--- + +## Task 5: CLI entrypoint + +**Files:** Create `deep_quoridor/src/run_play_server_web.py`; extend the test file. + +- [ ] **Step 1: Write the failing test** + +Append to `deep_quoridor/test/test_play_server_web.py`: +```python +import importlib + + +def test_main_returns_1_on_missing_config(tmp_path): + mod = importlib.import_module("run_play_server_web") + + class Args: + run_dir = str(tmp_path) # no config.yaml here + static_dir = None + models_dir = None + host = "127.0.0.1" + port = 8080 + + assert mod.main(Args()) == 1 +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py::test_main_returns_1_on_missing_config -q 2>&1 | tail -10 +``` +Expected: `ModuleNotFoundError: No module named 'run_play_server_web'`. + +- [ ] **Step 3: Implement `run_play_server_web.py`** + +Create `deep_quoridor/src/run_play_server_web.py`: +```python +"""Serve the Quoridor browser play app + model files + config API. + +Read-only over an existing run directory (its config.yaml + models/checkpoints/). +Mirrors run_benchmarks_v2.py's CLI shape.""" + +import argparse +from pathlib import Path + +import uvicorn + +from v2.play_server_web.app import create_app + + +def main(args) -> int: + run_dir = Path(args.run_dir).resolve() + if not (run_dir / "config.yaml").is_file(): + print(f"error: {run_dir / 'config.yaml'} not found") + return 1 + static_dir = Path(args.static_dir).resolve() if args.static_dir else None + models_dir = Path(args.models_dir).resolve() if args.models_dir else None + app = create_app(run_dir, static_dir=static_dir, models_dir=models_dir) + uvicorn.run(app, host=args.host, port=args.port) + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Serve the Quoridor browser play app + model files + config API." + ) + parser.add_argument( + "run_dir", + type=str, + help="Path to a run directory containing config.yaml and models/checkpoints/.", + ) + parser.add_argument( + "--static-dir", type=str, default=None, help="Directory of the built SPA (Plan 3 output)." + ) + parser.add_argument( + "--models-dir", + type=str, + default=None, + help="Override the .onnx models dir (default: /models/checkpoints).", + ) + parser.add_argument("--host", type=str, default="127.0.0.1") + parser.add_argument("--port", type=int, default=8080) + args = parser.parse_args() + raise SystemExit(main(args)) +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -q 2>&1 | tail -12 +``` +Expected: 10 passed. + +- [ ] **Step 5: Commit** + +```bash +git -C deep_quoridor add src/run_play_server_web.py test/test_play_server_web.py +git -C deep_quoridor commit -m "feat(play-server): uvicorn CLI entrypoint" +``` + +--- + +## Task 6: Docs + verification sweep + +**Files:** Create `deep_quoridor/src/v2/play_server_web/README.md`. + +- [ ] **Step 1: Write the README** + +Create `deep_quoridor/src/v2/play_server_web/README.md`: +```markdown +# play_server_web + +Thin FastAPI server for the browser Quoridor play app. Serves the built SPA + its +`.wasm` and the trained model `.onnx` files (with cross-origin isolation + wasm +MIME), and exposes a small config/model API. The AI runs client-side (see +`rust/quoridor-wasm`). Design: `docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md`. + +## Run + +``` +pip install fastapi uvicorn +PYTHONPATH=deep_quoridor/src python deep_quoridor/src/run_play_server_web.py \ + /path/to/runs/ --static-dir /path/to/spa/dist --port 8080 +``` +- `run_dir` (positional): an existing run directory (`config.yaml` + `models/checkpoints/`). +- `--static-dir`: the built SPA (Plan 3). Omit to serve a placeholder page. +- `--models-dir`: override where `.onnx` files are read from. + +## API +- `GET /api/config` → `{ board_size, max_walls, max_steps, defaults: { mcts_n, mcts_c_puct, temperature, mcts_noise_epsilon, mcts_noise_alpha, leaf_parallelism, virtual_loss, mcts_worker_threads } }` +- `GET /api/models` → `{ models: ["model_1.onnx", ...], default: "model_N.onnx" }` +- `GET /models/.onnx` → the model file (for onnxruntime-web to fetch). +- `GET /` and other paths → the SPA (or placeholder). + +All responses carry `Cross-Origin-Opener-Policy: same-origin` + +`Cross-Origin-Embedder-Policy: require-corp`. Plan 3 must bundle onnxruntime-web +**same-origin** (COEP blocks non-CORP cross-origin resources). + +## Tests +``` +PYTHONPATH=deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -v +``` +``` + +- [ ] **Step 2: Full verification sweep** + +Run: +```bash +PYTHONPATH=$(pwd)/deep_quoridor/src python -m pytest deep_quoridor/test/test_play_server_web.py -v 2>&1 | tail -20 +``` +Expected: all tests pass (10). + +- [ ] **Step 3: Confirm the entrypoint starts (smoke, then stop)** + +Run (starts the server briefly against a placeholder, then kills it): +```bash +RUN=$(mktemp -d); printf 'run_id: s\nquoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\nalphazero:\n mcts_n: 100\n mcts_c_puct: 1.4\nself_play:\n num_processes: 1\n games_per_process: 1\ntraining:\n games_per_training_step: 1.0\n learning_rate: 0.001\n batch_size: 64\n weight_decay: 0.0001\n replay_buffer_size: 1000\n' > "$RUN/config.yaml" +PYTHONPATH=$(pwd)/deep_quoridor/src timeout 4 python deep_quoridor/src/run_play_server_web.py "$RUN" --port 8137 2>&1 | tail -5 & +sleep 2; curl -s http://127.0.0.1:8137/api/config | head -c 300; echo; wait +``` +Expected: the `curl` prints the JSON config view (board_size 5, defaults…). (The `timeout 4` stops uvicorn.) + +- [ ] **Step 4: Commit** + +```bash +git -C deep_quoridor add src/v2/play_server_web/README.md +git -C deep_quoridor commit -m "docs(play-server): README with run instructions and API" +``` + +--- + +## Self-review checklist (run after writing all tasks) + +- **Spec coverage:** static SPA+wasm serving ✓ (Task 4), model `.onnx` serving ✓ (Task 4 `/models` mount), COOP/COEP + wasm MIME ✓ (Task 4), `/api/models` ✓ (Task 4), `/api/config` ✓ (Task 3/4), reuse of Python config loader ✓ (`load_user_config`). Retiring the Rust `play_server` is a separate cleanup, tracked below. +- **Deferred / out of scope:** deleting the Rust `rust/src/play_server/` module (do it once Plan 3's SPA replaces its function end-to-end); serving a curated flat `models/` instead of all checkpoints; M4 REST (high scores). + +## Follow-ups (not built here) +- Retire `rust/src/play_server/` + `bin/play_server.rs` + `tests/play_server_e2e.rs` once the browser path fully replaces it (Plan 3). +- Optional: a `/api/config` "play defaults" that overrides the run's self-play values with play-appropriate ones (temperature 0, no noise) — currently the UI is expected to choose those. diff --git a/docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md b/docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md new file mode 100644 index 00000000..5f2e87a9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-browser-wasm-play-server-design.md @@ -0,0 +1,255 @@ +# Browser-WASM play server — design + +**Date:** 2026-07-05 +**Branch:** TBD (new feature branch off `main`) +**Status:** approved (design), pending implementation plan +**Supersedes:** the server-side-AI architecture of +`2026-05-29-quoridor-play-server-design.md` (that server ran MCTS + ONNX +per-session on the host; this design moves the AI into the browser). + +## Problem + +The current play server (`rust/src/play_server/`) runs the AlphaZero agent +server-side: each browser session holds an `Arc>` that owns +an `AlphaZeroAgent` and runs MCTS + ONNX inference on the host. That works but: + +- **Requires a GPU host.** ONNX inference for a good-strength AI wants a graphics + card on the server. +- **Scales poorly.** Every concurrent player consumes server CPU/GPU and RAM for + their MCTS search. + +We want to keep reusing the project's **Rust** AlphaZero implementation (game +mechanics + MCTS), which we like, but move the entire AI computation into the +player's browser so the server becomes a cheap static host. + +## Goals + +- Run the AI **entirely in the browser**, using the **client machine's GPU** for + neural-network inference. +- Keep the server **low-resource**: serve the page, the WASM, and the models. + (A REST API for things like high scores comes later.) +- A more **browser-heavy, responsive** UI (Svelte). Beyond current functionality, + add an **Undo** button and a real-time **"AI thinking" progress bar** showing + the fraction of MCTS simulations completed. +- Expose most AlphaZero parameters for **viewing and editing** in the UI + (`mcts_n`, `temperature`, Dirichlet α/ε, `c_puct`, `leaf_parallelism`, + `mcts_worker_threads`, …), presented in the same tree structure as the yaml + config files in run directories. +- Work on **modern browsers** (Chrome, Edge, Safari, Firefox — no dev/nightly + builds required). WebGPU is stable in all of these. +- Be fast enough not to frustrate the player, via the **leaf-parallel batched + MCTS** used in self-play. + +## Non-goals (deferred to later milestones) + +This spec is **Milestone 1**: the smallest thing that fully replaces today's +server. Explicitly deferred: + +- **M2 — Model & search visualization:** policy/value heatmap over the board; + interactive MCTS tree that grows as the AI thinks. +- **M3 — Self-play & run exploration:** server exposes a folder of run + directories; browse/replay self-play games; inspect models. +- **M4 — REST API:** high scores and other stateful features. + +The design keeps hooks for these (see "Forward compatibility") but does not +build them. + +## Architecture overview + +``` +Browser + Main thread (Svelte app) + board · legal-move hints · undo · progress bar · move history + · slide-over "Advanced" config drawer · model picker + │ start(state, config) ▲ progress {done/N} + ▼ │ result {move, policy} + Web Worker (one OS thread) + quoridor-wasm (Rust→WASM): game mechanics + leaf-parallel MCTS + │ evaluate_batch(features[K]) ──await──▶ onnxruntime-web + ▼ (WebGPU EP; WASM-CPU fallback) + collects K diverse leaves (virtual loss) → one batched eval → backprop K + │ + ▼ + Client GPU (WebGPU) + +Thin server (new Python app — FastAPI) + serves: SPA + .wasm + model .onnx files + sets: COOP/COEP + correct wasm/wasm-streaming MIME headers + API: GET /api/models, GET /api/config (reuse existing Python config loaders) + (the old Rust rust/src/play_server is retired: MCTS/ONNX/session move to WASM) +``` + +### Key decisions (with rationale) + +1. **Inference: `onnxruntime-web` with the WebGPU execution provider** + (CPU-WASM fallback automatic). *Why over a pure-Rust engine (`wonnx`/`burn`):* + ORT-web loads `.onnx` files **at runtime over HTTP**, which matches + "server serves the models" and lets the UI pick among models; it is mature + with broad op coverage for a small ResNet; and it keeps the risky part + (browser NN inference) on a well-trodden path. The trade-off — two WASM + runtimes and a JS↔WASM seam per batch — is amortized by batching. + +2. **Threading: single Web Worker + GPU batching** (self-play's leaf-parallel + design, reused for one game). *Why not multi-threaded tree search:* on a small + board the tree ops are microseconds; the cost is NN eval, which runs in + parallel **on the GPU**. Batching K leaves into one WebGPU call is the real + speedup and needs no `SharedArrayBuffer` for our code. True multi-threaded + tree search (`wasm-bindgen-rayon` + worker pool + `SharedArrayBuffer`) is a + deferred, profile-driven optimization. + +3. **Framework: Svelte.** Tiny runtime, fine-grained reactivity suited to a + live-updating board / progress bar / config tree, and clean SVG/canvas interop + for the M2 visualizations. + +4. **Crate boundary: a new `quoridor-wasm` crate** (`crate-type=["cdylib"]`) in + the workspace, depending on `quoridor-rs` with a slim feature set. *Why a + separate crate over a `wasm` feature flag on `quoridor-rs`:* keeps + `wasm-bindgen` out of the core lib and avoids the cdylib/rlib target conflict + already documented in `rust/Cargo.toml`. + +5. **Server: a new Python app (FastAPI)** that fully replaces the Rust + `play_server`. *Why Python, not the existing Rust server:* the server no + longer touches the AI at all (MCTS + ONNX now run in WASM), so there is no + reason to keep it in Rust. The team already uses Python, it has strong web + libraries, and — decisively — the run directories, trainer, config, and + yaml models are **already Python** (`src/v2/config.py`, `yaml_models.py`, + etc.), so M3 (run-dir browsing) and M4 (REST high scores) reuse existing + Python code instead of reimplementing it. FastAPI gives easy custom headers + (COOP/COEP) via middleware, `.wasm` static serving, and a clean REST surface + for later. (Flask/Starlette are viable; FastAPI is the recommendation.) + +## Components + +### A. `quoridor-wasm` crate (Rust → WASM) + +New workspace crate `rust/quoridor-wasm` (or a `quoridor-wasm` member; +finalize location in the plan). + +- **Depends on** `quoridor-rs` with `default-features = false` and a slim + feature set exposing: `compact/*` game mechanics, `actions`, `grid_helpers` + (feature encoding), `rotation`, and the MCTS **tree primitives** from + `mcts.rs` / `selfplay_mcts.rs` (`NodeArena`, `select_leaf_with_vl`, virtual + loss, `expand_node`, `backpropagate`, `promote_subtree`). None of `ort`, + `tokio`, `tiny_http`, `pyo3`, `rayon`, or `dashmap` are pulled into this build. + - This likely requires making some currently `pub(super)` tree primitives + `pub`, and confirming the core modules are free of the native-only deps + (the eval pipeline / ORT / tokio code stays behind `binary`). +- **Adds** `wasm-bindgen`, `wasm-bindgen-futures`, `js-sys`, `serde-wasm-bindgen`. +- **Exposes** `#[wasm_bindgen]` bindings, roughly: + - `new_game(board_size, max_walls, max_steps, human_player) -> GameHandle` + - `legal_actions(handle) -> JsValue` (enriched actions, reusing the + `EnrichedAction` shape from `play_server::state`) + - `apply_action(handle, action_index) -> StateView` + - `undo(handle) -> StateView` (replays move history minus the last human ply) + - `run_search(handle, config, eval_batch_cb, progress_cb) -> Promise` +- **New async MCTS driver** (the one genuinely new piece): the same + leaf-parallel structure as `selfplay_mcts.rs`, but the eval transport is a + **JS-provided batched-eval callback** instead of the tokio→ORT eval pipeline. + Per outer iteration: select K diverse leaves (virtual loss) → `await` + `eval_batch_cb(features)` (resolved by onnxruntime-web) → backprop all K → + fire `progress_cb(done, total)` every few sims → repeat to `mcts_n`. Returns + the chosen move plus the root policy (the policy field is already computed and + is the hook M2 will use). + +### B. Svelte app (main thread) + +- **Board component:** renders `StateView` (pawns, walls, walls-remaining), + highlights legal moves/wall slots, handles click-to-move / click-edge-to-wall. +- **Control rail:** AI-thinking card (progress bar: `done/total` sims + current + leading move), **Undo**, **New game** (model dropdown from `/api/models`, + who-goes-first), **move history** list. +- **Advanced config drawer** (slide-over): editable tree mirroring the yaml + config structure of run directories; values feed the `config` argument to + `run_search` and apply on the next search. (M1 exposes the AlphaZero search + params; the tree shape is chosen so more of the yaml can be surfaced later.) +- **Worker client:** posts `start(state, config)`, receives `progress`/`result` + messages, drives the progress bar and board updates. + +### C. Web Worker + +- Loads `quoridor-wasm` and `onnxruntime-web`; owns the ORT `InferenceSession` + (created with the WebGPU EP, CPU-WASM fallback). +- Fetches/caches the selected `.onnx` (HTTP cache for M1; IndexedDB/Cache API is + a later nicety). +- Bridges `quoridor-wasm`'s `eval_batch_cb` to `session.run(...)` and posts + progress/result messages to the main thread. + +### D. Thin server (new Python app — FastAPI) + +- A new Python package (e.g. `src/play_server_web/` or a small FastAPI app under + the existing `src/`), launched with uvicorn. +- Serves the built SPA + `.wasm` + `/models/*.onnx` (static mounts). +- Sends **COOP: `same-origin`** + **COEP: `require-corp`** (so ORT's CPU-fallback + threads work) via middleware, and correct `application/wasm` MIME for streaming + compile. +- Small JSON API: + - **`GET /api/models`** — lists `/models/*.onnx`, reusing the + project's existing Python config/yaml loaders (`src/v2/config.py`, + `yaml_models.py`) rather than reimplementing the Rust `config.rs` listing. + - **`GET /api/config`** — board dimensions + default AlphaZero params. +- The old Rust `rust/src/play_server/` module (HTTP, handlers, session, + server-side MCTS/ONNX) is **retired**: game play, the `StateView` shape, and + move/undo logic all move into `quoridor-wasm`. (The `EnrichedAction`/ + `StateView` types are a useful reference when porting the view shape to WASM.) + +## Data flow: one AI move + +1. Human clicks a legal cell/edge → Svelte calls `wasm.apply_action(idx)` → + gets updated `StateView`, re-renders, pushes to move history. +2. If it's now the AI's turn, Svelte tells the worker `start(state, config)`. +3. Worker calls `wasm.run_search(handle, config, eval_batch_cb, progress_cb)`. +4. The Rust driver loops: select K leaves → `await eval_batch_cb(features)` + (ORT/WebGPU) → backprop → `progress_cb(done, total)` (→ posted to main + thread → progress bar) until `mcts_n` sims. +5. Driver returns `{ move, policy }`; worker posts `result`; Svelte applies the + AI move via `wasm.apply_action`, re-renders, clears the progress bar. + +## Build & toolchain + +- **Vite + Svelte** for the app. +- **`wasm-pack`** builds `quoridor-wasm` into an npm-consumable package that Vite + bundles (including the worker + `.wasm`). +- **`onnxruntime-web`** from npm. +- Dev: Vite dev server (with COOP/COEP dev headers to mirror prod). Prod: `vite + build` output is what the Python (FastAPI/uvicorn) server serves as static + files. + +## Testing + +- **Rust (`quoridor-wasm`) unit tests** on native target for the game/undo logic + and the async driver against a **mock eval callback** (uniform priors, à la + `UniformMockEvaluator`) — asserts sim counts, progress-callback cadence, and + that N sims produce a legal move. Reuses existing tree-primitive tests. +- **Parity test:** for a fixed seed/model, the WASM driver's visit counts / + chosen move match the native `selfplay_mcts` leaf-parallel search (guards the + new eval transport, not new tree logic). Consider extending the existing + cross-language consistency harness. +- **Svelte component tests** (Vitest): board rendering from `StateView`, legal- + move highlighting, undo reducing history, progress-bar binding. +- **Server tests (Python, pytest + FastAPI `TestClient`):** static routes serve + with correct `application/wasm` MIME + COOP/COEP headers; `/api/models` lists + `.onnx`; `/api/config` returns board dims. (The Rust `play_server_e2e.rs` is + retired with the module.) +- **Manual smoke:** real browser, WebGPU on and forced-off (CPU fallback), full + game vs AI with visible progress bar and working undo. + +## Forward compatibility (hooks, not built) + +- `run_search`'s `result` already carries the **root policy** → M2 heatmap. +- The async driver can emit **tree snapshots** on `progress` → M2 live tree. +- The config drawer renders a **general yaml-shaped tree** → more params later. +- The Python server → M3 run-dir routes + M4 REST slot straight into existing + Python run/config/training code. + +## Risks / open questions for the plan + +- **ORT-web op coverage / perf on WebGPU** for this exact ResNet — validate + early with a spike (load a real `.onnx`, batched eval, measure). +- **Model download size** on first load — HTTP caching is fine for M1; revisit + IndexedDB if models are large. +- **Exact `quoridor-rs` feature split** — confirm core modules compile to + `wasm32-unknown-unknown` with the native-only deps fully gated out. +- **`mcts_worker_threads` in the config UI** — it is exposed for viewing/editing + but is inert in M1's single-worker design; the plan should decide whether to + show it as disabled/"self-play only" to avoid implying an effect it doesn't + have.