From f997cc8b156fe6045b96bb0006b93596ccd2d88c Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Sun, 24 May 2026 19:35:16 -0400 Subject: [PATCH 01/30] Update B9W10 config --- .../2026_05_23_jon_b9w10_performance/config.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/experiments/2026_05_23_jon_b9w10_performance/config.yaml b/experiments/2026_05_23_jon_b9w10_performance/config.yaml index 9a8a214c..b0cd8d87 100644 --- a/experiments/2026_05_23_jon_b9w10_performance/config.yaml +++ b/experiments/2026_05_23_jon_b9w10_performance/config.yaml @@ -22,7 +22,7 @@ wandb: self_play: program: rust num_processes: 1 - mcts_worker_threads: 12 + mcts_worker_threads: 18 games_per_process: 128 eval_batch_size: 1024 leaf_parallelism: 16 @@ -32,8 +32,9 @@ self_play: eval_max_wait_ms: 1 alphazero: mcts_noise_epsilon: 0.25 + temperature: 3.0 training: - games_per_training_step: 50 + games_per_training_step: 100 learning_rate: 0.001 batch_size: 2048 weight_decay: 0.0001 @@ -52,14 +53,12 @@ benchmarks: - random - greedy:p_random=0.3,nick=greedy-03 - greedy - - simple:branching_factor=16,nick=simple-bf16 -- every: 1 models +- every: 10 models jobs: - type: tournament - prefix: full + prefix: "" times: 10 opponents: - random - greedy:p_random=0.3,nick=greedy-03 - greedy - - simple:branching_factor=16,nick=simple-bf16 From 250c2c609d2c4bb12308981d368343e367a1f553 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 20:38:44 -0400 Subject: [PATCH 02/30] vibe: add self-play MCTS metrics design spec Rust self-play exposes no visibility into MCTS behavior during a run. Spec adds per-model-version diagnostics (terminal/truncation sim fraction, tree depth, root-visit spread, nodes/branching, unique games) written by Rust to JSON records and logged to W&B by a new Python process in the same run group, reset on each model update. --- ...2026-05-26-selfplay-mcts-metrics-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md diff --git a/docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md b/docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md new file mode 100644 index 00000000..3ba18f1d --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md @@ -0,0 +1,154 @@ +# Self-play MCTS metrics → W&B — design + +**Date:** 2026-05-26 +**Branch:** `jdb/b9w10-performance` +**Status:** approved (design), pending implementation plan + +## Problem + +The Rust self-play binary (`deep_quoridor/rust`, driven by `train_v2.py`) runs leaf-parallel +MCTS to generate training games, but exposes almost no visibility into *how* the search is +behaving during a training run. We want diagnostics — terminal-hit rate, tree depth, how +"spread out" the search is, and how many distinct games are being produced — surfaced to +Weights & Biases in the **same W&B group as the other workers** (`train`, `benchmark`, +`ai_report`), and **reset each time the model is updated** so each metric reflects a single +model version. + +## Constraint that shapes everything + +The Rust self-play runs as a **subprocess** (`subprocess.Popen` in `train_v2.py:112`) with **no +W&B client** — only the Python processes call `wandb.init(group=config.run_id, …)`. There is +currently no self-play W&B run at all. So a Rust→Python bridge is required: Rust writes metric +records to files; a new Python process logs them to W&B. This reuses the existing file-based +Rust→Python channel already used for replay games. + +## Architecture & data flow + +``` +Rust selfplay subprocess(es) Python (spawned by train_v2.py) +┌──────────────────────────────┐ ┌─────────────────────────────┐ +│ per-search SearchStats ──┐ │ JSON │ run_selfplay_metrics(config)│ +│ per-game hashes ─────────┤ │ records │ • polls metrics dir │ +│ SelfPlayMetrics accumulator │ ──────► │ • aggregates across pids │ +│ (keyed by model_version) │ files │ • wandb.log(group=run_id) │ +│ flush+reset on version change│ │ x-axis = "Model version" │ +└──────────────────────────────┘ └─────────────────────────────┘ +``` + +Rust accumulates raw metric aggregates for the current `model_version`. The existing +model-reload poll task (`selfplay.rs:679-688`) already detects version changes and is the +natural flush+reset point. On a version change (and on shutdown) Rust writes one JSON record +per `(model_version, pid)` to a metrics directory and clears the accumulator. A new Python +process — spawned by `train_v2.py` like the benchmark processes — polls that directory and logs +each completed version to W&B once. + +## Metrics (all per model version; x-axis = `Model version`) + +| W&B metric | Definition | Version aggregation | +|---|---|---| +| `selfplay/terminal_sim_frac` | fraction of MCTS simulations whose selected leaf is a **win** terminal | Σ terminal-win sims / Σ sims | +| `selfplay/truncation_sim_frac` | fraction of simulations whose leaf hit the `max_steps` cap (code already distinguishes win vs truncation in `selfplay_mcts.rs:180-197`) | Σ trunc sims / Σ sims | +| `selfplay/max_tree_depth` | deepest selection path (`path.len()` from `select_leaf_with_vl`) | max | +| `selfplay/mean_tree_depth` | mean selection-path depth per sim | Σ depth / Σ sims | +| `selfplay/root_visit_entropy` | entropy (nats) of root child visit distribution | mean over moves | +| `selfplay/root_visit_perplexity` | `exp(entropy)` = effective number of moves considered | mean over moves | +| `selfplay/top_move_visit_frac` | max child visits / total root visits | mean over moves | +| `selfplay/mean_nodes_per_search` | arena node count per search | Σ nodes / moves | +| `selfplay/mean_branching` | tree edges / internal (expanded) nodes | pooled: (Σ nodes − Σ moves) / Σ internal_nodes | +| `selfplay/games_generated` | games completed this version | sum | +| `selfplay/unique_games_full` / `selfplay/unique_frac_full` | distinct full move-sequences; and unique/total | per-process dedup, summed (see Decisions) | +| `selfplay/unique_games_opening` / `selfplay/unique_frac_opening` | distinct first-K-ply prefixes; and unique/total | per-process dedup, summed | + +"Simulation" = one leaf selection/backprop iteration of MCTS. "Move" = one root search (one +played move). Entropy/perplexity/top-frac are computed per move from the root child visit +counts, then averaged over the moves in the version. + +## Rust side + +- **`SearchStats`** — `search()` (`selfplay_mcts.rs:102`) returns this alongside its existing + `(children, root_value)`. Fields: `sims`, `terminal_wins`, `truncations`, `max_depth`, + `sum_depth`, `nodes`, `internal_nodes`, `root_visit_entropy`, `top_move_visit_frac`. All are + derived from data the search already has: per-sim `path` length (depth), the existing + terminal/truncation branch, the arena node count, and the root `children` visit counts. +- **Per-game folding** — `play_game_async` folds each move's `SearchStats` into a per-game + total and, at game end, computes a full-game move-sequence hash and a first-K-ply hash. +- **`SelfPlayMetrics` accumulator** — one per process, `Arc>`, keyed by + `model_version`. Holds running raw aggregates: summed counters, running `max_depth`, sums and + counts for the per-move means, `games_generated`, and two `HashSet` (full + opening + hashes) for within-process uniqueness. Game tasks fold their per-game results in under the + current `model_version`. +- **Flush + reset** — at the version-change point in the poll task, and on shutdown: serialize + the raw aggregates for the just-finished version to + `/v{version}_pid{pid}.json`, then clear the accumulator. Writing raw aggregates + (not final metrics) lets Python combine multiple processes correctly. +- **CLI** — new `--metrics-dir ` arg. When absent, metric collection is disabled entirely + (no accumulator, no overhead) so the binary stays usable standalone. `train_v2.py` always + passes it for Rust self-play. + +### JSON record schema (one file per `(version, pid)`) +```json +{ + "model_version": 42, "pid": 700653, + "sims": 12830000, "terminal_wins": 410000, "truncations": 90000, + "max_depth": 37, "sum_depth": 41000000, + "moves": 9800, "sum_root_entropy": 18000.0, "sum_top_move_frac": 6100.0, + "sum_nodes": 9100000, "sum_internal_nodes": 5200000, + "games_generated": 96, + "unique_full": 96, "unique_opening": 71 +} +``` +(Per-move sums are divided by `moves` on the Python side; `sum_depth` is divided by `sims`.) + +## Python side + +- **`run_selfplay_metrics(config)`** — a new function/module spawned by `train_v2.py` as an + `mp.Process`, alongside `create_benchmark_processes`, only when `config.self_play.program == + "rust"`. It `wandb.init(project=config.wandb.project, group=config.run_id, + job_type="selfplay", name=f"{config.run_id}-selfplay", id=f"{config.run_id}-selfplay", + resume="allow")` and `wandb.define_metric("*", "Model version")` (matching `benchmarks.py`). +- **Poll loop** — every few seconds: scan the metrics dir. A version `V` is **complete** when a + record file for some version `> V` exists, or `ShutdownSignal` is set. For each complete, + not-yet-logged `V`: read all `v{V}_pid*.json`, combine (sum the sums/counters, max the maxes, + sum the unique/total counts), compute final metrics, `wandb.log({…metrics…, "Model version": + V})`, and record `V` as logged. Exits on `ShutdownSignal`. +- Aggregation lives in a small pure helper (`aggregate_records(list[dict]) -> dict[str,float]`) + so it is unit-testable without W&B or the filesystem. + +## Decisions + +- **Opening length `K = 8` plies** (4 moves each). +- **Uniqueness: per-process dedup, summed across processes by Python.** With `num_processes=1` + (current setup) this is exact. With >1 it can slightly *over*count uniques (a game produced by + two processes counts twice), which is acceptable for a collapse-detection diagnostic and + avoids shipping large hash lists in the JSON. +- **No periodic heartbeat** — flush only at version boundaries and shutdown, one definitive + record per version, logged once. Model updates are frequent (≈ every training step), so the + in-progress version becoming visible only at the next update is acceptable. +- **Disabled unless `--metrics-dir` is passed**, so standalone/benchmark runs of the binary are + unaffected and there is zero overhead when off. + +## Edge cases + +- A version that produces zero games/searches before the next update: skip (don't log an empty + record, or log with zeros — implementation will log with whatever was accumulated; a version + with `moves == 0` is skipped to avoid divide-by-zero). +- Shutdown mid-version: the shutdown flush writes the final (partial) version so it isn't lost. +- Multiple processes desynced across versions: Python only finalizes `V` once a `> V` record + exists from *any* process; late records for an already-logged `V` are ignored (logged once). +- Metrics dir must be created by the Rust side on startup (like `tmp_dir`). + +## Testing + +- **Rust unit test**: drive `search()` with the existing stub coordinator and assert + `SearchStats` is sane (`sims == n`, `max_depth ≥ 1`, `top_move_visit_frac ∈ (0,1]`, + `entropy ≥ 0`). A game-hash test: two identical forced move-sequences hash equal; two + different ones do not. +- **Python unit test**: feed synthetic per-pid JSON records to `aggregate_records` and assert + weighted means, max-of-maxes, and summed unique/total counts are correct. + +## Out of scope (YAGNI) + +- Exact cross-process unique-game dedup (hash union). +- Periodic in-version heartbeat logging. +- Per-game or per-move time-series (only per-version aggregates). +- Logging from the legacy Python self-play path (`v2/self_play.py`) — Rust path only. From be190938c34edc996922631ea32fafb64fce5aef Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 20:50:15 -0400 Subject: [PATCH 03/30] vibe: add self-play MCTS metrics implementation plan Seven-task TDD plan: SearchStats from search(), per-game GameMetrics plus move hashing, a SelfPlayAccumulator that flushes per-version JSON records, wiring into the continuous loop's reload/shutdown points, and a Python aggregator + W&B logger process spawned by train_v2. --- .../plans/2026-05-26-selfplay-mcts-metrics.md | 1153 +++++++++++++++++ 1 file changed, 1153 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md diff --git a/docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md b/docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md new file mode 100644 index 00000000..14aebda9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-selfplay-mcts-metrics.md @@ -0,0 +1,1153 @@ +# Self-play MCTS Metrics → W&B 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:** Surface per-model-version MCTS diagnostics from Rust self-play (terminal/truncation sim fraction, tree depth, root-visit spread, nodes/branching, unique games) to a W&B run in the training group, reset on each model update. + +**Architecture:** Rust `search()` returns lightweight `SearchStats`; `play_game_async` folds them per game and hashes the move sequence; a per-process `SelfPlayAccumulator` aggregates by model version and flushes a JSON record to a metrics dir on each model-version change and on shutdown. A new Python process spawned by `train_v2.py` polls the dir, aggregates per-version records across processes, and logs to W&B (`group=run_id`, x-axis `Model version`). + +**Tech Stack:** Rust (edition 2024, `serde_json`, `clap`, `tokio`), Python (`wandb`, `pydantic` config), pytest, cargo test. + +**Spec:** `docs/superpowers/specs/2026-05-26-selfplay-mcts-metrics-design.md` + +--- + +## File structure + +- `deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs` — add `SearchStats`, return it from `search()`. (modify) +- `deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs` — add `GameMetrics` + move-sequence hashing; `play_game_async` returns `(GameResult, GameMetrics)`. (modify) +- `deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs` — `SelfPlayAccumulator` (fold + flush JSON). (create) +- `deep_quoridor/rust/src/agents/alphazero/mod.rs` — register new module. (modify) +- `deep_quoridor/rust/Cargo.toml` — add `serde_json` to the `binary` feature. (modify) +- `deep_quoridor/rust/src/bin/selfplay.rs` — `--metrics-dir` arg; create accumulator; fold per game; flush on version change + shutdown. (modify) +- `deep_quoridor/src/v2/selfplay_metrics.py` — `aggregate_records()` + `run_selfplay_metrics()`. (create) +- `deep_quoridor/src/v2/__init__.py` — export `run_selfplay_metrics`. (modify) +- `deep_quoridor/src/train_v2.py` — pass `--metrics-dir`, spawn the metrics process. (modify) +- `deep_quoridor/test/test_selfplay_metrics.py` — Python aggregator unit tests. (create) + +Build/test env note: Rust builds/tests that touch the eval pipeline need `--features binary,gpu` and, at runtime, the GPU env vars. For the tests in this plan that use the **stub coordinator** (no real ONNX), `--features binary` is enough and **no** env vars are needed. Run Rust commands with the sandbox disabled and long timeouts. Commit style (AGENTS.md): `vibe: ` imperative subject ≤50 chars; separate functional vs formatting commits; run `cargo fmt` before committing Rust. + +--- + +## Task 1: `SearchStats` from `search()` + +**Files:** +- Modify: `deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs` + +- [ ] **Step 1: Add the `SearchStats` struct** + +At the top of `selfplay_mcts.rs`, after the `LeafParallelConfig` struct (around line 30), add: + +```rust +/// Lightweight per-search diagnostics, accumulated cheaply during one `search()`. +#[derive(Debug, Clone, Copy, Default)] +pub struct SearchStats { + /// Number of MCTS simulations (selected leaves) this search. + pub sims: u32, + /// Simulations whose selected leaf was a win-terminal game state. + pub terminal_wins: u32, + /// Simulations whose selected leaf hit the max_steps cap. + pub truncations: u32, + /// Deepest selection path length (nodes from root to leaf, inclusive). + pub max_depth: u32, + /// Sum of selection-path lengths (divide by `sims` for mean depth). + pub sum_depth: u64, + /// Total nodes in the arena at search end. + pub nodes: u32, + /// Arena nodes that have at least one child (internal/expanded nodes). + pub internal_nodes: u32, + /// Entropy (nats) of the root child visit distribution. + pub root_visit_entropy: f64, + /// Fraction of root visits on the single most-visited child. + pub top_move_visit_frac: f64, +} +``` + +- [ ] **Step 2: Change `search()` signature and instrument it** + +In `selfplay_mcts.rs`, change the `search` return type (around line 102-107) from: + +```rust + pub async fn search( + &mut self, + root_data: CompactState, + mechanics: &QGameMechanics, + visited_states: &HashSet, + ) -> Result<(Vec, f32)> { +``` + +to: + +```rust + pub async fn search( + &mut self, + root_data: CompactState, + mechanics: &QGameMechanics, + visited_states: &HashSet, + ) -> Result<(Vec, f32, SearchStats)> { +``` + +Immediately before the `let mut iters_done: u32 = 0;` line (around line 147), add: + +```rust + let mut stats = SearchStats::default(); +``` + +Inside the selection loop `for _ in 0..outer { ... }`, right after `let leaf_data = arena.get(leaf_idx).data;` (around line 177), add depth/sim accounting: + +```rust + let depth = path.len() as u32; + stats.sims += 1; + stats.sum_depth += depth as u64; + if depth > stats.max_depth { + stats.max_depth = depth; + } +``` + +In the same loop, in the win-terminal branch (where `Item::Terminal { path, value: v }` with `v = 1.0` is pushed for `mechanics.winner(...).is_some()`), increment after pushing: + +```rust + items.push(Item::Terminal { path, value: v }); + if v > 0.0 { + stats.terminal_wins += 1; + } else { + stats.truncations += 1; + } + continue; +``` + +And in the explicit max_steps branch (`items.push(Item::Terminal { path, value: 0.0 });` under `if let Some(max) = self.cfg.max_steps`), add `stats.truncations += 1;` before its `continue;`. + +(Note: the win branch already computes `v` as `1.0` for a winner else `0.0`; the `v > 0.0` test above classifies win vs draw-terminal. The separate max_steps branch is always a truncation.) + +- [ ] **Step 3: Compute spread + node stats and return them** + +Replace the children-extraction tail of `search()` (around line 275-300) — the block that builds `children` and ends with `Ok((children, computed_root_value))` — so it computes the spread/node stats and returns the 3-tuple: + +```rust + // Extract children info. + let bs = mechanics.repr().board_size() as i32; + let root = arena.get(0); + let computed_root_value = if root.visit_count > 0 { + -(root.value_sum / root.visit_count as f64) as f32 + } else { + root_value + }; + let children: Vec = root + .children + .iter() + .map(|&ci| { + let c = arena.get(ci); + let ai = c.action_index.expect("child node must have action_index"); + ChildInfo { + action: crate::actions::action_index_to_action(bs, ai), + action_index: ai, + visit_count: c.visit_count, + } + }) + .collect(); + + // Root visit spread (entropy in nats + top-move fraction). + let total_visits: u64 = children.iter().map(|c| c.visit_count as u64).sum(); + if total_visits > 0 { + let mut entropy = 0.0f64; + let mut max_v = 0u32; + for c in &children { + if c.visit_count > 0 { + let p = c.visit_count as f64 / total_visits as f64; + entropy -= p * p.ln(); + if c.visit_count > max_v { + max_v = c.visit_count; + } + } + } + stats.root_visit_entropy = entropy; + stats.top_move_visit_frac = max_v as f64 / total_visits as f64; + } + stats.nodes = arena.len() as u32; + stats.internal_nodes = (0..arena.len()) + .filter(|&i| !arena.get(i).children.is_empty()) + .count() as u32; + + // Stash the arena for tree reuse on the next call. + self.arena = Some(arena); + + Ok((children, computed_root_value, stats)) +``` + +- [ ] **Step 4: Update the 4 in-module tests to destructure the 3-tuple** + +In the `#[cfg(test)] mod tests` of `selfplay_mcts.rs`, the four `mcts.search(...).await.unwrap()` calls currently bind `(children, _root_value)` / `(children, _)`. Change each to add a third binding `_stats`: +- `let (children, _root_value, _stats) = mcts.search(data, &mech, &visited).await.unwrap();` (test_leaf_parallel_k1...) +- `let (children, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap();` (test_leaf_parallel_k8...) +- `let (children_1, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap();` and `let (children_2, _, _stats) = mcts.search(next_state, &mech, &visited).await.unwrap();` (test_tree_reuse...) +- `let _ = mcts.search(data, &mech, &visited).await.unwrap();` (test_note_model_version... — unchanged, already discards) + +- [ ] **Step 5: Add a `SearchStats` sanity test** + +Append this test inside the `mod tests` block of `selfplay_mcts.rs` (reuses the existing `spawn_stub_coordinator`): + +```rust + #[test] + fn test_search_stats_are_sane() { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let mech = QGameMechanics::new(5, 0, 200); + let data = mech.create_initial_state(); + let cache = Arc::new(EvalCache::new()); + let (tx, rx) = tokio_mpsc::channel::(128); + let stub = spawn_stub_coordinator(rx, Arc::clone(&cache)); + + let mcts_cfg = MCTSConfig { + n: Some(40), + ucb_c: 1.4, + noise_epsilon: 0.0, + ..Default::default() + }; + let lp_cfg = LeafParallelConfig { + leaf_parallelism: 4, + virtual_loss: 1, + enable_tree_reuse: false, + }; + let mut mcts = LeafParallelMCTS::new(mcts_cfg, lp_cfg, tx.clone(), Arc::clone(&cache)); + let visited = std::collections::HashSet::new(); + let (_children, _v, stats) = mcts.search(data, &mech, &visited).await.unwrap(); + + assert_eq!(stats.sims, 40, "sims should equal mcts_n"); + assert!(stats.max_depth >= 1, "max_depth must be >= 1"); + assert!(stats.sum_depth >= stats.sims as u64, "each sim has depth >= 1"); + assert!(stats.nodes >= 1); + assert!(stats.internal_nodes >= 1); + assert!(stats.root_visit_entropy >= 0.0); + assert!( + stats.top_move_visit_frac > 0.0 && stats.top_move_visit_frac <= 1.0, + "top_move_visit_frac in (0,1], got {}", + stats.top_move_visit_frac + ); + + drop(mcts); + drop(tx); + let _ = stub.await; + }); + } +``` + +- [ ] **Step 6: Build + run the tests** + +Run: +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary selfplay_mcts -- --nocapture +``` +Expected: all `selfplay_mcts::tests::*` pass, including `test_search_stats_are_sane`, printing no failures. + +- [ ] **Step 7: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs +git commit -m "vibe: return per-search MCTS SearchStats" +``` + +--- + +## Task 2: `GameMetrics` + move hashing in `play_game_async` + +**Files:** +- Modify: `deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs` +- Modify: `deep_quoridor/rust/src/bin/selfplay.rs` (two `play_game_async` call sites) + +- [ ] **Step 1: Add `GameMetrics`, the opening constant, and the hash helper** + +In `selfplay_game.rs`, after the `GameSettings` struct (around line 74), add: + +```rust +/// Number of leading plies that define a game's "opening" for uniqueness. +pub const OPENING_PLIES: usize = 8; + +/// Per-game MCTS diagnostics, summed over the game's searches, plus move-sequence hashes. +#[derive(Debug, Clone, Default)] +pub struct GameMetrics { + pub sims: u64, + pub terminal_wins: u64, + pub truncations: u64, + pub max_depth: u32, + pub sum_depth: u64, + pub moves: u64, + pub sum_root_entropy: f64, + pub sum_top_move_frac: f64, + pub sum_nodes: u64, + pub sum_internal_nodes: u64, + pub full_hash: u64, + pub opening_hash: u64, +} + +/// Deterministic (within-process) hash of a move-index sequence. +fn hash_actions(actions: &[usize]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + actions.hash(&mut h); + h.finish() +} +``` + +Add the `SearchStats` import to the existing `use` of `selfplay_mcts` at the top: + +```rust +use crate::agents::alphazero::selfplay_mcts::{LeafParallelMCTS, SearchStats}; +``` + +- [ ] **Step 2: Make `run_az_select` return its `SearchStats`** + +In `selfplay_game.rs`, change `run_az_select` to thread the stats out. Its first line becomes: + +```rust + let (children, _root_value, stats) = mcts.search(data, mechanics, visited).await?; +``` + +and its return type and final expression: + +```rust +) -> Result<(usize, Vec, SearchStats)> { +``` +... +```rust + Ok((action_idx, policy, stats)) +``` + +- [ ] **Step 3: Fold stats + record moves in `play_game_async`, return `(GameResult, GameMetrics)`** + +Change the `play_game_async` return type: + +```rust +) -> Result<(GameResult, GameMetrics)> { +``` + +After `let mut winner: Option = None;` (around line 90), add: + +```rust + let mut gm = GameMetrics::default(); + let mut actions: Vec = Vec::new(); +``` + +Replace the action-selection block (the `let (action_idx, policy) = if current_player == 0 { ... } else { ... };`, around line 101-110) with a version that captures optional stats: + +```rust + let (action_idx, policy, stats) = if current_player == 0 { + let (a, p, s) = + run_az_select(p1, data, &mechanics, &visited, settings, step as usize).await?; + (a, p, Some(s)) + } else { + match p2 { + P2::AlphaZero(m) => { + let (a, p, s) = + run_az_select(m, data, &mechanics, &visited, settings, step as usize) + .await?; + (a, p, Some(s)) + } + P2::Random => { + let (a, p) = random_select(&mask); + (a, p, None) + } + } + }; + if let Some(s) = stats { + gm.moves += 1; + gm.sims += s.sims as u64; + gm.terminal_wins += s.terminal_wins as u64; + gm.truncations += s.truncations as u64; + gm.max_depth = gm.max_depth.max(s.max_depth); + gm.sum_depth += s.sum_depth; + gm.sum_root_entropy += s.root_visit_entropy; + gm.sum_top_move_frac += s.top_move_visit_frac; + gm.sum_nodes += s.nodes as u64; + gm.sum_internal_nodes += s.internal_nodes as u64; + } + actions.push(action_idx); +``` + +- [ ] **Step 4: Single return point with hashes** + +Replace the win-branch early return (around line 143-157) so it sets the winner and `break`s instead of returning: + +```rust + if mechanics.check_win(data, current_player as usize) { + winner = Some(current_player); + break; + } +``` + +Then replace the function's tail (the final `Ok(GameResult { winner, num_turns: max_steps, replay_items })`, around line 159-163) with value assignment + hashing + a single tuple return: + +```rust + if let Some(w) = winner { + for item in replay_items.iter_mut() { + item.value = if item.player == w { 1.0 } else { -1.0 }; + } + } + gm.full_hash = hash_actions(&actions); + gm.opening_hash = hash_actions(&actions[..actions.len().min(OPENING_PLIES)]); + let num_turns = if winner.is_some() { + actions.len() as i32 + } else { + max_steps + }; + Ok(( + GameResult { + winner, + num_turns, + replay_items, + }, + gm, + )) +``` + +(This preserves the prior behavior: on a win, `num_turns` is the number of moves played; on truncation, `max_steps`. Value targets are ±1 from the winner's perspective, identical to before.) + +- [ ] **Step 5: Update the two call sites in `selfplay.rs`** + +In `run_continuous_batched` (around line 659): change +```rust + let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; + write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?; +``` +to +```rust + let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; + write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?; +``` +(The `_game_metrics` binding is replaced with real folding in Task 4; keep the underscore for now so this task builds independently.) + +In the batch-mode game loop inline in `main` (the non-`--continuous` path, around line 451): change +```rust + let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; +``` +to +```rust + let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; +``` + +- [ ] **Step 6: Add a hashing unit test** + +Append to the bottom of `selfplay_game.rs` (create a `#[cfg(test)] mod tests` block if none exists): + +```rust +#[cfg(test)] +mod tests { + use super::{hash_actions, OPENING_PLIES}; + + #[test] + fn identical_sequences_hash_equal_different_differ() { + let a = vec![3usize, 7, 1, 9, 2, 4, 8, 0, 5, 6]; + let b = a.clone(); + let mut c = a.clone(); + c[9] = 99; // differs only after the opening + + assert_eq!(hash_actions(&a), hash_actions(&b), "identical games hash equal"); + assert_ne!(hash_actions(&a), hash_actions(&c), "different full games differ"); + + // Same opening (first OPENING_PLIES), different tail -> opening hashes equal. + let open_a = hash_actions(&a[..a.len().min(OPENING_PLIES)]); + let open_c = hash_actions(&c[..c.len().min(OPENING_PLIES)]); + assert_eq!(open_a, open_c, "same opening hashes equal"); + } +} +``` + +- [ ] **Step 7: Build + test** + +Run: +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary selfplay_game -- --nocapture && cargo build --no-default-features --features binary --bin selfplay +``` +Expected: the hashing test passes and the `selfplay` binary builds (confirms both call sites updated). + +- [ ] **Step 8: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs deep_quoridor/rust/src/bin/selfplay.rs +git commit -m "vibe: collect per-game MCTS metrics and move hashes" +``` + +--- + +## Task 3: `SelfPlayAccumulator` + JSON flush + +**Files:** +- Create: `deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs` +- Modify: `deep_quoridor/rust/src/agents/alphazero/mod.rs` +- Modify: `deep_quoridor/rust/Cargo.toml` + +- [ ] **Step 1: Add `serde_json` to the `binary` feature** + +In `deep_quoridor/rust/Cargo.toml`, under `[dependencies]` add (near the other optional deps): + +```toml +serde_json = { version = "1", optional = true } +``` + +and add `"serde_json"` to the `binary` feature list, so it reads: + +```toml +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"] +``` + +- [ ] **Step 2: Create the accumulator module** + +Create `deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs`: + +```rust +//! Per-process self-play metric accumulator. +//! +//! Folds `GameMetrics` for the current model version, then flushes a raw-aggregate +//! JSON record per `(version, pid)` so the Python side can combine processes and +//! compute final metrics. Reset happens on each model-version change and on shutdown. + +use std::collections::HashSet; + +use anyhow::{Context, Result}; + +use crate::agents::alphazero::selfplay_game::GameMetrics; + +/// Running raw aggregates for one model version within one process. +pub struct SelfPlayAccumulator { + version: i64, + sims: u64, + terminal_wins: u64, + truncations: u64, + max_depth: u32, + sum_depth: u64, + moves: u64, + sum_root_entropy: f64, + sum_top_move_frac: f64, + sum_nodes: u64, + sum_internal_nodes: u64, + games_generated: u64, + full_hashes: HashSet, + opening_hashes: HashSet, +} + +impl SelfPlayAccumulator { + pub fn new(version: i64) -> Self { + Self { + version, + sims: 0, + terminal_wins: 0, + truncations: 0, + max_depth: 0, + sum_depth: 0, + moves: 0, + sum_root_entropy: 0.0, + sum_top_move_frac: 0.0, + sum_nodes: 0, + sum_internal_nodes: 0, + games_generated: 0, + full_hashes: HashSet::new(), + opening_hashes: HashSet::new(), + } + } + + pub fn set_version(&mut self, v: i64) { + self.version = v; + } + + pub fn fold_game(&mut self, gm: &GameMetrics) { + self.sims += gm.sims; + self.terminal_wins += gm.terminal_wins; + self.truncations += gm.truncations; + self.max_depth = self.max_depth.max(gm.max_depth); + self.sum_depth += gm.sum_depth; + self.moves += gm.moves; + self.sum_root_entropy += gm.sum_root_entropy; + self.sum_top_move_frac += gm.sum_top_move_frac; + self.sum_nodes += gm.sum_nodes; + self.sum_internal_nodes += gm.sum_internal_nodes; + self.games_generated += 1; + self.full_hashes.insert(gm.full_hash); + self.opening_hashes.insert(gm.opening_hash); + } + + fn clear_counts(&mut self) { + self.sims = 0; + self.terminal_wins = 0; + self.truncations = 0; + self.max_depth = 0; + self.sum_depth = 0; + self.moves = 0; + self.sum_root_entropy = 0.0; + self.sum_top_move_frac = 0.0; + self.sum_nodes = 0; + self.sum_internal_nodes = 0; + self.games_generated = 0; + self.full_hashes.clear(); + self.opening_hashes.clear(); + } + + fn to_json(&self, pid: u32) -> serde_json::Value { + serde_json::json!({ + "model_version": self.version, + "pid": pid, + "sims": self.sims, + "terminal_wins": self.terminal_wins, + "truncations": self.truncations, + "max_depth": self.max_depth, + "sum_depth": self.sum_depth, + "moves": self.moves, + "sum_root_entropy": self.sum_root_entropy, + "sum_top_move_frac": self.sum_top_move_frac, + "sum_nodes": self.sum_nodes, + "sum_internal_nodes": self.sum_internal_nodes, + "games_generated": self.games_generated, + "unique_full": self.full_hashes.len(), + "unique_opening": self.opening_hashes.len(), + }) + } + + /// Write the current version's record to `/v{version}_pid{pid}.json` (atomic + /// tmp+rename), then clear counts. No-op (just clears) when nothing was accumulated. + pub fn flush_and_reset(&mut self, dir: &str, pid: u32) -> Result<()> { + if self.moves == 0 && self.games_generated == 0 { + self.clear_counts(); + return Ok(()); + } + let path = format!("{}/v{}_pid{}.json", dir, self.version, pid); + let tmp = format!("{}.tmp", path); + let bytes = serde_json::to_vec(&self.to_json(pid)).context("serialize metrics record")?; + std::fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp))?; + std::fs::rename(&tmp, &path).with_context(|| format!("rename to {}", path))?; + self.clear_counts(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::selfplay_game::GameMetrics; + + fn sample_game(full: u64, opening: u64) -> GameMetrics { + GameMetrics { + sims: 100, + terminal_wins: 5, + truncations: 2, + max_depth: 10, + sum_depth: 300, + moves: 20, + sum_root_entropy: 12.0, + sum_top_move_frac: 8.0, + sum_nodes: 500, + sum_internal_nodes: 250, + full_hash: full, + opening_hash: opening, + } + } + + #[test] + fn flush_writes_expected_aggregates() { + let dir = std::env::temp_dir().join(format!("spm_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dir_s = dir.to_str().unwrap().to_string(); + + let mut acc = SelfPlayAccumulator::new(7); + acc.fold_game(&sample_game(1, 100)); // unique full=1, opening=100 + acc.fold_game(&sample_game(2, 100)); // distinct full, same opening + acc.fold_game(&sample_game(2, 100)); // duplicate of the previous + acc.flush_and_reset(&dir_s, 4242).unwrap(); + + let path = format!("{}/v7_pid4242.json", dir_s); + let v: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(v["model_version"], 7); + assert_eq!(v["games_generated"], 3); + assert_eq!(v["sims"], 300); + assert_eq!(v["unique_full"], 2); // hashes {1,2} + assert_eq!(v["unique_opening"], 1); // hashes {100} + assert_eq!(v["max_depth"], 10); + + // After flush the accumulator is empty: a second flush writes nothing new. + std::fs::remove_file(&path).unwrap(); + acc.flush_and_reset(&dir_s, 4242).unwrap(); + assert!(!std::path::Path::new(&path).exists(), "empty flush writes nothing"); + + std::fs::remove_dir_all(&dir).ok(); + } +} +``` + +- [ ] **Step 3: Register the module** + +In `deep_quoridor/rust/src/agents/alphazero/mod.rs`, add alongside the other `pub mod` lines: + +```rust +pub mod selfplay_metrics; +``` + +- [ ] **Step 4: Build + test** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary selfplay_metrics -- --nocapture +``` +Expected: `flush_writes_expected_aggregates` passes (downloads `serde_json` on first build). + +- [ ] **Step 5: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/Cargo.toml deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs deep_quoridor/rust/src/agents/alphazero/mod.rs +git commit -m "vibe: add SelfPlayAccumulator with JSON metric flush" +``` + +--- + +## Task 4: Wire metrics into the continuous self-play loop + +**Files:** +- Modify: `deep_quoridor/rust/src/bin/selfplay.rs` + +- [ ] **Step 1: Add the `--metrics-dir` CLI arg** + +In the `Cli` struct (`selfplay.rs`, after the `profile_counters` field around line 125), add: + +```rust + /// Directory to write per-model-version MCTS metric JSON records. When omitted, + /// metric collection is disabled. + #[arg(long)] + metrics_dir: Option, +``` + +- [ ] **Step 2: Create the accumulator before the game tasks spawn** + +In `run_continuous_batched`, inside the `rt.block_on(async move { ... })`, after `let pid = std::process::id();` (around line 595), add: + +```rust + use quoridor_rs::agents::alphazero::selfplay_metrics::SelfPlayAccumulator; + let metrics_dir = cli.metrics_dir.clone(); + if let Some(ref d) = metrics_dir { + std::fs::create_dir_all(d)?; + } + let metrics = std::sync::Arc::new(std::sync::Mutex::new(SelfPlayAccumulator::new( + initial_version, + ))); +``` + +- [ ] **Step 3: Clone the handle into the game-task closure and fold each game** + +In the `for _tid in 0..rust_cfg.games_per_process { ... }` loop, alongside the other `let ... = std::sync::Arc::clone(&...)` clones (around line 632-643), add: + +```rust + let metrics = std::sync::Arc::clone(&metrics); + let metrics_enabled = metrics_dir.is_some(); +``` + +Then change the game body (the `_game_metrics` binding added in Task 2, around line 659) to fold when enabled: + +```rust + let (result, game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; + write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?; + if metrics_enabled { + metrics.lock().unwrap().fold_game(&game_metrics); + } +``` + +- [ ] **Step 4: Flush on version change and on shutdown in the poll task** + +Clone the handle for the poll task. Alongside the poll task's existing clones (around line 668-670), add: + +```rust + let metrics = std::sync::Arc::clone(&metrics); + let metrics_dir = metrics_dir.clone(); + let pid_for_metrics = pid; +``` + +In the poll task body, in the new-model branch (right after `model_version.store(latest.version, ...)`, around line 685), flush the just-finished version then point the accumulator at the new one: + +```rust + if let Some(ref d) = metrics_dir { + let mut m = metrics.lock().unwrap(); + if let Err(e) = m.flush_and_reset(d, pid_for_metrics) { + eprintln!("selfplay-metrics: flush failed: {:#}", e); + } + m.set_version(latest.version); + } +``` + +And in the shutdown branch (right after `shutdown.store(true, ...)`, around line 676), flush the final partial version: + +```rust + if let Some(ref d) = metrics_dir { + let mut m = metrics.lock().unwrap(); + if let Err(e) = m.flush_and_reset(d, pid_for_metrics) { + eprintln!("selfplay-metrics: final flush failed: {:#}", e); + } + } +``` + +- [ ] **Step 5: Build** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin selfplay +``` +Expected: builds with no errors or warnings. + +- [ ] **Step 6: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/bin/selfplay.rs +git commit -m "vibe: flush self-play MCTS metrics per model version" +``` + +--- + +## Task 5: Python aggregator + logger process + +**Files:** +- Create: `deep_quoridor/src/v2/selfplay_metrics.py` +- Create: `deep_quoridor/test/test_selfplay_metrics.py` + +- [ ] **Step 1: Write the failing aggregator test** + +Create `deep_quoridor/test/test_selfplay_metrics.py`: + +```python +import math + +from v2.selfplay_metrics import aggregate_records + + +def _record(**kw): + base = dict( + model_version=3, pid=1, sims=0, terminal_wins=0, truncations=0, + max_depth=0, sum_depth=0, moves=0, sum_root_entropy=0.0, + sum_top_move_frac=0.0, sum_nodes=0, sum_internal_nodes=0, + games_generated=0, unique_full=0, unique_opening=0, + ) + base.update(kw) + return base + + +def test_aggregate_combines_two_processes(): + r1 = _record( + sims=100, terminal_wins=10, truncations=5, max_depth=12, sum_depth=400, + moves=20, sum_root_entropy=20.0, sum_top_move_frac=10.0, sum_nodes=600, + sum_internal_nodes=300, games_generated=2, unique_full=2, unique_opening=1, + ) + r2 = _record( + sims=300, terminal_wins=30, truncations=15, max_depth=18, sum_depth=1200, + moves=60, sum_root_entropy=66.0, sum_top_move_frac=36.0, sum_nodes=1800, + sum_internal_nodes=900, games_generated=6, unique_full=5, unique_opening=2, + ) + agg = aggregate_records([r1, r2]) + + sims, moves = 400, 80 + assert agg["selfplay/terminal_sim_frac"] == 40 / sims + assert agg["selfplay/truncation_sim_frac"] == 20 / sims + assert agg["selfplay/max_tree_depth"] == 18 + assert agg["selfplay/mean_tree_depth"] == 1600 / sims + assert agg["selfplay/root_visit_entropy"] == 86.0 / moves + assert agg["selfplay/root_visit_perplexity"] == math.exp(86.0 / moves) + assert agg["selfplay/top_move_visit_frac"] == 46.0 / moves + assert agg["selfplay/mean_nodes_per_search"] == 2400 / moves + assert agg["selfplay/mean_branching"] == (2400 - moves) / 1200 + assert agg["selfplay/games_generated"] == 8 + assert agg["selfplay/unique_games_full"] == 7 + assert agg["selfplay/unique_games_opening"] == 3 + assert agg["selfplay/unique_frac_full"] == 7 / 8 + assert agg["selfplay/unique_frac_opening"] == 3 / 8 + + +def test_aggregate_skips_empty(): + assert aggregate_records([_record(moves=0, sims=0)]) is None +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole && PYTHONPATH=$PYTHONPATH:$(pwd)/deep_quoridor/src .venv/bin/python -m pytest deep_quoridor/test/test_selfplay_metrics.py -q +``` +Expected: FAIL with `ModuleNotFoundError: No module named 'v2.selfplay_metrics'`. + +- [ ] **Step 3: Implement the module** + +Create `deep_quoridor/src/v2/selfplay_metrics.py`: + +```python +"""Self-play MCTS metrics: aggregate per-process Rust JSON records and log to W&B. + +The Rust self-play binary writes one raw-aggregate record per (model_version, pid) +to a metrics directory. This module combines a version's records across processes, +computes the final metrics, and logs them to a W&B run in the training group. +""" +import glob +import json +import math +import os +import re +import time +from typing import Optional + +import wandb + +from v2.common import MockWandb, ShutdownSignal +from v2.config import Config + +_FILE_RE = re.compile(r"v(\d+)_pid\d+\.json$") + + +def metrics_dir_for(config: Config) -> str: + """Directory shared by the Rust writer and this reader.""" + return str(config.paths.run_dir / "selfplay_metrics") + + +def aggregate_records(records: list[dict]) -> Optional[dict]: + """Combine per-process raw records for one model version into final metrics. + + Returns None if no searches happened (nothing to log). + """ + sims = sum(r["sims"] for r in records) + moves = sum(r["moves"] for r in records) + if moves == 0 or sims == 0: + return None + + sum_entropy = sum(r["sum_root_entropy"] for r in records) + sum_nodes = sum(r["sum_nodes"] for r in records) + sum_internal = sum(r["sum_internal_nodes"] for r in records) + games = sum(r["games_generated"] for r in records) + unique_full = sum(r["unique_full"] for r in records) + unique_opening = sum(r["unique_opening"] for r in records) + mean_entropy = sum_entropy / moves + + out = { + "selfplay/terminal_sim_frac": sum(r["terminal_wins"] for r in records) / sims, + "selfplay/truncation_sim_frac": sum(r["truncations"] for r in records) / sims, + "selfplay/max_tree_depth": max(r["max_depth"] for r in records), + "selfplay/mean_tree_depth": sum(r["sum_depth"] for r in records) / sims, + "selfplay/root_visit_entropy": mean_entropy, + "selfplay/root_visit_perplexity": math.exp(mean_entropy), + "selfplay/top_move_visit_frac": sum(r["sum_top_move_frac"] for r in records) / moves, + "selfplay/mean_nodes_per_search": sum_nodes / moves, + "selfplay/mean_branching": (sum_nodes - moves) / sum_internal if sum_internal else 0.0, + "selfplay/games_generated": games, + "selfplay/unique_games_full": unique_full, + "selfplay/unique_games_opening": unique_opening, + "selfplay/unique_frac_full": unique_full / games if games else 0.0, + "selfplay/unique_frac_opening": unique_opening / games if games else 0.0, + } + return out + + +def _scan(metrics_dir: str) -> dict[int, list[str]]: + """Map model_version -> list of record file paths present on disk.""" + by_version: dict[int, list[str]] = {} + for path in glob.glob(os.path.join(metrics_dir, "v*_pid*.json")): + m = _FILE_RE.search(os.path.basename(path)) + if m: + by_version.setdefault(int(m.group(1)), []).append(path) + return by_version + + +def _load(paths: list[str]) -> list[dict]: + records = [] + for p in paths: + try: + with open(p) as f: + records.append(json.load(f)) + except (OSError, json.JSONDecodeError): + continue # mid-write or transient; picked up on a later poll + return records + + +def run_selfplay_metrics(config: Config, poll_seconds: float = 5.0): + """Poll the metrics dir and log each completed model version to W&B once.""" + metrics_dir = metrics_dir_for(config) + os.makedirs(metrics_dir, exist_ok=True) + + if config.wandb: + run_id = f"{config.run_id}-selfplay" + wandb_run = wandb.init( + project=config.wandb.project, + job_type="selfplay", + group=config.run_id, + name=run_id, + id=run_id, + resume="allow", + ) + wandb.define_metric("Model version", hidden=True) + wandb.define_metric("*", "Model version") + else: + wandb_run = MockWandb() + + logged: set[int] = set() + + def flush(finalize_all: bool): + by_version = _scan(metrics_dir) + if not by_version: + return + max_version = max(by_version) + for version in sorted(by_version): + if version in logged: + continue + # A version is complete once a newer version exists (the writer moved on) + # or we're finalizing on shutdown. + if not finalize_all and version >= max_version: + continue + agg = aggregate_records(_load(by_version[version])) + if agg is not None: + agg["Model version"] = version + wandb_run.log(agg) + logged.add(version) + + while not ShutdownSignal.is_set(config): + flush(finalize_all=False) + time.sleep(poll_seconds) + + flush(finalize_all=True) # log the last in-progress version on shutdown +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole && PYTHONPATH=$PYTHONPATH:$(pwd)/deep_quoridor/src .venv/bin/python -m pytest deep_quoridor/test/test_selfplay_metrics.py -q +``` +Expected: `2 passed`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/src/v2/selfplay_metrics.py deep_quoridor/test/test_selfplay_metrics.py +git commit -m "vibe: add self-play metrics aggregator and W&B logger" +``` + +--- + +## Task 6: Spawn the logger and pass `--metrics-dir` from `train_v2.py` + +**Files:** +- Modify: `deep_quoridor/src/v2/__init__.py` +- Modify: `deep_quoridor/src/train_v2.py` + +- [ ] **Step 1: Export `run_selfplay_metrics` from the package** + +In `deep_quoridor/src/v2/__init__.py`, add to `__all__` (the list that includes `"create_benchmark_processes"`): + +```python + "run_selfplay_metrics", +``` + +and add the import (next to `from v2.benchmarks import create_benchmark_processes`): + +```python +from v2.selfplay_metrics import metrics_dir_for, run_selfplay_metrics +``` + +Also add `"metrics_dir_for"` to `__all__`. + +- [ ] **Step 2: Import in `train_v2.py` and pass `--metrics-dir` to the Rust subprocess** + +In `deep_quoridor/src/train_v2.py`, update the `from v2 import (...)` line (line 8) to also import the two new names: + +```python +from v2 import ( + benchmarks, + check_ai_available, + load_config_and_setup_run, + metrics_dir_for, + run_ai_reporter, + run_selfplay_metrics, + self_play, + train, +) +``` + +In the rust-spawn block (`if config.self_play.program == "rust":`, around line 93), before the `for i in range(...)` loop, compute the metrics dir: + +```python + selfplay_env = _selfplay_subprocess_env() + if selfplay_env is not None: + print(f"Self-play GPU env: ORT_DYLIB_PATH={selfplay_env['ORT_DYLIB_PATH']}") + metrics_dir = metrics_dir_for(config) + os.makedirs(metrics_dir, exist_ok=True) + config_file_path = str(config.paths.config_file) +``` + +and add the two args to `cmd` (inside the list, after the `--shutdown-file` pair): + +```python + "--shutdown-file", + str(ShutdownSignal.file_path(config)), + "--metrics-dir", + metrics_dir, +``` + +- [ ] **Step 3: Spawn the metrics logger process (rust path only)** + +Still inside the `if config.self_play.program == "rust":` block, after the `for` loop that starts the rust subprocesses (after the `print(f"Started Rust self-play process {proc.pid}")` line), add: + +```python + selfplay_metrics_process = mp.Process(target=run_selfplay_metrics, args=[config]) + selfplay_metrics_process.start() + self_play_processes.append(selfplay_metrics_process) +``` + +(Appending to `self_play_processes` means the existing shutdown/`is_alive()` accounting at lines 128-129 already tracks and waits on it.) + +- [ ] **Step 4: Syntax check** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor && PYTHONPATH=$(pwd)/src ../.venv/bin/python -m py_compile src/train_v2.py src/v2/__init__.py src/v2/selfplay_metrics.py && echo PY_OK +``` +Expected: `PY_OK`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/src/v2/__init__.py deep_quoridor/src/train_v2.py +git commit -m "vibe: spawn self-play metrics logger from train_v2" +``` + +--- + +## Task 7: Formatting commit (per AGENTS.md) + +- [ ] **Step 1: Format Rust and Python** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo fmt && cargo fmt --check && echo FMT_OK +cd /home/jbinney/ws/deep_rabbit_hole && .venv/bin/ruff format deep_quoridor/src/v2/selfplay_metrics.py deep_quoridor/test/test_selfplay_metrics.py deep_quoridor/src/train_v2.py deep_quoridor/src/v2/__init__.py 2>/dev/null || true +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin selfplay +``` +Expected: `FMT_OK`, build succeeds. + +- [ ] **Step 2: Commit only if formatting changed files** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git status --short deep_quoridor/rust deep_quoridor/src +# If any files changed: +git add -u deep_quoridor/rust deep_quoridor/src +git commit -m "vibe: cargo fmt + ruff format" +``` +If nothing changed, skip the commit. + +--- + +## Manual end-to-end verification (after all tasks) + +Not a unit test — a smoke check the implementer should run once with a real model and the GPU env exported (`ORT_DYLIB_PATH` + `LD_LIBRARY_PATH` as in prior work), building with `--features binary,gpu`: + +1. Start `train_v2.py` on the b9w10 config with a fresh `run_id` for a few minutes (enough for ≥2 model updates). +2. Confirm JSON records appear under `runs//selfplay_metrics/` named `v{N}_pid{PID}.json`. +3. Confirm a W&B run `${run_id}-selfplay` exists in group `${run_id}` with `selfplay/*` metrics plotted against `Model version`. +4. Sanity-check values: `terminal_sim_frac > 0`, `max_tree_depth ≥ mean_tree_depth ≥ 1`, `top_move_visit_frac ∈ (0,1]`, `unique_frac_full` near 1.0 early on. + +## Self-review (completed during authoring) + +- **Spec coverage:** terminal/truncation frac, max+mean depth, root entropy/perplexity, top-move frac, nodes/branching (Task 1 + Task 5 aggregation); unique full+opening with K=8 (Task 2 `OPENING_PLIES`, per-process dedup in Task 3, summed in Task 5); reset-on-model-update + shutdown flush (Task 4); file-based bridge + Python logger in the run group, x-axis Model version (Tasks 3/5/6); disabled unless `--metrics-dir` (Task 4); testing (Tasks 1/2/3/5) — all mapped. +- **Placeholder scan:** none — every step has concrete code/commands. +- **Type consistency:** `SearchStats` (Task 1) fields are consumed exactly by `GameMetrics` folding (Task 2) and `SelfPlayAccumulator.fold_game` (Task 3); JSON keys written by `to_json` (Task 3) match those read by `aggregate_records` (Task 5) and the test record (Task 5); `metrics_dir_for` defined in Task 5 is used in Task 6; `play_game_async`'s `(GameResult, GameMetrics)` return is consistently destructured at both call sites (Task 2) and folded (Task 4). From c5befa0050d945b6cad1488834c099fcdd7f7957 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 21:31:41 -0400 Subject: [PATCH 04/30] vibe: return per-search MCTS SearchStats Co-Authored-By: Claude Sonnet 4.6 --- .../src/agents/alphazero/selfplay_game.rs | 2 +- .../src/agents/alphazero/selfplay_mcts.rs | 118 +++++++++++++++++- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs index 1044b9ac..af9402b2 100644 --- a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs @@ -171,7 +171,7 @@ async fn run_az_select( settings: GameSettings, step: usize, ) -> Result<(usize, Vec)> { - let (children, _root_value) = mcts.search(data, mechanics, visited).await?; + let (children, _root_value, _stats) = mcts.search(data, mechanics, visited).await?; let visit_counts: Vec = children.iter().map(|c| c.visit_count).collect(); let action_indices: Vec = children.iter().map(|c| c.action_index).collect(); let temperature = match settings.drop_t_on_step { diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs index d7988caf..32277236 100644 --- a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs @@ -29,6 +29,29 @@ pub struct LeafParallelConfig { pub enable_tree_reuse: bool, } +/// Lightweight per-search diagnostics, accumulated cheaply during one `search()`. +#[derive(Debug, Clone, Copy, Default)] +pub struct SearchStats { + /// Number of MCTS simulations (selected leaves) this search. + pub sims: u32, + /// Simulations whose selected leaf was a win-terminal game state. + pub terminal_wins: u32, + /// Simulations whose selected leaf hit the max_steps cap. + pub truncations: u32, + /// Deepest selection path length (nodes from root to leaf, inclusive). + pub max_depth: u32, + /// Sum of selection-path lengths (divide by `sims` for mean depth). + pub sum_depth: u64, + /// Total nodes in the arena at search end. + pub nodes: u32, + /// Arena nodes that have at least one child (internal/expanded nodes). + pub internal_nodes: u32, + /// Entropy (nats) of the root child visit distribution. + pub root_visit_entropy: f64, + /// Fraction of root visits on the single most-visited child. + pub top_move_visit_frac: f64, +} + /// One LeafParallelMCTS per game agent. Lives across moves so tree reuse can /// preserve the subtree of the chosen child. pub struct LeafParallelMCTS { @@ -104,7 +127,7 @@ impl LeafParallelMCTS { root_data: CompactState, mechanics: &QGameMechanics, visited_states: &HashSet, - ) -> Result<(Vec, f32)> { + ) -> Result<(Vec, f32, SearchStats)> { // Fresh arena unless we have a reusable one matching the root state. let mut arena = match self.arena.take() { Some(a) if a.get(0).data == root_data => a, @@ -144,6 +167,7 @@ impl LeafParallelMCTS { self.cfg.k.unwrap_or(10) * action_mask.iter().filter(|&&m| m).count() as u32 }); + let mut stats = SearchStats::default(); let mut iters_done: u32 = 0; let k = self.lp.leaf_parallelism.max(1); let vl = self.lp.virtual_loss; @@ -176,6 +200,13 @@ impl LeafParallelMCTS { let leaf_idx = *path.last().unwrap(); let leaf_data = arena.get(leaf_idx).data; + let depth = path.len() as u32; + stats.sims += 1; + stats.sum_depth += depth as u64; + if depth > stats.max_depth { + stats.max_depth = depth; + } + // Terminal? if mechanics.is_game_over(leaf_data) { // Terminal value convention: matches the synchronous mcts::search reference. @@ -187,11 +218,17 @@ impl LeafParallelMCTS { 0.0 }; items.push(Item::Terminal { path, value: v }); + if v > 0.0 { + stats.terminal_wins += 1; + } else { + stats.truncations += 1; + } continue; } if let Some(max) = self.cfg.max_steps { if mechanics.repr().get_completed_steps(leaf_data) >= max as usize { items.push(Item::Terminal { path, value: 0.0 }); + stats.truncations += 1; continue; } } @@ -294,10 +331,32 @@ impl LeafParallelMCTS { }) .collect(); + // Root visit spread (entropy in nats + top-move fraction). + let total_visits: u64 = children.iter().map(|c| c.visit_count as u64).sum(); + if total_visits > 0 { + let mut entropy = 0.0f64; + let mut max_v = 0u32; + for c in &children { + if c.visit_count > 0 { + let p = c.visit_count as f64 / total_visits as f64; + entropy -= p * p.ln(); + if c.visit_count > max_v { + max_v = c.visit_count; + } + } + } + stats.root_visit_entropy = entropy; + stats.top_move_visit_frac = max_v as f64 / total_visits as f64; + } + stats.nodes = arena.len() as u32; + stats.internal_nodes = (0..arena.len()) + .filter(|&i| !arena.get(i).children.is_empty()) + .count() as u32; + // Stash the arena for tree reuse on the next call. self.arena = Some(arena); - Ok((children, computed_root_value)) + Ok((children, computed_root_value, stats)) } /// Run a single eval through the pipeline (used to seed root expansion). @@ -407,7 +466,7 @@ mod tests { LeafParallelMCTS::new(mcts_cfg.clone(), lp_cfg, tx.clone(), Arc::clone(&cache)); let visited = std::collections::HashSet::new(); - let (children, _root_value) = mcts.search(data, &mech, &visited).await.unwrap(); + let (children, _root_value, _stats) = mcts.search(data, &mech, &visited).await.unwrap(); let total: u32 = children.iter().map(|c| c.visit_count).sum(); // Should be exactly n iterations of MCTS expansion under the root. assert!(total >= 20, "expected ≥20 child visits, got {}", total); @@ -448,7 +507,7 @@ mod tests { let mut mcts = LeafParallelMCTS::new(mcts_cfg, lp_cfg, tx.clone(), Arc::clone(&cache)); let visited = std::collections::HashSet::new(); - let (children, _) = mcts.search(data, &mech, &visited).await.unwrap(); + let (children, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap(); let visited_top: u32 = children.iter().filter(|c| c.visit_count > 0).count() as u32; assert!( visited_top >= 2, @@ -491,14 +550,14 @@ mod tests { let visited = std::collections::HashSet::new(); // First search at the initial state. - let (children_1, _) = mcts.search(data, &mech, &visited).await.unwrap(); + let (children_1, _, _stats) = mcts.search(data, &mech, &visited).await.unwrap(); let chosen = children_1.iter().max_by_key(|c| c.visit_count).unwrap(); // Advance root and search again from the resulting state. let mut next_state = data; mech.apply_action_index(&mut next_state, chosen.action_index); mcts.advance_root(chosen.action_index); - let (children_2, _) = mcts.search(next_state, &mech, &visited).await.unwrap(); + let (children_2, _, _stats) = mcts.search(next_state, &mech, &visited).await.unwrap(); assert!(!children_2.is_empty()); // Drop mcts (holds Sender clone) before tx, then await stub. @@ -508,6 +567,53 @@ mod tests { }); } + #[test] + fn test_search_stats_are_sane() { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let mech = QGameMechanics::new(5, 0, 200); + let data = mech.create_initial_state(); + let cache = Arc::new(EvalCache::new()); + let (tx, rx) = tokio_mpsc::channel::(128); + let stub = spawn_stub_coordinator(rx, Arc::clone(&cache)); + + let mcts_cfg = MCTSConfig { + n: Some(40), + ucb_c: 1.4, + noise_epsilon: 0.0, + ..Default::default() + }; + let lp_cfg = LeafParallelConfig { + leaf_parallelism: 4, + virtual_loss: 1, + enable_tree_reuse: false, + }; + let mut mcts = LeafParallelMCTS::new(mcts_cfg, lp_cfg, tx.clone(), Arc::clone(&cache)); + let visited = std::collections::HashSet::new(); + let (_children, _v, stats) = mcts.search(data, &mech, &visited).await.unwrap(); + + assert_eq!(stats.sims, 40, "sims should equal mcts_n"); + assert!(stats.max_depth >= 1, "max_depth must be >= 1"); + assert!(stats.sum_depth >= stats.sims as u64, "each sim has depth >= 1"); + assert!(stats.nodes >= 1); + assert!(stats.internal_nodes >= 1); + assert!(stats.root_visit_entropy >= 0.0); + assert!( + stats.top_move_visit_frac > 0.0 && stats.top_move_visit_frac <= 1.0, + "top_move_visit_frac in (0,1], got {}", + stats.top_move_visit_frac + ); + + drop(mcts); + drop(tx); + let _ = stub.await; + }); + } + #[test] fn test_note_model_version_clears_tree_on_change() { let rt = tokio::runtime::Builder::new_multi_thread() From eac37268722ba77d2b90dc023b29ad909aabe78c Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 21:37:54 -0400 Subject: [PATCH 05/30] vibe: collect per-game MCTS metrics and move hashes Co-Authored-By: Claude Sonnet 4.6 --- .../src/agents/alphazero/selfplay_game.rs | 127 ++++++++++++++---- deep_quoridor/rust/src/bin/selfplay.rs | 4 +- 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs index af9402b2..07a9bb27 100644 --- a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs @@ -9,7 +9,7 @@ use std::collections::HashSet; use anyhow::Result; use ndarray::Axis; -use crate::agents::alphazero::selfplay_mcts::LeafParallelMCTS; +use crate::agents::alphazero::selfplay_mcts::{LeafParallelMCTS, SearchStats}; use crate::compact::q_bit_repr::CompactState; use crate::compact::q_game_mechanics::QGameMechanics; use crate::game_runner::{GameResult, ReplayBufferItem}; @@ -65,6 +65,34 @@ fn sample_action( action_indices[action_indices.len() - 1] } +/// Number of leading plies that define a game's "opening" for uniqueness. +pub const OPENING_PLIES: usize = 8; + +/// Per-game MCTS diagnostics, summed over the game's searches, plus move-sequence hashes. +#[derive(Debug, Clone, Default)] +pub struct GameMetrics { + pub sims: u64, + pub terminal_wins: u64, + pub truncations: u64, + pub max_depth: u32, + pub sum_depth: u64, + pub moves: u64, + pub sum_root_entropy: f64, + pub sum_top_move_frac: f64, + pub sum_nodes: u64, + pub sum_internal_nodes: u64, + pub full_hash: u64, + pub opening_hash: u64, +} + +/// Deterministic (within-process) hash of a move-index sequence. +fn hash_actions(actions: &[usize]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + actions.hash(&mut h); + h.finish() +} + /// Per-game settings for action selection. #[derive(Debug, Clone, Copy)] pub struct GameSettings { @@ -80,7 +108,7 @@ pub async fn play_game_async( board_size: i32, max_walls: i32, max_steps: i32, -) -> Result { +) -> Result<(GameResult, GameMetrics)> { let mechanics = QGameMechanics::new(board_size as usize, max_walls as usize, max_steps as usize); let mut data = mechanics.create_initial_state(); @@ -88,6 +116,8 @@ pub async fn play_game_async( let mut replay_items: Vec = Vec::new(); let visited = HashSet::new(); let mut winner: Option = None; + let mut gm = GameMetrics::default(); + let mut actions: Vec = Vec::new(); for step in 0..max_steps { let current_player = mechanics.repr().get_current_player(data) as i32; @@ -98,16 +128,37 @@ pub async fn play_game_async( let resnet_input = compact_state_to_resnet_input(&mechanics, data); - let (action_idx, policy) = if current_player == 0 { - run_az_select(p1, data, &mechanics, &visited, settings, step as usize).await? + let (action_idx, policy, stats) = if current_player == 0 { + let (a, p, s) = + run_az_select(p1, data, &mechanics, &visited, settings, step as usize).await?; + (a, p, Some(s)) } else { match p2 { P2::AlphaZero(m) => { - run_az_select(m, data, &mechanics, &visited, settings, step as usize).await? + let (a, p, s) = + run_az_select(m, data, &mechanics, &visited, settings, step as usize) + .await?; + (a, p, Some(s)) + } + P2::Random => { + let (a, p) = random_select(&mask); + (a, p, None) } - P2::Random => random_select(&mask), } }; + if let Some(s) = stats { + gm.moves += 1; + gm.sims += s.sims as u64; + gm.terminal_wins += s.terminal_wins as u64; + gm.truncations += s.truncations as u64; + gm.max_depth = gm.max_depth.max(s.max_depth); + gm.sum_depth += s.sum_depth; + gm.sum_root_entropy += s.root_visit_entropy; + gm.sum_top_move_frac += s.top_move_visit_frac; + gm.sum_nodes += s.nodes as u64; + gm.sum_internal_nodes += s.internal_nodes as u64; + } + actions.push(action_idx); // Replay capture (current-player-downward frame). let (stored_input_3d, stored_policy, stored_mask) = if current_player == 1 { @@ -142,25 +193,29 @@ pub async fn play_game_async( if mechanics.check_win(data, current_player as usize) { winner = Some(current_player); - for item in replay_items.iter_mut() { - item.value = if item.player == current_player { - 1.0 - } else { - -1.0 - }; - } - return Ok(GameResult { - winner, - num_turns: step + 1, - replay_items, - }); + break; } } - Ok(GameResult { - winner, - num_turns: max_steps, - replay_items, - }) + if let Some(w) = winner { + for item in replay_items.iter_mut() { + item.value = if item.player == w { 1.0 } else { -1.0 }; + } + } + gm.full_hash = hash_actions(&actions); + gm.opening_hash = hash_actions(&actions[..actions.len().min(OPENING_PLIES)]); + let num_turns = if winner.is_some() { + actions.len() as i32 + } else { + max_steps + }; + Ok(( + GameResult { + winner, + num_turns, + replay_items, + }, + gm, + )) } async fn run_az_select( @@ -170,8 +225,8 @@ async fn run_az_select( visited: &HashSet, settings: GameSettings, step: usize, -) -> Result<(usize, Vec)> { - let (children, _root_value, _stats) = mcts.search(data, mechanics, visited).await?; +) -> Result<(usize, Vec, SearchStats)> { + let (children, _root_value, stats) = mcts.search(data, mechanics, visited).await?; let visit_counts: Vec = children.iter().map(|c| c.visit_count).collect(); let action_indices: Vec = children.iter().map(|c| c.action_index).collect(); let temperature = match settings.drop_t_on_step { @@ -194,7 +249,7 @@ async fn run_az_select( policy[c.action_index] = c.visit_count as f32 / total_visits as f32; } } - Ok((action_idx, policy)) + Ok((action_idx, policy, stats)) } fn random_select(mask: &[bool]) -> (usize, Vec) { @@ -210,3 +265,23 @@ fn random_select(mask: &[bool]) -> (usize, Vec) { p[idx] = 1.0; (idx, p) } + +#[cfg(test)] +mod tests { + use super::{hash_actions, OPENING_PLIES}; + + #[test] + fn identical_sequences_hash_equal_different_differ() { + let a = vec![3usize, 7, 1, 9, 2, 4, 8, 0, 5, 6]; + let b = a.clone(); + let mut c = a.clone(); + c[9] = 99; // differs only after the opening + + assert_eq!(hash_actions(&a), hash_actions(&b), "identical games hash equal"); + assert_ne!(hash_actions(&a), hash_actions(&c), "different full games differ"); + + let open_a = hash_actions(&a[..a.len().min(OPENING_PLIES)]); + let open_c = hash_actions(&c[..c.len().min(OPENING_PLIES)]); + assert_eq!(open_a, open_c, "same opening hashes equal"); + } +} diff --git a/deep_quoridor/rust/src/bin/selfplay.rs b/deep_quoridor/rust/src/bin/selfplay.rs index 81393c9c..2ebc05dc 100644 --- a/deep_quoridor/rust/src/bin/selfplay.rs +++ b/deep_quoridor/rust/src/bin/selfplay.rs @@ -448,7 +448,7 @@ fn run_batch_batched( if idx >= num_games { break; } p1.reset_tree(); if let P2::AlphaZero(m) = &mut p2 { m.reset_tree(); } - let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; + let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; write_replay(&output_dir, None, &result, model_version, idx, pid)?; let mut s = stats.lock().unwrap(); match result.winner { @@ -656,7 +656,7 @@ fn run_continuous_batched( p1.reset_tree(); if let P2::AlphaZero(m) = &mut p2 { m.reset_tree(); } let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let result = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; + let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?; } Ok::<(), anyhow::Error>(()) From 3ea932ceb4d9041c1453d55635e6f2450b605cd1 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 21:42:42 -0400 Subject: [PATCH 06/30] vibe: add SelfPlayAccumulator with JSON metric flush --- deep_quoridor/rust/Cargo.toml | 3 +- .../rust/src/agents/alphazero/mod.rs | 1 + .../src/agents/alphazero/selfplay_metrics.rs | 174 ++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml index a99aade4..8d1777cf 100644 --- a/deep_quoridor/rust/Cargo.toml +++ b/deep_quoridor/rust/Cargo.toml @@ -39,6 +39,7 @@ clap = { version = "4.5", features = ["derive"], optional = true } ort = { version = "=2.0.0-rc.12", optional = true } anyhow = "1" serde_yaml = { version = "0.9", optional = true } +serde_json = { version = "1", 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 } @@ -49,7 +50,7 @@ smallvec = "1.13" [features] default = ["python"] python = ["pyo3", "numpy"] -binary = ["clap", "ort", "serde_yaml", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"] +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"] gpu = ["binary", "ort/cuda", "ort/load-dynamic"] [dev-dependencies] diff --git a/deep_quoridor/rust/src/agents/alphazero/mod.rs b/deep_quoridor/rust/src/agents/alphazero/mod.rs index 88c9e6d8..01aa6c59 100644 --- a/deep_quoridor/rust/src/agents/alphazero/mod.rs +++ b/deep_quoridor/rust/src/agents/alphazero/mod.rs @@ -7,6 +7,7 @@ pub mod eval_pipeline; pub mod evaluator; pub mod mcts; pub mod selfplay_game; +pub mod selfplay_metrics; pub mod selfplay_mcts; pub mod agent; diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs new file mode 100644 index 00000000..3a79b78c --- /dev/null +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs @@ -0,0 +1,174 @@ +//! Per-process self-play metric accumulator. +//! +//! Folds `GameMetrics` for the current model version, then flushes a raw-aggregate +//! JSON record per `(version, pid)` so the Python side can combine processes and +//! compute final metrics. Reset happens on each model-version change and on shutdown. + +use std::collections::HashSet; + +use anyhow::{Context, Result}; + +use crate::agents::alphazero::selfplay_game::GameMetrics; + +/// Running raw aggregates for one model version within one process. +pub struct SelfPlayAccumulator { + version: i64, + sims: u64, + terminal_wins: u64, + truncations: u64, + max_depth: u32, + sum_depth: u64, + moves: u64, + sum_root_entropy: f64, + sum_top_move_frac: f64, + sum_nodes: u64, + sum_internal_nodes: u64, + games_generated: u64, + full_hashes: HashSet, + opening_hashes: HashSet, +} + +impl SelfPlayAccumulator { + pub fn new(version: i64) -> Self { + Self { + version, + sims: 0, + terminal_wins: 0, + truncations: 0, + max_depth: 0, + sum_depth: 0, + moves: 0, + sum_root_entropy: 0.0, + sum_top_move_frac: 0.0, + sum_nodes: 0, + sum_internal_nodes: 0, + games_generated: 0, + full_hashes: HashSet::new(), + opening_hashes: HashSet::new(), + } + } + + pub fn set_version(&mut self, v: i64) { + self.version = v; + } + + pub fn fold_game(&mut self, gm: &GameMetrics) { + self.sims += gm.sims; + self.terminal_wins += gm.terminal_wins; + self.truncations += gm.truncations; + self.max_depth = self.max_depth.max(gm.max_depth); + self.sum_depth += gm.sum_depth; + self.moves += gm.moves; + self.sum_root_entropy += gm.sum_root_entropy; + self.sum_top_move_frac += gm.sum_top_move_frac; + self.sum_nodes += gm.sum_nodes; + self.sum_internal_nodes += gm.sum_internal_nodes; + self.games_generated += 1; + self.full_hashes.insert(gm.full_hash); + self.opening_hashes.insert(gm.opening_hash); + } + + fn clear_counts(&mut self) { + self.sims = 0; + self.terminal_wins = 0; + self.truncations = 0; + self.max_depth = 0; + self.sum_depth = 0; + self.moves = 0; + self.sum_root_entropy = 0.0; + self.sum_top_move_frac = 0.0; + self.sum_nodes = 0; + self.sum_internal_nodes = 0; + self.games_generated = 0; + self.full_hashes.clear(); + self.opening_hashes.clear(); + } + + fn to_json(&self, pid: u32) -> serde_json::Value { + serde_json::json!({ + "model_version": self.version, + "pid": pid, + "sims": self.sims, + "terminal_wins": self.terminal_wins, + "truncations": self.truncations, + "max_depth": self.max_depth, + "sum_depth": self.sum_depth, + "moves": self.moves, + "sum_root_entropy": self.sum_root_entropy, + "sum_top_move_frac": self.sum_top_move_frac, + "sum_nodes": self.sum_nodes, + "sum_internal_nodes": self.sum_internal_nodes, + "games_generated": self.games_generated, + "unique_full": self.full_hashes.len(), + "unique_opening": self.opening_hashes.len(), + }) + } + + /// Write the current version's record to `/v{version}_pid{pid}.json` (atomic + /// tmp+rename), then clear counts. No-op (just clears) when nothing was accumulated. + pub fn flush_and_reset(&mut self, dir: &str, pid: u32) -> Result<()> { + if self.moves == 0 && self.games_generated == 0 { + self.clear_counts(); + return Ok(()); + } + let path = format!("{}/v{}_pid{}.json", dir, self.version, pid); + let tmp = format!("{}.tmp", path); + let bytes = serde_json::to_vec(&self.to_json(pid)).context("serialize metrics record")?; + std::fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp))?; + std::fs::rename(&tmp, &path).with_context(|| format!("rename to {}", path))?; + self.clear_counts(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::selfplay_game::GameMetrics; + + fn sample_game(full: u64, opening: u64) -> GameMetrics { + GameMetrics { + sims: 100, + terminal_wins: 5, + truncations: 2, + max_depth: 10, + sum_depth: 300, + moves: 20, + sum_root_entropy: 12.0, + sum_top_move_frac: 8.0, + sum_nodes: 500, + sum_internal_nodes: 250, + full_hash: full, + opening_hash: opening, + } + } + + #[test] + fn flush_writes_expected_aggregates() { + let dir = std::env::temp_dir().join(format!("spm_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dir_s = dir.to_str().unwrap().to_string(); + + let mut acc = SelfPlayAccumulator::new(7); + acc.fold_game(&sample_game(1, 100)); + acc.fold_game(&sample_game(2, 100)); + acc.fold_game(&sample_game(2, 100)); + acc.flush_and_reset(&dir_s, 4242).unwrap(); + + let path = format!("{}/v7_pid4242.json", dir_s); + let v: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(v["model_version"], 7); + assert_eq!(v["games_generated"], 3); + assert_eq!(v["sims"], 300); + assert_eq!(v["unique_full"], 2); + assert_eq!(v["unique_opening"], 1); + assert_eq!(v["max_depth"], 10); + + std::fs::remove_file(&path).unwrap(); + acc.flush_and_reset(&dir_s, 4242).unwrap(); + assert!(!std::path::Path::new(&path).exists(), "empty flush writes nothing"); + + std::fs::remove_dir_all(&dir).ok(); + } +} From e936b69aed1ff48fc0c8b8482b045716883dadcf Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 21:45:46 -0400 Subject: [PATCH 07/30] vibe: flush self-play MCTS metrics per model version Co-Authored-By: Claude Sonnet 4.6 --- deep_quoridor/rust/src/bin/selfplay.rs | 37 +++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/deep_quoridor/rust/src/bin/selfplay.rs b/deep_quoridor/rust/src/bin/selfplay.rs index 2ebc05dc..a4338792 100644 --- a/deep_quoridor/rust/src/bin/selfplay.rs +++ b/deep_quoridor/rust/src/bin/selfplay.rs @@ -123,6 +123,11 @@ struct Cli { /// Periodically print pipeline counters (GPU time, batcher wait, postprocess time). #[arg(long, default_value = "false")] profile_counters: bool, + + /// Directory to write per-model-version MCTS metric JSON records. When omitted, + /// metric collection is disabled. + #[arg(long)] + metrics_dir: Option, } /// Resolved runtime config (CLI overrides > YAML > defaults). @@ -594,6 +599,15 @@ fn run_continuous_batched( let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let pid = std::process::id(); + use quoridor_rs::agents::alphazero::selfplay_metrics::SelfPlayAccumulator; + let metrics_dir = cli.metrics_dir.clone(); + if let Some(ref d) = metrics_dir { + std::fs::create_dir_all(d)?; + } + let metrics = std::sync::Arc::new(std::sync::Mutex::new(SelfPlayAccumulator::new( + initial_version, + ))); + let print_task = if profile_counters { let counters = std::sync::Arc::clone(&counters); let shutdown = std::sync::Arc::clone(&shutdown); @@ -641,6 +655,8 @@ fn run_continuous_batched( let board_size = q.board_size; let max_walls = q.max_walls; let max_steps = q.max_steps as i32; + let metrics = std::sync::Arc::clone(&metrics); + let metrics_enabled = metrics_dir.is_some(); handles.push(tokio::spawn(async move { let mut p1 = LeafParallelMCTS::new(mcts_cfg.clone(), lp_cfg, front_tx.clone(), std::sync::Arc::clone(&cache)); let mut p2: P2 = match p2_kind.as_deref() { @@ -656,8 +672,11 @@ fn run_continuous_batched( p1.reset_tree(); if let P2::AlphaZero(m) = &mut p2 { m.reset_tree(); } let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let (result, _game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; + let (result, game_metrics) = play_game_async(&mut p1, &mut p2, settings, board_size, max_walls, max_steps).await?; write_replay(&output_dir, Some(&tmp_dir), &result, mv, idx, pid)?; + if metrics_enabled { + metrics.lock().unwrap().fold_game(&game_metrics); + } } Ok::<(), anyhow::Error>(()) })); @@ -668,12 +687,21 @@ fn run_continuous_batched( let front_tx = front_tx.clone(); let shutdown = std::sync::Arc::clone(&shutdown); let model_version = std::sync::Arc::clone(&model_version); + let metrics = std::sync::Arc::clone(&metrics); + let metrics_dir = metrics_dir.clone(); + let pid_for_metrics = pid; tokio::spawn(async move { let mut current = initial_version; loop { if std::path::Path::new(&shutdown_path).exists() { println!("Shutdown signal detected. Stopping workers..."); shutdown.store(true, std::sync::atomic::Ordering::Relaxed); + if let Some(ref d) = metrics_dir { + let mut m = metrics.lock().unwrap(); + if let Err(e) = m.flush_and_reset(d, pid_for_metrics) { + eprintln!("selfplay-metrics: final flush failed: {:#}", e); + } + } break; } if let Ok(latest) = load_latest_model(&latest_yaml_path) { @@ -683,6 +711,13 @@ fn run_continuous_batched( println!("New model detected: version {} -> {} ({})", current, latest.version, new_path); current = latest.version; model_version.store(latest.version, std::sync::atomic::Ordering::Relaxed); + if let Some(ref d) = metrics_dir { + let mut m = metrics.lock().unwrap(); + if let Err(e) = m.flush_and_reset(d, pid_for_metrics) { + eprintln!("selfplay-metrics: flush failed: {:#}", e); + } + m.set_version(latest.version); + } let _ = front_tx.send(FrontMsg::Reload(new_path)).await; } } From defa27b2f13e1945b51d9c53fa97969c0a692cde Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 21:49:46 -0400 Subject: [PATCH 08/30] vibe: add self-play metrics aggregator and W&B logger Co-Authored-By: Claude Sonnet 4.6 --- deep_quoridor/src/v2/selfplay_metrics.py | 130 ++++++++++++++++++++ deep_quoridor/test/test_selfplay_metrics.py | 48 ++++++++ 2 files changed, 178 insertions(+) create mode 100644 deep_quoridor/src/v2/selfplay_metrics.py create mode 100644 deep_quoridor/test/test_selfplay_metrics.py diff --git a/deep_quoridor/src/v2/selfplay_metrics.py b/deep_quoridor/src/v2/selfplay_metrics.py new file mode 100644 index 00000000..3286d870 --- /dev/null +++ b/deep_quoridor/src/v2/selfplay_metrics.py @@ -0,0 +1,130 @@ +"""Self-play MCTS metrics: aggregate per-process Rust JSON records and log to W&B. + +The Rust self-play binary writes one raw-aggregate record per (model_version, pid) +to a metrics directory. This module combines a version's records across processes, +computes the final metrics, and logs them to a W&B run in the training group. +""" +import glob +import json +import math +import os +import re +import time +from typing import Optional + +import wandb + +from v2.common import MockWandb, ShutdownSignal +from v2.config import Config + +_FILE_RE = re.compile(r"v(\d+)_pid\d+\.json$") + + +def metrics_dir_for(config: Config) -> str: + """Directory shared by the Rust writer and this reader.""" + return str(config.paths.run_dir / "selfplay_metrics") + + +def aggregate_records(records: list[dict]) -> Optional[dict]: + """Combine per-process raw records for one model version into final metrics. + + Returns None if no searches happened (nothing to log). + """ + sims = sum(r["sims"] for r in records) + moves = sum(r["moves"] for r in records) + if moves == 0 or sims == 0: + return None + + sum_entropy = sum(r["sum_root_entropy"] for r in records) + sum_nodes = sum(r["sum_nodes"] for r in records) + sum_internal = sum(r["sum_internal_nodes"] for r in records) + games = sum(r["games_generated"] for r in records) + unique_full = sum(r["unique_full"] for r in records) + unique_opening = sum(r["unique_opening"] for r in records) + mean_entropy = sum_entropy / moves + + out = { + "selfplay/terminal_sim_frac": sum(r["terminal_wins"] for r in records) / sims, + "selfplay/truncation_sim_frac": sum(r["truncations"] for r in records) / sims, + "selfplay/max_tree_depth": max(r["max_depth"] for r in records), + "selfplay/mean_tree_depth": sum(r["sum_depth"] for r in records) / sims, + "selfplay/root_visit_entropy": mean_entropy, + "selfplay/root_visit_perplexity": math.exp(mean_entropy), + "selfplay/top_move_visit_frac": sum(r["sum_top_move_frac"] for r in records) / moves, + "selfplay/mean_nodes_per_search": sum_nodes / moves, + "selfplay/mean_branching": (sum_nodes - moves) / sum_internal if sum_internal else 0.0, + "selfplay/games_generated": games, + "selfplay/unique_games_full": unique_full, + "selfplay/unique_games_opening": unique_opening, + "selfplay/unique_frac_full": unique_full / games if games else 0.0, + "selfplay/unique_frac_opening": unique_opening / games if games else 0.0, + } + return out + + +def _scan(metrics_dir: str) -> dict[int, list[str]]: + """Map model_version -> list of record file paths present on disk.""" + by_version: dict[int, list[str]] = {} + for path in glob.glob(os.path.join(metrics_dir, "v*_pid*.json")): + m = _FILE_RE.search(os.path.basename(path)) + if m: + by_version.setdefault(int(m.group(1)), []).append(path) + return by_version + + +def _load(paths: list[str]) -> list[dict]: + records = [] + for p in paths: + try: + with open(p) as f: + records.append(json.load(f)) + except (OSError, json.JSONDecodeError): + continue # mid-write or transient; picked up on a later poll + return records + + +def run_selfplay_metrics(config: Config, poll_seconds: float = 5.0): + """Poll the metrics dir and log each completed model version to W&B once.""" + metrics_dir = metrics_dir_for(config) + os.makedirs(metrics_dir, exist_ok=True) + + if config.wandb: + run_id = f"{config.run_id}-selfplay" + wandb_run = wandb.init( + project=config.wandb.project, + job_type="selfplay", + group=config.run_id, + name=run_id, + id=run_id, + resume="allow", + ) + wandb.define_metric("Model version", hidden=True) + wandb.define_metric("*", "Model version") + else: + wandb_run = MockWandb() + + logged: set[int] = set() + + def flush(finalize_all: bool): + by_version = _scan(metrics_dir) + if not by_version: + return + max_version = max(by_version) + for version in sorted(by_version): + if version in logged: + continue + # A version is complete once a newer version exists (the writer moved on) + # or we're finalizing on shutdown. + if not finalize_all and version >= max_version: + continue + agg = aggregate_records(_load(by_version[version])) + if agg is not None: + agg["Model version"] = version + wandb_run.log(agg) + logged.add(version) + + while not ShutdownSignal.is_set(config): + flush(finalize_all=False) + time.sleep(poll_seconds) + + flush(finalize_all=True) # log the last in-progress version on shutdown diff --git a/deep_quoridor/test/test_selfplay_metrics.py b/deep_quoridor/test/test_selfplay_metrics.py new file mode 100644 index 00000000..8682809c --- /dev/null +++ b/deep_quoridor/test/test_selfplay_metrics.py @@ -0,0 +1,48 @@ +import math + +from v2.selfplay_metrics import aggregate_records + + +def _record(**kw): + base = dict( + model_version=3, pid=1, sims=0, terminal_wins=0, truncations=0, + max_depth=0, sum_depth=0, moves=0, sum_root_entropy=0.0, + sum_top_move_frac=0.0, sum_nodes=0, sum_internal_nodes=0, + games_generated=0, unique_full=0, unique_opening=0, + ) + base.update(kw) + return base + + +def test_aggregate_combines_two_processes(): + r1 = _record( + sims=100, terminal_wins=10, truncations=5, max_depth=12, sum_depth=400, + moves=20, sum_root_entropy=20.0, sum_top_move_frac=10.0, sum_nodes=600, + sum_internal_nodes=300, games_generated=2, unique_full=2, unique_opening=1, + ) + r2 = _record( + sims=300, terminal_wins=30, truncations=15, max_depth=18, sum_depth=1200, + moves=60, sum_root_entropy=66.0, sum_top_move_frac=36.0, sum_nodes=1800, + sum_internal_nodes=900, games_generated=6, unique_full=5, unique_opening=2, + ) + agg = aggregate_records([r1, r2]) + + sims, moves = 400, 80 + assert agg["selfplay/terminal_sim_frac"] == 40 / sims + assert agg["selfplay/truncation_sim_frac"] == 20 / sims + assert agg["selfplay/max_tree_depth"] == 18 + assert agg["selfplay/mean_tree_depth"] == 1600 / sims + assert agg["selfplay/root_visit_entropy"] == 86.0 / moves + assert agg["selfplay/root_visit_perplexity"] == math.exp(86.0 / moves) + assert agg["selfplay/top_move_visit_frac"] == 46.0 / moves + assert agg["selfplay/mean_nodes_per_search"] == 2400 / moves + assert agg["selfplay/mean_branching"] == (2400 - moves) / 1200 + assert agg["selfplay/games_generated"] == 8 + assert agg["selfplay/unique_games_full"] == 7 + assert agg["selfplay/unique_games_opening"] == 3 + assert agg["selfplay/unique_frac_full"] == 7 / 8 + assert agg["selfplay/unique_frac_opening"] == 3 / 8 + + +def test_aggregate_skips_empty(): + assert aggregate_records([_record(moves=0, sims=0)]) is None From 12f00c7d6364e397f1706dcd7236730bf4328661 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 21:53:49 -0400 Subject: [PATCH 09/30] vibe: spawn self-play metrics logger from train_v2 --- deep_quoridor/src/train_v2.py | 18 +++++++++++++++++- deep_quoridor/src/v2/__init__.py | 3 +++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/deep_quoridor/src/train_v2.py b/deep_quoridor/src/train_v2.py index f5a0b31c..9e5512be 100644 --- a/deep_quoridor/src/train_v2.py +++ b/deep_quoridor/src/train_v2.py @@ -5,7 +5,16 @@ import time from pathlib import Path -from v2 import benchmarks, check_ai_available, load_config_and_setup_run, run_ai_reporter, self_play, train +from v2 import ( + benchmarks, + check_ai_available, + load_config_and_setup_run, + metrics_dir_for, + run_ai_reporter, + run_selfplay_metrics, + self_play, + train, +) from v2.common import ShutdownSignal # Prevents getting messages in the console every few lines telling you to install weave @@ -95,6 +104,8 @@ def _selfplay_subprocess_env(): selfplay_env = _selfplay_subprocess_env() if selfplay_env is not None: print(f"Self-play GPU env: ORT_DYLIB_PATH={selfplay_env['ORT_DYLIB_PATH']}") + metrics_dir = metrics_dir_for(config) + os.makedirs(metrics_dir, exist_ok=True) config_file_path = str(config.paths.config_file) for i in range(config.self_play.num_processes): cmd = [ @@ -108,10 +119,15 @@ def _selfplay_subprocess_env(): str(config.paths.latest_model_yaml), "--shutdown-file", str(ShutdownSignal.file_path(config)), + "--metrics-dir", + metrics_dir, ] proc = subprocess.Popen(cmd, env=selfplay_env) rust_subprocesses.append(proc) print(f"Started Rust self-play process {proc.pid}") + selfplay_metrics_process = mp.Process(target=run_selfplay_metrics, args=[config]) + selfplay_metrics_process.start() + self_play_processes.append(selfplay_metrics_process) else: for i in range(config.self_play.num_processes): p = mp.Process(target=self_play, args=[config]) diff --git a/deep_quoridor/src/v2/__init__.py b/deep_quoridor/src/v2/__init__.py index 296b58ee..2d52b649 100644 --- a/deep_quoridor/src/v2/__init__.py +++ b/deep_quoridor/src/v2/__init__.py @@ -13,10 +13,13 @@ "check_ai_available", "run_ai_reporter", "generate_on_demand_report", + "metrics_dir_for", + "run_selfplay_metrics", ] from v2.ai_report import check_ai_available, generate_on_demand_report, run_ai_reporter from v2.benchmarks import create_benchmark_processes +from v2.selfplay_metrics import metrics_dir_for, run_selfplay_metrics from v2.common import JobTrigger, MockWandb, ShutdownSignal, create_alphazero, upload_model from v2.config import load_config_and_setup_run from v2.self_play import self_play From 9ed6c73e6e4a73baf84e7250e8f03121817e4f70 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Tue, 26 May 2026 21:55:58 -0400 Subject: [PATCH 10/30] vibe: cargo fmt + ruff format --- deep_quoridor/rust/src/agents/alphazero/mod.rs | 2 +- .../rust/src/agents/alphazero/selfplay_game.rs | 14 +++++++++++--- .../rust/src/agents/alphazero/selfplay_mcts.rs | 5 ++++- .../rust/src/agents/alphazero/selfplay_metrics.rs | 8 +++++--- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/deep_quoridor/rust/src/agents/alphazero/mod.rs b/deep_quoridor/rust/src/agents/alphazero/mod.rs index 01aa6c59..27e6fbae 100644 --- a/deep_quoridor/rust/src/agents/alphazero/mod.rs +++ b/deep_quoridor/rust/src/agents/alphazero/mod.rs @@ -7,8 +7,8 @@ pub mod eval_pipeline; pub mod evaluator; pub mod mcts; pub mod selfplay_game; -pub mod selfplay_metrics; pub mod selfplay_mcts; +pub mod selfplay_metrics; pub mod agent; pub use agent::{AlphaZeroAgent, AlphaZeroAgentConfig}; diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs index 07a9bb27..4b8b6556 100644 --- a/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_game.rs @@ -268,7 +268,7 @@ fn random_select(mask: &[bool]) -> (usize, Vec) { #[cfg(test)] mod tests { - use super::{hash_actions, OPENING_PLIES}; + use super::{OPENING_PLIES, hash_actions}; #[test] fn identical_sequences_hash_equal_different_differ() { @@ -277,8 +277,16 @@ mod tests { let mut c = a.clone(); c[9] = 99; // differs only after the opening - assert_eq!(hash_actions(&a), hash_actions(&b), "identical games hash equal"); - assert_ne!(hash_actions(&a), hash_actions(&c), "different full games differ"); + assert_eq!( + hash_actions(&a), + hash_actions(&b), + "identical games hash equal" + ); + assert_ne!( + hash_actions(&a), + hash_actions(&c), + "different full games differ" + ); let open_a = hash_actions(&a[..a.len().min(OPENING_PLIES)]); let open_c = hash_actions(&c[..c.len().min(OPENING_PLIES)]); diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs index 32277236..859bd848 100644 --- a/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_mcts.rs @@ -598,7 +598,10 @@ mod tests { assert_eq!(stats.sims, 40, "sims should equal mcts_n"); assert!(stats.max_depth >= 1, "max_depth must be >= 1"); - assert!(stats.sum_depth >= stats.sims as u64, "each sim has depth >= 1"); + assert!( + stats.sum_depth >= stats.sims as u64, + "each sim has depth >= 1" + ); assert!(stats.nodes >= 1); assert!(stats.internal_nodes >= 1); assert!(stats.root_visit_entropy >= 0.0); diff --git a/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs index 3a79b78c..bed5506f 100644 --- a/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs +++ b/deep_quoridor/rust/src/agents/alphazero/selfplay_metrics.rs @@ -156,8 +156,7 @@ mod tests { acc.flush_and_reset(&dir_s, 4242).unwrap(); let path = format!("{}/v7_pid4242.json", dir_s); - let v: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(v["model_version"], 7); assert_eq!(v["games_generated"], 3); assert_eq!(v["sims"], 300); @@ -167,7 +166,10 @@ mod tests { std::fs::remove_file(&path).unwrap(); acc.flush_and_reset(&dir_s, 4242).unwrap(); - assert!(!std::path::Path::new(&path).exists(), "empty flush writes nothing"); + assert!( + !std::path::Path::new(&path).exists(), + "empty flush writes nothing" + ); std::fs::remove_dir_all(&dir).ok(); } From dde24f3d9a65f897c1877114b62cb70186b60b9c Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 11:33:03 -0400 Subject: [PATCH 11/30] vibe: add Quoridor play server design spec A local web app for playing Quoridor against the project's Rust AlphaZero agent. One rust binary using tiny_http (no framework) serves embedded HTML/CSS/JS plus a small JSON API; each browser session gets its own in-memory game vs the AI. The user picks the ONNX model from /models/*.onnx (board config inferred from a sibling config.yaml), the mcts_n slider, and who plays first. --- .../2026-05-29-quoridor-play-server-design.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md diff --git a/docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md b/docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md new file mode 100644 index 00000000..ba26cd1a --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md @@ -0,0 +1,199 @@ +# Quoridor play server — design + +**Date:** 2026-05-29 +**Branch:** `jdb/rust-self-play-logging` +**Status:** approved (design), pending implementation plan + +## Problem + +We want a locally-hosted web app so the author and friends can play Quoridor +against the project's AlphaZero agent in their browsers. Each person opens the +URL in their browser and gets their own independent game vs the AI; multiple +games run concurrently on the same server. Constraints: + +- Use the project's **Rust** AlphaZero agent (the agent that actually has the + trained models we want to play against). +- Keep it as simple as possible — no large web frameworks, no Python in the + request path, no build step for the frontend. +- The player can pick which ONNX model the AI uses from a directory of models. + +## Architecture + +A single new Rust binary `bin/play_server.rs` in the existing `quoridor_rs` +crate: + +- Reads `/config.yaml` once at startup for `board_size`, `max_walls`, + `max_steps` (reusing the existing `selfplay_config` loader), and scans + `/models/*.onnx` for the selectable models. +- Listens on TCP (default port 8080; default bind `127.0.0.1`, `0.0.0.0` for + LAN). +- Serves a single `index.html` + `app.css` + `app.js` (embedded into the binary + via `include_str!`) plus a small JSON API. +- Holds active games in `Arc>>>`. Each + `GameSession` owns its own `AlphaZeroAgent` (built from the chosen ONNX with + the chosen `mcts_n`, `temperature=0`, deterministic tie-break) and the current + `CompactState` / `QGameMechanics`. + +### User flow +1. Friend opens `http://:8080` in their browser. +2. Picks **model** (dropdown), **`mcts_n`** (slider), and **who goes first** + (toggle); clicks "New Game". +3. The client POSTs `/api/games`; server creates a `GameSession`, returns a + `game_id` + initial `state`. +4. On the human's turn, legal moves and legal wall slots are highlighted; the + user clicks one, the client POSTs `/api/games//move` with the chosen + action index, the server applies the human move and (if it's then the AI's + turn) the AI response, and returns the new `state` in the same round-trip. +5. Repeat until `state.winner != null`. A game-over banner appears. + +### Why single Rust binary, not Python + pyo3 +"Use the Rust agent" + "no big frameworks" + "as simple as possible" point at +one binary: no venv, no maturin step, no two-language tooling for a small +feature. The only new dep is `tiny_http`, which is a small HTTP server library +(not a framework — no routing macros, no extractors, no async runtime). + +## Folder layout consumed by the server + +The user passes a directory to `--play-dir`. The expected layout is: +``` +/ + config.yaml # board_size, max_walls, max_steps, alphazero.* + models/ + *.onnx # selectable models +``` +The server treats the single board config as authoritative for the session; +every model in `models/` is assumed to match it. (Mixed-config setups are out +of scope.) + +## CLI +``` +play_server --play-dir + [--port 8080] + [--bind 127.0.0.1] # use 0.0.0.0 for LAN + [--default-mcts-n 400] +``` + +## HTTP API (small, JSON) + +- `GET /` → `index.html` +- `GET /static/` → embedded `app.css`, `app.js` +- `GET /api/config` → + ```json + { "board_size": 9, "max_walls": 10, "max_steps": 100, + "models": ["model_0.onnx", "model_100.onnx", ...], + "default_mcts_n": 400 } + ``` +- `POST /api/games` body `{ "model": "model_100.onnx", "mcts_n": 400, "human_player": 0 }` + → `{ "game_id": "8 hex chars", "state": }` +- `GET /api/games/` → `{ "state": }` +- `POST /api/games//move` body `{ "action_index": 17 }` + → `{ "state": }` (atomically applies the human move plus the AI's + response if it then becomes the AI's turn). + +**Errors:** invalid move → `400` with a `{ "error": "..." }` body; unknown +`game_id` → `404`; bad model name → `400`; ORT load failure → `500` with the +message; the server keeps running through any of these. + +## The `State` object + +Everything the client needs to render the board without knowing the action +encoding: +```json +{ + "board_size": 9, "max_walls": 10, + "current_player": 0, + "p1_pos": [0, 4], "p2_pos": [8, 4], + "p1_walls": 10, "p2_walls": 10, + "walls": [{ "row": 3, "col": 2, "orientation": "h" }, ...], + "legal_actions": [ + { "index": 3, "kind": "move", "to": [4, 5] }, + { "index": 17, "kind": "wall", "row": 3, "col": 2, "orientation": "h" } + ], + "completed_steps": 5, + "winner": null, + "human_player": 0, + "last_action": { "kind": "move", "to": [1, 4] }, + "move_history": [3, 17, ...] +} +``` +`legal_actions` carrying the kind/coords is the key piece: the client never has +to mirror the action-encoding logic. The wall list and `last_action` use the +same shape so the frontend has one render path. + +## Frontend + +Vanilla HTML + CSS + JS, served from the binary's embedded strings. The board +is a `(2N-1) × (2N-1)` CSS grid: + +- even row, even col → **pawn cell** (clickable if a `move` action lands here) +- odd row, even col → **horizontal wall slot** (clickable if a horizontal wall + is legal here) +- even row, odd col → **vertical wall slot** (clickable if a vertical wall is + legal here) +- odd row, odd col → wall post / spacer + +To set up interactions, the client iterates `state.legal_actions`, attaches a +click handler to the appropriate cell/slot keyed by the bare `index`, and on +click POSTs `{ "action_index": index }` to the move endpoint. The same +iteration produces the legal-move highlights. Walls and `last_action` render +through the same mapping. + +A right-side panel shows: whose turn it is, walls remaining per player, +completed steps, an "AI is thinking…" spinner while a move request is in +flight, and a "New Game" button. The new-game form has the model ``, and the human-player toggle. + +**Orientation.** Render with the human's home row at the bottom regardless of +`human_player`, so the player always sees "I'm advancing upward." The server +sends absolute coordinates; the client mirrors when `human_player == 1`. + +**Game-over banner.** "You won" / "AI won" / "Draw (truncated)" based on +`state.winner` (vs `human_player`). + +## Concurrency + +- Outer `Arc>>>>`: takes the outer + lock briefly to look up the session, then holds the inner per-game lock for + the duration of a move + AI response. +- AI inference is already single-threaded (`with_intra_threads(1)` is set on + the session builders), so other concurrent games proceed in parallel without + oversubscribing the CPU. +- Game IDs are 8 hex chars from a secure RNG. + +## Cargo.toml additions +```toml +[[bin]] +name = "play_server" +path = "src/bin/play_server.rs" +required-features = ["binary"] + +[dependencies] +tiny_http = { version = "0.12", optional = true } +``` +And `tiny_http` is added to the `binary` feature list. `serde_json` is already +on `binary` from the metrics work. + +## Testing + +- **Rust unit tests** in `play_server.rs` for the pure helpers: `State` + serialization, legal-action enrichment (`index → {kind, to/row/col/orient}`), + the wall list / `last_action` mapping. +- **Rust integration test** using the existing `alphazero_B5W2_mv1.onnx` + fixture: bind to port 0 (OS-assigned), `POST /api/games`, walk through a + small game, assert state transitions and that `winner` is eventually set on a + forced win. Uses `ureq` as a dev-dependency for the HTTP client. +- No frontend tests — manual. + +## Out of scope (YAGNI) + +- Auth or user accounts. +- HTTPS (localhost/LAN only). +- Game persistence — sessions are in-memory and lost on server restart. +- Undo / takeback. +- Spectator mode. +- Human-vs-human (matchmaking, lobbies). +- Replay save/load. +- Mobile-responsive layout (assume desktop browser). +- Per-session TTL or eviction — restart the server to clear. +- Model switching mid-game. +- Mixed-config model directories. From e4ae6d7a6a58f7121b24bdb9190a7c92d4fdd7d4 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 11:44:45 -0400 Subject: [PATCH 12/30] vibe: add Quoridor play server implementation plan Nine-task TDD plan: scaffold the play_server module with pure StateView and action enrichment, then config loader, GameSession + registry, JSON handlers, the tiny_http bin with embedded static assets, an end-to-end test against the B5W2 fixture, and the vanilla HTML/CSS/JS frontend. --- .../plans/2026-05-29-quoridor-play-server.md | 1990 +++++++++++++++++ 1 file changed, 1990 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-quoridor-play-server.md diff --git a/docs/superpowers/plans/2026-05-29-quoridor-play-server.md b/docs/superpowers/plans/2026-05-29-quoridor-play-server.md new file mode 100644 index 00000000..67402364 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-quoridor-play-server.md @@ -0,0 +1,1990 @@ +# Quoridor 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 small Rust binary that serves a local web app for playing Quoridor against the project's AlphaZero agent, with model selection from a directory. + +**Architecture:** One new `bin/play_server.rs` plus a `play_server` module in the existing `quoridor_rs` crate. The binary uses `tiny_http` (no framework) to serve embedded HTML/CSS/JS and a small JSON API; each browser session creates a `GameSession` (owns a `QGameMechanics` + an `AlphaZeroAgent`) held in an `Arc>`. Vanilla HTML+JS frontend with a `(2N-1) × (2N-1)` CSS-grid board; the server enriches legal actions with semantic shape so the client never has to know the action-index encoding. + +**Tech Stack:** Rust (edition 2024), `tiny_http`, `serde`/`serde_json`, `serde_yaml`, the existing `AlphaZeroAgent`/`QGameMechanics`/`actions` modules, plain HTML/CSS/JS in the browser. + +**Spec:** `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md` + +--- + +## File structure + +- `deep_quoridor/rust/Cargo.toml` — add `tiny_http` dep + `play_server` bin entry + `ureq` dev-dep. (modify) +- `deep_quoridor/rust/src/lib.rs` — register `pub mod play_server;` behind the `binary` feature. (modify) +- `deep_quoridor/rust/src/play_server/mod.rs` — re-exports. (create) +- `deep_quoridor/rust/src/play_server/state.rs` — `StateView` JSON shape + action enrichment. (create) +- `deep_quoridor/rust/src/play_server/config.rs` — `ServerConfig` loaded from the play-dir. (create) +- `deep_quoridor/rust/src/play_server/session.rs` — `GameSession` + `GameRegistry`. (create) +- `deep_quoridor/rust/src/play_server/handlers.rs` — request/response types + pure handler functions. (create) +- `deep_quoridor/rust/src/play_server/static/index.html` — frontend HTML. (create) +- `deep_quoridor/rust/src/play_server/static/app.css` — frontend CSS. (create) +- `deep_quoridor/rust/src/play_server/static/app.js` — frontend JS. (create) +- `deep_quoridor/rust/src/bin/play_server.rs` — CLI + `tiny_http` loop + route dispatch. (create) +- `deep_quoridor/rust/tests/play_server_e2e.rs` — end-to-end test using the existing B5W2 fixture. (create) + +**Build/run notes for the implementer.** All work goes on the current branch (don't switch). The server is built with `--features binary` (NOT `--all-features`, which enables `gpu` and would require `ORT_DYLIB_PATH`). Run cargo commands with sandbox disabled and long timeouts (release/test builds use LTO and are slow). AGENTS.md: commit subject starts with `vibe: ` imperative ≤50 chars; do NOT run `cargo fmt` between tasks (formatting is a separate final task per the project rule). + +--- + +## Task 1: Cargo.toml — add `tiny_http`, `ureq` dev-dep, register the `play_server` bin + +**Files:** +- Modify: `deep_quoridor/rust/Cargo.toml` + +- [ ] **Step 1: Add the `tiny_http` optional dep** + +In `[dependencies]`, near `serde_json`: + +```toml +tiny_http = { version = "0.12", optional = true } +``` + +- [ ] **Step 2: Add `"tiny_http"` to the `binary` feature list** + +Current line: +```toml +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"] +``` +Change to: +```toml +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures", "tiny_http"] +``` + +- [ ] **Step 3: Register the new bin** + +Below the existing `[[bin]]` entries (`create_policy_db`, `selfplay`), add: +```toml +[[bin]] +name = "play_server" +path = "src/bin/play_server.rs" +required-features = ["binary"] +``` + +- [ ] **Step 4: Add `ureq` as a dev-dependency for the end-to-end test** + +In `[dev-dependencies]`: +```toml +ureq = { version = "2", default-features = false, features = ["json"] } +``` + +- [ ] **Step 5: Verify the existing CPU build still passes** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin selfplay +``` +Expected: builds successfully. (`play_server` will fail until later tasks create it — that's fine; we only built `selfplay` here.) + +- [ ] **Step 6: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/Cargo.toml +git commit -m "vibe: add tiny_http dep + play_server bin entry" +``` + +--- + +## Task 2: `play_server` module scaffold + `StateView` + action enrichment + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/mod.rs` +- Create: `deep_quoridor/rust/src/play_server/state.rs` +- Modify: `deep_quoridor/rust/src/lib.rs` + +- [ ] **Step 1: Add the module to `lib.rs`** + +Add this `pub mod` declaration (gated by the `binary` feature) alongside the others. Find a `#[cfg(feature = "binary")]` block of `pub mod` lines in `src/lib.rs` and add: +```rust +#[cfg(feature = "binary")] +pub mod play_server; +``` + +- [ ] **Step 2: Create the module entry point** + +Create `deep_quoridor/rust/src/play_server/mod.rs`: +```rust +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod config; +pub mod handlers; +pub mod session; +pub mod state; +``` + +(`config`, `handlers`, `session` are created in later tasks; the build will only succeed at the end of Task 5. To unblock incremental builds, comment out the not-yet-created `pub mod` lines and uncomment as each task lands. **Implementer:** at this step, only leave `pub mod state;` uncommented.) + +So the file at the end of this task is: +```rust +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod state; +// pub mod config; // added in Task 3 +// pub mod session; // added in Task 4 +// pub mod handlers; // added in Task 5 +``` + +- [ ] **Step 3: Write the failing test for action enrichment + StateView** + +Create `deep_quoridor/rust/src/play_server/state.rs` with the test scaffolding first: +```rust +//! Pure types and helpers for the play-server `state` JSON shape and for +//! enriching action indices with their semantic board coordinates. + +use serde::Serialize; + +use crate::actions::action_index_to_action; + +/// Single legal action carried over the wire. The client never needs to know +/// the action-index encoding; it just looks at `kind` and the coords. +#[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, Copy, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WallOrientation { + H, + V, +} + +/// Snapshot of a `GameSession` for the client to render. Built by +/// `session::GameSession::view()` from the underlying `QGameMechanics` + +/// `CompactState`. +#[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, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WallEntry { + pub row: i32, + pub col: i32, + pub orientation: WallOrientation, +} + +/// Map an action index to its semantic enrichment (move dest / wall coords + +/// orientation). Matches the convention in `actions::action_index_to_action`: +/// indices < N*N are moves to row/col=index/N,index%N; the next (N-1)^2 are +/// vertical walls; the remaining (N-1)^2 are horizontal walls. +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 { + 0 => EnrichedAction::Move { + index: index as u32, + to: [row, col], + }, + 1 => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::V, + }, + 2 => EnrichedAction::Wall { + index: index as u32, + row, + col, + orientation: WallOrientation::H, + }, + other => panic!("unexpected action type {other} for index {index}"), + } +} + +/// Apply `enrich_action` to every legal index in `mask`. +pub fn enrich_legal_actions(board_size: i32, mask: &[bool]) -> Vec { + mask.iter() + .enumerate() + .filter_map(|(i, &legal)| legal.then(|| enrich_action(board_size, i))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::policy_size; + + #[test] + fn enrich_move_action_round_trips_coords() { + // First action on a 5x5 board: move to (0, 0). + let a = enrich_action(5, 0); + assert_eq!( + a, + EnrichedAction::Move { + index: 0, + to: [0, 0] + } + ); + + // Index N*N - 1 on 5x5 = last move cell = (4, 4). + let a = enrich_action(5, 24); + assert_eq!( + a, + EnrichedAction::Move { + index: 24, + to: [4, 4] + } + ); + } + + #[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; + + // First vertical wall is at index N*N. + let v = enrich_action(n, nn); + assert_eq!( + v, + EnrichedAction::Wall { + index: nn as u32, + row: 0, + col: 0, + orientation: WallOrientation::V + } + ); + + // First horizontal wall is at index N*N + (N-1)^2. + let h = enrich_action(n, nn + walls); + assert_eq!( + h, + EnrichedAction::Wall { + index: (nn + walls) as u32, + row: 0, + col: 0, + orientation: WallOrientation::H + } + ); + } + + #[test] + fn enrich_legal_actions_filters_by_mask() { + let n = 5; + let size = policy_size(n); + let mut mask = vec![false; size]; + mask[0] = true; + mask[(n * n) as usize] = true; // first vertical wall + + let actions = enrich_legal_actions(n, &mask); + assert_eq!(actions.len(), 2); + assert!(matches!( + actions[0], + EnrichedAction::Move { index: 0, .. } + )); + assert!(matches!( + actions[1], + EnrichedAction::Wall { + orientation: WallOrientation::V, + .. + } + )); + } + + #[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"}"# + ); + } +} +``` + +- [ ] **Step 4: Run the new tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server::state -- --nocapture +``` +Expected: all 4 tests pass. (If `action_index_to_action` returns a different action_type numbering than the comments above suggest, **read** `src/actions.rs` and adjust the `match action_type` arms accordingly — the goal is that vertical wall maps to `WallOrientation::V` and horizontal to `WallOrientation::H`.) + +- [ ] **Step 5: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/lib.rs deep_quoridor/rust/src/play_server +git commit -m "vibe: add play_server state view + action enrichment" +``` + +--- + +## Task 3: `ServerConfig` — load `/config.yaml` + list `models/*.onnx` + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/config.rs` +- Modify: `deep_quoridor/rust/src/play_server/mod.rs` + +- [ ] **Step 1: Uncomment the module line** + +Edit `src/play_server/mod.rs` so the file contains: +```rust +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod config; +pub mod state; +// pub mod session; // added in Task 4 +// pub mod handlers; // added in Task 5 +``` + +- [ ] **Step 2: Create the config module with tests** + +Create `deep_quoridor/rust/src/play_server/config.rs`: +```rust +//! Server-side configuration: derived from `/config.yaml` plus the +//! list of selectable models found in `/models/*.onnx`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// What the server needs from the play directory. +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub play_dir: PathBuf, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub default_mcts_c_puct: f32, + /// Filenames (not full paths) of `*.onnx` files in `/models/`. + pub models: Vec, +} + +/// Subset of `config.yaml` we actually parse. Anything else is ignored. +#[derive(Debug, Deserialize)] +struct ConfigFile { + quoridor: QuoridorSection, + #[serde(default)] + alphazero: AlphaZeroSection, +} + +#[derive(Debug, Deserialize)] +struct QuoridorSection { + board_size: i32, + max_walls: i32, + max_steps: i32, +} + +#[derive(Debug, Deserialize, Default)] +struct AlphaZeroSection { + #[serde(default)] + mcts_c_puct: Option, +} + +impl ServerConfig { + pub fn load(play_dir: &Path) -> Result { + let cfg_path = play_dir.join("config.yaml"); + let raw = std::fs::read_to_string(&cfg_path) + .with_context(|| format!("reading {}", cfg_path.display()))?; + let file: ConfigFile = serde_yaml::from_str(&raw) + .with_context(|| format!("parsing {}", cfg_path.display()))?; + + let models_dir = play_dir.join("models"); + let mut models: Vec = std::fs::read_dir(&models_dir) + .with_context(|| format!("reading {}", models_dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("onnx")) + .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) + .collect(); + models.sort(); + + Ok(Self { + play_dir: play_dir.to_path_buf(), + board_size: file.quoridor.board_size, + max_walls: file.quoridor.max_walls, + max_steps: file.quoridor.max_steps, + default_mcts_c_puct: file.alphazero.mcts_c_puct.unwrap_or(1.4), + models, + }) + } + + /// Full path to a chosen model file. Returns `None` if the name isn't in + /// the listed `models`. + pub fn model_path(&self, model: &str) -> Option { + if self.models.iter().any(|m| m == model) { + Some(self.play_dir.join("models").join(model)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn make_play_dir(yaml: &str, model_files: &[&str]) -> PathBuf { + let dir = tempfile::Builder::new() + .prefix("playsrv_test_") + .tempdir() + .expect("tempdir") + .into_path(); + fs::write(dir.join("config.yaml"), yaml).unwrap(); + fs::create_dir_all(dir.join("models")).unwrap(); + for f in model_files { + fs::write(dir.join("models").join(f), b"not really onnx").unwrap(); + } + dir + } + + #[test] + fn loads_minimal_config_and_lists_models_sorted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["model_002.onnx", "model_000.onnx", "ignore.txt"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert_eq!(cfg.board_size, 5); + assert_eq!(cfg.max_walls, 2); + assert_eq!(cfg.max_steps, 50); + assert!((cfg.default_mcts_c_puct - 1.4).abs() < 1e-6); + assert_eq!(cfg.models, vec!["model_000.onnx", "model_002.onnx"]); + } + + #[test] + fn picks_up_alphazero_c_puct_when_present() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n\ + alphazero:\n mcts_c_puct: 1.7\n", + &["m.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!((cfg.default_mcts_c_puct - 1.7).abs() < 1e-6); + } + + #[test] + fn model_path_returns_some_for_listed_and_none_for_unlisted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["a.onnx", "b.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!(cfg.model_path("a.onnx").is_some()); + assert!(cfg.model_path("c.onnx").is_none()); + } +} +``` + +- [ ] **Step 3: Run the new tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server::config -- --nocapture +``` +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/play_server +git commit -m "vibe: add play_server config loader and models scan" +``` + +--- + +## Task 4: `GameSession` + `GameRegistry` + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/session.rs` +- Modify: `deep_quoridor/rust/src/play_server/mod.rs` + +- [ ] **Step 1: Enable the module** + +Edit `src/play_server/mod.rs` so the not-yet-existing `handlers` is still commented but `session` is enabled: +```rust +pub mod config; +pub mod session; +pub mod state; +// pub mod handlers; // added in Task 5 +``` + +- [ ] **Step 2: Implement `GameSession` + `GameRegistry`** + +Create `deep_quoridor/rust/src/play_server/session.rs`: + +```rust +//! Per-game state (`GameSession`) and the shared registry that the HTTP +//! handlers look up by `game_id`. +//! +//! Each session owns its own `AlphaZeroAgent` so games run independently. The +//! registry holds an `Arc>` per game; an HTTP handler takes +//! the outer `Mutex` briefly to look up the session and then holds the inner +//! `Mutex` for the duration of the move + AI response. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Context, Result}; +use rand::RngCore; + +use crate::actions::policy_size; +use crate::agents::ActionSelector; +use crate::agents::alphazero::agent::{AlphaZeroAgent, AlphaZeroAgentConfig}; +use crate::agents::alphazero::evaluator::{Evaluator, UniformMockEvaluator}; +use crate::agents::alphazero::mcts::MCTSConfig; +use crate::compact::q_bit_repr::CompactState; +use crate::compact::q_game_mechanics::QGameMechanics; +use crate::play_server::config::ServerConfig; +use crate::play_server::state::{ + enrich_action, enrich_legal_actions, EnrichedAction, StateView, WallEntry, WallOrientation, +}; + +pub type GameId = String; + +/// One running game: owns the agent and the board state. +pub struct GameSession { + pub mechanics: QGameMechanics, + pub state: CompactState, + pub agent: AlphaZeroAgent, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub human_player: i32, + pub last_action: Option, + pub move_history: Vec, +} + +impl GameSession { + /// Construct an agent config matching the server's notion of "play-mode": + /// the requested `mcts_n`, temperature 0 (argmax visits), deterministic + /// tie-break, no Dirichlet noise, and the server's max_steps as the MCTS + /// search cap. + pub fn agent_config(mcts_n: u32, c_puct: f32, max_steps: i32) -> AlphaZeroAgentConfig { + AlphaZeroAgentConfig { + mcts: MCTSConfig { + n: Some(mcts_n), + k: None, + ucb_c: c_puct, + noise_epsilon: 0.0, + noise_alpha: None, + max_steps: Some(max_steps), + penalize_visited_states: false, + }, + temperature: 0.0, + drop_t_on_step: None, + penalize_visited_states: false, + deterministic_tie_break: true, + } + } + + /// Build a session that loads ONNX from disk. The session's mechanics + + /// initial state come from `cfg`. + pub fn new_from_onnx( + cfg: &ServerConfig, + model_path: &std::path::Path, + mcts_n: u32, + human_player: i32, + ) -> Result { + let mechanics = QGameMechanics::new( + cfg.board_size as usize, + cfg.max_walls as usize, + cfg.max_steps as usize, + ); + let state = mechanics.create_initial_state(); + let model_str = model_path + .to_str() + .ok_or_else(|| anyhow!("model path is not valid UTF-8"))?; + let agent_config = Self::agent_config(mcts_n, cfg.default_mcts_c_puct, cfg.max_steps); + let agent = AlphaZeroAgent::new(model_str, agent_config) + .context("constructing AlphaZeroAgent")?; + Ok(Self { + mechanics, + state, + agent, + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + human_player, + last_action: None, + move_history: Vec::new(), + }) + } + + /// Test-only variant that injects a fake evaluator instead of loading ORT. + #[cfg(test)] + pub fn new_with_evaluator( + board_size: i32, + max_walls: i32, + max_steps: i32, + evaluator: Box, + mcts_n: u32, + c_puct: f32, + 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(); + let agent_config = Self::agent_config(mcts_n, c_puct, max_steps); + let agent = AlphaZeroAgent::with_evaluator(evaluator, agent_config); + Self { + mechanics, + state, + agent, + 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 + } + + 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 + } + + fn legal_mask(&mut self) -> Vec { + self.mechanics.get_action_mask_immut(self.state) + } + + /// Apply one action (no matter whose turn). Records it in `last_action` + + /// `move_history`. Returns an error if the action is illegal. + pub fn apply_action(&mut self, action_index: u32) -> Result<()> { + if self.is_game_over() { + return Err(anyhow!("game is already over")); + } + let mask = self.legal_mask(); + let idx = action_index as usize; + if idx >= mask.len() || !mask[idx] { + return Err(anyhow!("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(()) + } + + /// Run the AI for one move on the current state. Errors if it's actually + /// the human's turn or the game is over. + pub fn ai_step(&mut self) -> Result<()> { + if self.is_game_over() { + return Ok(()); + } + if self.current_player() == self.human_player { + return Err(anyhow!("not AI's turn")); + } + let mask = self.legal_mask(); + let (action_idx, _policy) = self + .agent + .select_action(self.state, &self.mechanics, &mask) + .context("AI MCTS selection")?; + self.last_action = Some(enrich_action(self.board_size, action_idx)); + self.move_history.push(action_idx as u32); + self.mechanics + .apply_action_index(&mut self.state, action_idx); + Ok(()) + } + + /// Build the JSON-facing snapshot the client renders from. + pub fn view(&mut self) -> StateView { + let mask = self.legal_mask(); + let legal_actions = enrich_legal_actions(self.board_size, &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 p1w = repr.get_walls_remaining(self.state, 0) as i32; + let p2w = repr.get_walls_remaining(self.state, 1) as i32; + let completed_steps = repr.get_completed_steps(self.state) as i32; + let winner = if self.mechanics.check_win(self.state, 0) { + Some(0) + } else if self.mechanics.check_win(self.state, 1) { + Some(1) + } else { + None + }; + + 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: p1w, + p2_walls: p2w, + walls: list_walls(&self.mechanics, self.state, self.board_size), + legal_actions, + completed_steps, + winner, + human_player: self.human_player, + last_action: self.last_action.clone(), + move_history: self.move_history.clone(), + } + } +} + +/// Iterate every potential wall slot (`(N-1)^2` for each orientation) and ask +/// the mechanics whether a wall is currently present at that location. +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_idx in 0..2 { + let orientation = if orientation_idx == 0 { + WallOrientation::H + } else { + WallOrientation::V + }; + for row in 0..wall_size { + for col in 0..wall_size { + if mechanics + .repr() + .get_wall(state, row, col, orientation_idx == 0) + { + out.push(WallEntry { + row: row as i32, + col: col as i32, + orientation, + }); + } + } + } + } + out +} + +/// Thread-safe map `game_id -> GameSession`. +#[derive(Clone, Default)] +pub struct GameRegistry { + inner: Arc>>>>, +} + +impl GameRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, session: GameSession) -> GameId { + let id = new_game_id(); + self.inner + .lock() + .unwrap() + .insert(id.clone(), Arc::new(Mutex::new(session))); + id + } + + pub fn get(&self, game_id: &str) -> Option>> { + self.inner.lock().unwrap().get(game_id).cloned() + } +} + +fn new_game_id() -> GameId { + let mut bytes = [0u8; 4]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_session(human_player: i32) -> GameSession { + GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ) + } + + #[test] + fn initial_view_has_pawns_on_home_rows_and_no_walls() { + let mut s = mock_session(0); + let v = s.view(); + assert_eq!(v.board_size, 5); + assert_eq!(v.max_walls, 2); + assert_eq!(v.current_player, 0); + assert_eq!(v.completed_steps, 0); + assert!(v.walls.is_empty()); + assert_eq!(v.p1_walls, 2); + assert_eq!(v.p2_walls, 2); + assert!(v.winner.is_none()); + assert_eq!(v.human_player, 0); + assert!(v.last_action.is_none()); + assert!(v.move_history.is_empty()); + // Some legal move actions must exist at the start. + let has_move = v + .legal_actions + .iter() + .any(|a| matches!(a, EnrichedAction::Move { .. })); + assert!(has_move); + } + + #[test] + fn apply_action_records_last_action_and_advances_player() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let first_legal_move = mask + .iter() + .enumerate() + .find(|(_, &b)| b) + .map(|(i, _)| i as u32) + .expect("at least one legal action"); + s.apply_action(first_legal_move).unwrap(); + assert_eq!(s.move_history, vec![first_legal_move]); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); + } + + #[test] + fn apply_action_rejects_illegal_index() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let illegal = mask + .iter() + .enumerate() + .find(|(_, &b)| !b) + .map(|(i, _)| i as u32) + .expect("at least one illegal action"); + let err = s.apply_action(illegal).unwrap_err(); + assert!(err.to_string().contains("not legal")); + } + + #[test] + fn ai_step_errors_when_its_human_turn() { + let mut s = mock_session(0); // human is P1, AI is P2, P1 goes first + let err = s.ai_step().unwrap_err(); + assert!(err.to_string().contains("not AI's turn")); + } + + #[test] + fn ai_step_runs_when_its_ai_turn() { + let mut s = mock_session(1); // human is P2, AI is P1, AI goes first + s.ai_step().unwrap(); + assert_eq!(s.move_history.len(), 1); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); // now human (P2) to move + } + + #[test] + fn registry_insert_and_get_round_trip() { + let reg = GameRegistry::new(); + let id = reg.insert(mock_session(0)); + assert!(reg.get(&id).is_some()); + assert!(reg.get("does-not-exist").is_none()); + } + + #[test] + fn game_id_is_8_hex_chars() { + let id = new_game_id(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } +} +``` + +- [ ] **Step 3: Run the session tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server::session -- --nocapture +``` +Expected: all session tests pass. + +If `QGameMechanics::repr().get_wall(state, row, col, is_horizontal)` doesn't have that exact signature, **read** `src/compact/q_bit_repr.rs` for the actual `get_wall` signature and adjust `list_walls` accordingly. The semantic is "iterate every potential wall slot and ask if a wall is there"; the exact API call is local to that helper. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/play_server +git commit -m "vibe: add GameSession + GameRegistry" +``` + +--- + +## Task 5: HTTP handlers (pure functions) + +**Files:** +- Create: `deep_quoridor/rust/src/play_server/handlers.rs` +- Modify: `deep_quoridor/rust/src/play_server/mod.rs` + +- [ ] **Step 1: Enable the module** + +Edit `src/play_server/mod.rs` so all four `pub mod` lines are active: +```rust +pub mod config; +pub mod handlers; +pub mod session; +pub mod state; +``` + +- [ ] **Step 2: Implement the handlers** + +Create `deep_quoridor/rust/src/play_server/handlers.rs`: +```rust +//! Pure handler functions that the HTTP layer calls. These are unit-testable +//! without an actual TCP socket — they take parsed request bodies + the +//! shared state and return response structs. + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; + +use crate::play_server::config::ServerConfig; +use crate::play_server::session::{GameRegistry, GameSession}; +use crate::play_server::state::StateView; + +/// Reasonable bounds for the per-game MCTS slider. The server clamps inbound +/// values to this range. +pub const MCTS_N_MIN: u32 = 1; +pub const MCTS_N_MAX: u32 = 4000; + +#[derive(Debug, Serialize)] +pub struct ConfigResponse { + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub models: Vec, + pub default_mcts_n: u32, +} + +#[derive(Debug, Deserialize)] +pub struct NewGameRequest { + pub model: String, + pub mcts_n: u32, + pub human_player: i32, +} + +#[derive(Debug, Serialize)] +pub struct NewGameResponse { + pub game_id: String, + pub state: StateView, +} + +#[derive(Debug, Serialize)] +pub struct StateResponse { + pub state: StateView, +} + +#[derive(Debug, Deserialize)] +pub struct MoveRequest { + pub action_index: u32, +} + +/// Tag for `handle_*` failures so the HTTP layer can pick the right status. +#[derive(Debug)] +pub enum ApiError { + BadRequest(String), + NotFound(String), + Internal(String), +} + +pub fn handle_config(cfg: &ServerConfig, default_mcts_n: u32) -> ConfigResponse { + ConfigResponse { + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + models: cfg.models.clone(), + default_mcts_n, + } +} + +pub fn handle_new_game( + cfg: &ServerConfig, + registry: &GameRegistry, + req: NewGameRequest, +) -> Result { + if req.human_player != 0 && req.human_player != 1 { + return Err(ApiError::BadRequest(format!( + "human_player must be 0 or 1, got {}", + req.human_player + ))); + } + let mcts_n = req.mcts_n.clamp(MCTS_N_MIN, MCTS_N_MAX); + let model_path = cfg + .model_path(&req.model) + .ok_or_else(|| ApiError::BadRequest(format!("unknown model: {}", req.model)))?; + + let mut session = GameSession::new_from_onnx(cfg, &model_path, mcts_n, req.human_player) + .map_err(|e| ApiError::Internal(format!("constructing session: {e:#}")))?; + + // If the AI plays first, take its move before returning the initial state. + if session.human_player != 0 + && !session.view().winner.is_some() + && session.view().current_player == 1 - session.human_player + { + session + .ai_step() + .map_err(|e| ApiError::Internal(format!("initial AI move: {e:#}")))?; + } + let game_id = registry.insert(session); + let view = with_session(registry, &game_id, |s| Ok::<_, anyhow::Error>(s.view())) + .map_err(api_internal)?; + Ok(NewGameResponse { + game_id, + state: view, + }) +} + +pub fn handle_get_state( + registry: &GameRegistry, + game_id: &str, +) -> Result { + let view = with_session(registry, game_id, |s| Ok::<_, anyhow::Error>(s.view())) + .map_err(api_internal)?; + Ok(StateResponse { state: view }) +} + +pub fn handle_move( + registry: &GameRegistry, + game_id: &str, + req: MoveRequest, +) -> Result { + let view = with_session(registry, game_id, |s| { + if s.view().winner.is_some() { + return Err(anyhow!("game is over")); + } + if s.view().current_player != s.human_player { + return Err(anyhow!("not human's turn")); + } + s.apply_action(req.action_index)?; + // If after the human's move it's the AI's turn (and the game isn't + // over), run the AI in the same round-trip. + if s.view().winner.is_none() && s.view().current_player != s.human_player { + s.ai_step()?; + } + Ok(s.view()) + }) + .map_err(|e| { + let m = format!("{e:#}"); + if m.contains("not legal") || m.contains("not human") || m.contains("game is over") { + ApiError::BadRequest(m) + } else { + ApiError::Internal(m) + } + })?; + Ok(StateResponse { state: view }) +} + +fn with_session( + registry: &GameRegistry, + game_id: &str, + f: F, +) -> Result +where + F: FnOnce(&mut GameSession) -> Result, + E: Into, +{ + let lock = registry + .get(game_id) + .ok_or_else(|| anyhow!("unknown game_id"))?; + let mut session = lock.lock().unwrap(); + f(&mut session).map_err(Into::into) +} + +fn api_internal(e: anyhow::Error) -> ApiError { + let m = format!("{e:#}"); + if m.contains("unknown game_id") { + ApiError::NotFound(m) + } else { + ApiError::Internal(m) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::evaluator::UniformMockEvaluator; + + fn mock_session(human_player: i32) -> GameSession { + GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ) + } + + fn register(reg: &GameRegistry, s: GameSession) -> String { + reg.insert(s) + } + + #[test] + fn handle_get_state_returns_view_when_game_exists() { + let reg = GameRegistry::new(); + let id = register(®, mock_session(0)); + let r = handle_get_state(®, &id).unwrap(); + assert_eq!(r.state.board_size, 5); + } + + #[test] + fn handle_get_state_returns_not_found_for_bad_id() { + let reg = GameRegistry::new(); + match handle_get_state(®, "ffffffff") { + Err(ApiError::NotFound(_)) => {} + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn handle_move_rejects_illegal_action() { + let reg = GameRegistry::new(); + let id = register(®, mock_session(0)); + // Pick an index that's known to be illegal (e.g. moving to own square + // = the agent's starting cell, index = N*N - first/last cell). + // Easier: pick a wall index that can't exist (index 0 is a move and is + // not legal at the very start because move 0 is to (0,0)). + match handle_move( + ®, + &id, + MoveRequest { + action_index: u32::MAX - 1, + }, + ) { + Err(ApiError::BadRequest(msg)) => assert!(msg.contains("not legal") || msg.contains("range")), + other => panic!("expected BadRequest, got {other:?}"), + } + } +} +``` + +- [ ] **Step 3: Build + run tests** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --lib --no-default-features --features binary play_server -- --nocapture +``` +Expected: all `play_server::*` tests pass. + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/play_server +git commit -m "vibe: add play_server API handlers" +``` + +--- + +## Task 6: `bin/play_server.rs` — CLI, tiny_http loop, static assets + +**Files:** +- Create: `deep_quoridor/rust/src/bin/play_server.rs` +- Create: `deep_quoridor/rust/src/play_server/static/index.html` (placeholder; real content in Task 8) +- Create: `deep_quoridor/rust/src/play_server/static/app.css` (placeholder) +- Create: `deep_quoridor/rust/src/play_server/static/app.js` (placeholder) + +- [ ] **Step 1: Create the static asset placeholders** + +The bin file uses `include_str!` to embed these at compile time, so they must exist as files even before Task 8 fills them. Minimal stand-ins: + +`deep_quoridor/rust/src/play_server/static/index.html`: +```html +Quoridor +

Play UI placeholder — see Task 8.

+``` + +`deep_quoridor/rust/src/play_server/static/app.css`: +```css +/* Real styles in Task 8. */ +``` + +`deep_quoridor/rust/src/play_server/static/app.js`: +```js +// Real frontend logic in Task 8. +``` + +- [ ] **Step 2: Write the binary** + +Create `deep_quoridor/rust/src/bin/play_server.rs`: +```rust +//! Local web server for playing Quoridor against the project's AlphaZero +//! agent. See `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +use std::io::Read; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use tiny_http::{Header, Method, Response, Server}; + +use quoridor_rs::play_server::config::ServerConfig; +use quoridor_rs::play_server::handlers::{ + self, ApiError, MoveRequest, NewGameRequest, +}; +use quoridor_rs::play_server::session::GameRegistry; + +const INDEX_HTML: &str = include_str!("../play_server/static/index.html"); +const APP_CSS: &str = include_str!("../play_server/static/app.css"); +const APP_JS: &str = include_str!("../play_server/static/app.js"); + +#[derive(Parser)] +#[command(about = "Quoridor play server")] +struct Cli { + /// Directory containing `config.yaml` and `models/*.onnx`. + #[arg(long)] + play_dir: PathBuf, + + /// TCP port to listen on. + #[arg(long, default_value_t = 8080)] + port: u16, + + /// Bind address. Default is loopback; use `0.0.0.0` for LAN access. + #[arg(long, default_value = "127.0.0.1")] + bind: String, + + /// Default `mcts_n` the UI starts the slider at. + #[arg(long, default_value_t = 400)] + default_mcts_n: u32, +} + +struct App { + cfg: ServerConfig, + default_mcts_n: u32, + registry: GameRegistry, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let cfg = + ServerConfig::load(&cli.play_dir).with_context(|| format!("loading {:?}", cli.play_dir))?; + let app = Arc::new(App { + cfg, + default_mcts_n: cli.default_mcts_n, + registry: GameRegistry::new(), + }); + + let addr: SocketAddr = format!("{}:{}", cli.bind, cli.port) + .parse() + .context("parse bind addr")?; + let server = Server::http(addr).map_err(|e| anyhow::anyhow!("tiny_http listen: {e}"))?; + println!("play_server listening on http://{addr}"); + println!("models: {:?}", app.cfg.models); + + for mut request in server.incoming_requests() { + let app = Arc::clone(&app); + // tiny_http is sync; for simplicity handle in-thread (sessions take + // ~ms; AI moves dominate but each session has its own lock, so + // concurrent games still progress in parallel via multiple threads + // if we spawn one here). + std::thread::spawn(move || { + let response = route(&app, &mut request); + if let Err(e) = request.respond(response) { + eprintln!("response error: {e:#}"); + } + }); + } + Ok(()) +} + +fn route(app: &App, request: &mut tiny_http::Request) -> Response>> { + let url = request.url().to_string(); + let method = request.method().clone(); + + // Static files. + if method == Method::Get { + match url.as_str() { + "/" | "/index.html" => return html(INDEX_HTML, 200), + "/static/app.css" => return text(APP_CSS, 200, "text/css; charset=utf-8"), + "/static/app.js" => { + return text(APP_JS, 200, "application/javascript; charset=utf-8") + } + "/api/config" => { + let resp = handlers::handle_config(&app.cfg, app.default_mcts_n); + return json_ok(&resp); + } + u if u.starts_with("/api/games/") && !u[11..].contains('/') => { + let game_id = &u[11..]; + return match handlers::handle_get_state(&app.registry, game_id) { + Ok(r) => json_ok(&r), + Err(e) => api_err_response(e), + }; + } + _ => {} + } + } else if method == Method::Post { + match url.as_str() { + "/api/games" => { + let body: NewGameRequest = match read_json(request) { + Ok(b) => b, + Err(msg) => return json_err(400, &msg), + }; + return match handlers::handle_new_game(&app.cfg, &app.registry, body) { + Ok(r) => json_ok(&r), + Err(e) => api_err_response(e), + }; + } + u if u.starts_with("/api/games/") && u.ends_with("/move") => { + let game_id = &u["/api/games/".len()..u.len() - "/move".len()]; + let body: MoveRequest = match read_json(request) { + Ok(b) => b, + Err(msg) => return json_err(400, &msg), + }; + return match handlers::handle_move(&app.registry, game_id, body) { + Ok(r) => json_ok(&r), + Err(e) => api_err_response(e), + }; + } + _ => {} + } + } + + text("not found", 404, "text/plain; charset=utf-8") +} + +fn read_json(request: &mut tiny_http::Request) -> Result { + let mut buf = String::new(); + request + .as_reader() + .read_to_string(&mut buf) + .map_err(|e| format!("reading body: {e}"))?; + serde_json::from_str(&buf).map_err(|e| format!("parsing body: {e}")) +} + +fn json_ok(body: &T) -> Response>> { + let s = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec()); + let mut r = Response::from_data(s).with_status_code(200); + r.add_header(Header::from_bytes("Content-Type", "application/json; charset=utf-8").unwrap()); + r +} + +fn json_err(code: u32, msg: &str) -> Response>> { + let body = serde_json::json!({ "error": msg }).to_string(); + let mut r = Response::from_string(body).with_status_code(code); + r.add_header(Header::from_bytes("Content-Type", "application/json; charset=utf-8").unwrap()); + r +} + +fn api_err_response(e: ApiError) -> Response>> { + match e { + ApiError::BadRequest(m) => json_err(400, &m), + ApiError::NotFound(m) => json_err(404, &m), + ApiError::Internal(m) => json_err(500, &m), + } +} + +fn html(body: &str, code: u32) -> Response>> { + text(body, code, "text/html; charset=utf-8") +} + +fn text(body: &str, code: u32, content_type: &str) -> Response>> { + let mut r = Response::from_string(body.to_string()).with_status_code(code); + r.add_header(Header::from_bytes("Content-Type", content_type).unwrap()); + r +} +``` + +- [ ] **Step 3: Build the bin** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin play_server +``` +Expected: builds successfully. (`Response::from_string` / `from_data` return slightly different concrete types in `tiny_http` 0.12; if the `Response>>` return signature mismatches, switch the helpers to return `Response` for `from_string` and convert via `.boxed()` consistently. The simplest is to import `tiny_http::Response` aliased without the generic and use `Response::from_data(Vec)` throughout so all helpers share the `Cursor>` type. Adjust as the compiler guides.) + +- [ ] **Step 4: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/src/bin/play_server.rs deep_quoridor/rust/src/play_server/static +git commit -m "vibe: add play_server bin with tiny_http loop" +``` + +--- + +## Task 7: End-to-end test using the existing B5W2 fixture + +**Files:** +- Create: `deep_quoridor/rust/tests/play_server_e2e.rs` + +- [ ] **Step 1: Write the failing test** + +Create `deep_quoridor/rust/tests/play_server_e2e.rs`: +```rust +//! End-to-end test: spawn the play server bound to port 0, drive it via HTTP, +//! and verify state transitions on a tiny game using the existing B5W2 +//! fixture model. + +#![cfg(feature = "binary")] + +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn fixture_onnx() -> PathBuf { + workspace_root().join("fixtures/alphazero_B5W2_mv1.onnx") +} + +fn make_play_dir() -> PathBuf { + let dir = tempfile::Builder::new() + .prefix("play_e2e_") + .tempdir() + .expect("tempdir") + .into_path(); + std::fs::write( + dir.join("config.yaml"), + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + ) + .unwrap(); + std::fs::create_dir_all(dir.join("models")).unwrap(); + std::fs::copy( + fixture_onnx(), + dir.join("models").join("alphazero_B5W2_mv1.onnx"), + ) + .unwrap(); + dir +} + +struct ServerProc { + child: Child, + port: u16, +} + +impl Drop for ServerProc { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn spawn_server(play_dir: &std::path::Path) -> ServerProc { + // Use a fixed high-ish port; if collision, the test will retry. + let port = pick_port(); + let bin = workspace_root().join("target/debug/play_server"); + let child = Command::new(&bin) + .args([ + "--play-dir", + play_dir.to_str().unwrap(), + "--port", + &port.to_string(), + "--bind", + "127.0.0.1", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn play_server"); + wait_ready(port); + ServerProc { child, port } +} + +fn pick_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + port +} + +fn wait_ready(port: u16) { + let deadline = Instant::now() + Duration::from_secs(15); + let url = format!("http://127.0.0.1:{port}/api/config"); + while Instant::now() < deadline { + if ureq::get(&url).call().is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(150)); + } + panic!("play_server never became ready on port {port}"); +} + +#[test] +fn e2e_new_game_and_first_move() { + // Build the bin once; ignore exit code (`cargo test` already builds bins + // it depends on, but we want explicit failure if it doesn't). + assert!( + Command::new(env!("CARGO")) + .args([ + "build", + "--no-default-features", + "--features", + "binary", + "--bin", + "play_server", + ]) + .status() + .expect("cargo build") + .success(), + "cargo build of play_server failed" + ); + + let dir = make_play_dir(); + let server = spawn_server(&dir); + let base = format!("http://127.0.0.1:{}", server.port); + + // /api/config lists the fixture model + the board config. + let cfg: Value = ureq::get(&format!("{base}/api/config")) + .call() + .unwrap() + .into_json() + .unwrap(); + assert_eq!(cfg["board_size"], 5); + assert_eq!(cfg["max_walls"], 2); + assert!(cfg["models"] + .as_array() + .unwrap() + .iter() + .any(|m| m == "alphazero_B5W2_mv1.onnx")); + + // POST /api/games -> initial state. + let new_game: Value = ureq::post(&format!("{base}/api/games")) + .send_json(serde_json::json!({ + "model": "alphazero_B5W2_mv1.onnx", + "mcts_n": 8, + "human_player": 0, + })) + .unwrap() + .into_json() + .unwrap(); + let game_id = new_game["game_id"].as_str().unwrap().to_string(); + assert_eq!(new_game["state"]["board_size"], 5); + assert_eq!(new_game["state"]["current_player"], 0); + assert_eq!(new_game["state"]["human_player"], 0); + assert!(new_game["state"]["legal_actions"] + .as_array() + .unwrap() + .iter() + .any(|a| a["kind"] == "move")); + + // Pick any legal move action; POST /api/games//move. + let first_move_idx = new_game["state"]["legal_actions"] + .as_array() + .unwrap() + .iter() + .find_map(|a| { + if a["kind"] == "move" { + a["index"].as_u64().map(|x| x as u32) + } else { + None + } + }) + .expect("at least one move action"); + let after: Value = ureq::post(&format!("{base}/api/games/{game_id}/move")) + .send_json(serde_json::json!({ "action_index": first_move_idx })) + .unwrap() + .into_json() + .unwrap(); + // After human move + AI response, it's the human's turn again, and + // history grew by exactly 2. + assert_eq!(after["state"]["human_player"], 0); + assert_eq!(after["state"]["current_player"], 0); + assert_eq!(after["state"]["move_history"].as_array().unwrap().len(), 2); +} +``` + +- [ ] **Step 2: Run the test** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo test --no-default-features --features binary --test play_server_e2e -- --nocapture +``` +Expected: `e2e_new_game_and_first_move` passes (≈ a few seconds — the fixture model is tiny). + +- [ ] **Step 3: Commit** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole +git add deep_quoridor/rust/tests/play_server_e2e.rs +git commit -m "vibe: e2e test for play_server against B5W2 fixture" +``` + +--- + +## Task 8: Frontend — `index.html`, `app.css`, `app.js` + +**Files:** +- Modify (replace placeholder): `deep_quoridor/rust/src/play_server/static/index.html` +- Modify: `deep_quoridor/rust/src/play_server/static/app.css` +- Modify: `deep_quoridor/rust/src/play_server/static/app.js` + +- [ ] **Step 1: Replace `index.html`** + +```html + + + + +Quoridor vs AlphaZero + + + +
+

Quoridor vs AlphaZero

+ +
+ + + + +
+ + +
+ + + +``` + +- [ ] **Step 2: Replace `app.css`** + +```css +* { box-sizing: border-box; } +body { font-family: system-ui, sans-serif; margin: 1.5em; background: #fafafa; color: #222; } +#app { max-width: 1000px; margin: 0 auto; } +h1 { margin-top: 0; } +.panel { background: #fff; padding: 1em; border: 1px solid #ddd; border-radius: 6px; } +#setup label { display: block; margin-bottom: 0.75em; } +#setup button { padding: 0.5em 1em; } +#game { display: flex; gap: 1em; margin-top: 1em; align-items: flex-start; } + +#board { + display: grid; + background: #d8b876; + padding: 4px; + border-radius: 6px; + --cell: 44px; + --slot: 8px; +} +#board > div { + display: flex; + align-items: center; + justify-content: center; +} +#board > .cell { + background: #f1d9a1; + border-radius: 3px; + font-weight: bold; +} +#board > .cell.legal { background: #c8e8a6; cursor: pointer; } +#board > .cell.legal:hover { background: #a4d878; } +#board > .cell.last { outline: 3px solid #f08020; } +#board > .pawn-p1 { color: #1a48d8; } +#board > .pawn-p2 { color: #d8281a; } +#board > .pawn-p1::before { content: '\25CF'; font-size: 1.5em; } +#board > .pawn-p2::before { content: '\25CF'; font-size: 1.5em; } +#board > .wallslot { background: transparent; } +#board > .wallslot.legal { background: #c8e8a6aa; cursor: pointer; } +#board > .wallslot.legal:hover { background: #a4d878dd; } +#board > .wallslot.wall { background: #5a3a18; border-radius: 2px; } +#board > .post { background: #b08850; border-radius: 1px; } + +#info { min-width: 220px; } +#status { font-weight: bold; min-height: 1.2em; } +.thinking #status::after { content: ' (AI thinking…)'; font-weight: normal; color: #666; } +``` + +- [ ] **Step 3: Replace `app.js`** + +```javascript +"use strict"; + +const $ = (id) => document.getElementById(id); +let CONFIG = null; +let GAME = null; + +async function fetchJson(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const r = await fetch(path, opts); + const text = await r.text(); + const data = text ? JSON.parse(text) : {}; + if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`); + return data; +} + +async function init() { + CONFIG = await fetchJson("GET", "/api/config"); + const modelSel = $("model"); + modelSel.innerHTML = ""; + for (const m of CONFIG.models) { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + modelSel.appendChild(opt); + } + const slider = $("mcts"); + slider.value = CONFIG.default_mcts_n; + $("mcts-val").value = slider.value; + slider.addEventListener("input", () => ($("mcts-val").value = slider.value)); + + $("start").addEventListener("click", startGame); + $("newgame2").addEventListener("click", () => { + $("setup").hidden = false; + $("game").hidden = true; + }); +} + +async function startGame() { + const body = { + model: $("model").value, + mcts_n: parseInt($("mcts").value, 10), + human_player: parseInt($("first").value, 10), + }; + const data = await fetchJson("POST", "/api/games", body); + GAME = { id: data.game_id, state: data.state }; + $("setup").hidden = true; + $("game").hidden = false; + render(); +} + +function render() { + const s = GAME.state; + const N = s.board_size; + const board = $("board"); + board.style.gridTemplateColumns = `repeat(${2 * N - 1}, var(--col-size))`; + board.style.gridAutoRows = "var(--row-size)"; + board.style.setProperty("--col-size", "var(--cell)"); + board.style.setProperty("--row-size", "var(--cell)"); + + board.innerHTML = ""; + const mirror = s.human_player === 1; + // (gridR, gridC) is the (2N-1)x(2N-1) grid position. + for (let gr = 0; gr < 2 * N - 1; gr++) { + for (let gc = 0; gc < 2 * N - 1; gc++) { + const el = document.createElement("div"); + // Display row is mirrored so the human's home row is at the bottom. + el.style.gridRow = `${(mirror ? gr : 2 * N - 2 - gr) + 1}`; + el.style.gridColumn = `${gc + 1}`; + + if (gr % 2 === 0 && gc % 2 === 0) { + // pawn cell + const r = gr / 2; + const c = gc / 2; + el.className = "cell"; + el.style.width = "var(--cell)"; + el.style.height = "var(--cell)"; + if (s.p1_pos[0] === r && s.p1_pos[1] === c) el.classList.add("pawn-p1"); + if (s.p2_pos[0] === r && s.p2_pos[1] === c) el.classList.add("pawn-p2"); + el.dataset.kind = "cell"; + el.dataset.r = r; + el.dataset.c = c; + } else if (gr % 2 === 1 && gc % 2 === 1) { + el.className = "post"; + el.style.width = "var(--slot)"; + el.style.height = "var(--slot)"; + } else { + // wall slot + el.className = "wallslot"; + const horizontal = gr % 2 === 1; + el.dataset.kind = horizontal ? "wall-h" : "wall-v"; + el.dataset.r = horizontal ? (gr - 1) / 2 : gr / 2; + el.dataset.c = horizontal ? gc / 2 : (gc - 1) / 2; + if (horizontal) { + el.style.height = "var(--slot)"; + el.style.gridColumn = `${gc + 1} / span 3`; + } else { + el.style.width = "var(--slot)"; + el.style.gridRow = `${(mirror ? gr : 2 * N - 2 - gr) + 1} / span 3`; + } + } + board.appendChild(el); + } + } + + // Mark existing walls. + for (const w of s.walls) { + const sel = + w.orientation === "h" + ? `.wallslot[data-kind="wall-h"][data-r="${w.row}"][data-c="${w.col}"]` + : `.wallslot[data-kind="wall-v"][data-r="${w.row}"][data-c="${w.col}"]`; + document.querySelectorAll(sel).forEach((el) => el.classList.add("wall")); + } + // Highlight last action. + if (s.last_action) { + const a = s.last_action; + if (a.kind === "move") { + document + .querySelectorAll(`.cell[data-r="${a.to[0]}"][data-c="${a.to[1]}"]`) + .forEach((el) => el.classList.add("last")); + } + } + + // Mark legal actions only when it's the human's turn. + if (s.winner === null && s.current_player === s.human_player) { + for (const a of s.legal_actions) { + let el = null; + if (a.kind === "move") { + el = board.querySelector(`.cell[data-r="${a.to[0]}"][data-c="${a.to[1]}"]`); + } else { + const kind = a.orientation === "h" ? "wall-h" : "wall-v"; + el = board.querySelector( + `.wallslot[data-kind="${kind}"][data-r="${a.row}"][data-c="${a.col}"]`, + ); + } + if (el) { + el.classList.add("legal"); + el.addEventListener("click", () => playMove(a.index), { once: true }); + } + } + } + + // Side panel. + const turnName = s.winner !== null ? "Game over" : s.current_player === 0 ? "Player 1" : "Player 2"; + $("turn").textContent = turnName; + $("you").textContent = s.human_player === 0 ? "Player 1" : "Player 2"; + const hWalls = s.human_player === 0 ? s.p1_walls : s.p2_walls; + const aWalls = s.human_player === 0 ? s.p2_walls : s.p1_walls; + $("hwalls").textContent = hWalls; + $("awalls").textContent = aWalls; + $("steps").textContent = s.completed_steps; + const status = + s.winner === null + ? "" + : s.winner === s.human_player + ? "You won 🎉" + : "AI won"; + $("status").textContent = status; +} + +async function playMove(actionIndex) { + document.body.classList.add("thinking"); + try { + const data = await fetchJson("POST", `/api/games/${GAME.id}/move`, { + action_index: actionIndex, + }); + GAME.state = data.state; + render(); + } catch (e) { + alert(`move rejected: ${e.message}`); + } finally { + document.body.classList.remove("thinking"); + } +} + +init(); +``` + +- [ ] **Step 4: Rebuild + manual smoke check** + +```bash +cd /home/jbinney/ws/deep_rabbit_hole/deep_quoridor/rust && cargo build --no-default-features --features binary --bin play_server +``` +Expected: builds (`include_str!` re-embeds the new files). + +Then, manually: +```bash +mkdir -p /tmp/quoridor-play/models +cp deep_quoridor/rust/fixtures/alphazero_B5W2_mv1.onnx /tmp/quoridor-play/models/ +cat > /tmp/quoridor-play/config.yaml <>` per game); §Cargo.toml additions → Task 1; §Testing → Tasks 2–5 unit tests + Task 7 e2e; §Out of scope kept out (no auth, no persistence, no undo, etc.). All mapped. +- **Placeholder scan:** none — every step has concrete code or exact commands. Tasks 6 and 4 carry small "if API signature differs, read X and adapt" notes for two specific calls (`tiny_http::Response` generics; `repr().get_wall`) where the exact compile-time shape isn't guaranteed without reading those files; both name the file to read and what to preserve. +- **Type consistency:** `StateView` (Task 2) field names match the JSON in the spec, the e2e test assertions (Task 7), and the frontend reads (Task 8). `EnrichedAction` tag/`kind` naming consistent across server, e2e test (`"kind":"move"`), and JS. `GameRegistry::get`/`insert` signatures are the same in Tasks 4, 5, and 6. `ApiError::{BadRequest,NotFound,Internal}` defined in Task 5 and consumed in Task 6. From e58cad1c2fc39dec424defc5030f711a8d867a55 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 11:48:36 -0400 Subject: [PATCH 13/30] vibe: add tiny_http dep + play_server bin entry --- deep_quoridor/rust/Cargo.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml index 8d1777cf..9f3e4878 100644 --- a/deep_quoridor/rust/Cargo.toml +++ b/deep_quoridor/rust/Cargo.toml @@ -21,6 +21,11 @@ name = "selfplay" path = "src/bin/selfplay.rs" required-features = ["binary"] +[[bin]] +name = "play_server" +path = "src/bin/play_server.rs" +required-features = ["binary"] + [dependencies] pyo3 = { version = "0.22", features = ["extension-module"], optional = true } numpy = { version = "0.22", optional = true } @@ -40,6 +45,7 @@ ort = { version = "=2.0.0-rc.12", optional = true } anyhow = "1" serde_yaml = { version = "0.9", optional = true } 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 } @@ -50,12 +56,13 @@ smallvec = "1.13" [features] default = ["python"] python = ["pyo3", "numpy"] -binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures"] +binary = ["clap", "ort", "serde_yaml", "serde_json", "ndarray-npy", "zip", "rand_distr", "tokio", "futures", "tiny_http"] gpu = ["binary", "ort/cuda", "ort/load-dynamic"] [dev-dependencies] tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] } +ureq = { version = "2", default-features = false, features = ["json"] } [profile.release] # Enable optimizations for better performance From 810907db93d3f0120842273b602a28f3cea4966a Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 11:52:26 -0400 Subject: [PATCH 14/30] vibe: add play_server state view + action enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds play_server module with state.rs containing EnrichedAction, WallOrientation, StateView, WallEntry types and enrich_action / enrich_legal_actions helpers. ACTION constants differ from plan assumptions: WALL_VERTICAL=0, WALL_HORIZONTAL=1, MOVE=2 — match arms updated accordingly. --- deep_quoridor/rust/src/lib.rs | 2 + deep_quoridor/rust/src/play_server/mod.rs | 9 + deep_quoridor/rust/src/play_server/state.rs | 196 ++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 deep_quoridor/rust/src/play_server/mod.rs create mode 100644 deep_quoridor/rust/src/play_server/state.rs diff --git a/deep_quoridor/rust/src/lib.rs b/deep_quoridor/rust/src/lib.rs index 9ff009fb..5a3855a2 100644 --- a/deep_quoridor/rust/src/lib.rs +++ b/deep_quoridor/rust/src/lib.rs @@ -32,6 +32,8 @@ mod python_consistency; #[cfg(feature = "binary")] pub mod game_runner; #[cfg(feature = "binary")] +pub mod play_server; +#[cfg(feature = "binary")] pub mod replay_writer; #[cfg(feature = "binary")] pub mod selfplay_config; diff --git a/deep_quoridor/rust/src/play_server/mod.rs b/deep_quoridor/rust/src/play_server/mod.rs new file mode 100644 index 00000000..7c618be8 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/mod.rs @@ -0,0 +1,9 @@ +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture overview is in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. + +pub mod state; +// pub mod config; // added in Task 3 +// pub mod session; // added in Task 4 +// pub mod handlers; // added in Task 5 diff --git a/deep_quoridor/rust/src/play_server/state.rs b/deep_quoridor/rust/src/play_server/state.rs new file mode 100644 index 00000000..8ea74957 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/state.rs @@ -0,0 +1,196 @@ +//! Pure types and helpers for the play-server `state` JSON shape and for +//! enriching action indices with their semantic board coordinates. + +use serde::Serialize; + +use crate::actions::{action_index_to_action, ACTION_MOVE, ACTION_WALL_HORIZONTAL, ACTION_WALL_VERTICAL}; + +/// Single legal action carried over the wire. The client never needs to know +/// the action-index encoding; it just looks at `kind` and the coords. +#[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, Copy, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WallOrientation { + H, + V, +} + +/// Snapshot of a `GameSession` for the client to render. Built by +/// `session::GameSession::view()` from the underlying `QGameMechanics` + +/// `CompactState`. +#[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, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WallEntry { + pub row: i32, + pub col: i32, + pub orientation: WallOrientation, +} + +/// Map an action index to its semantic enrichment (move dest / wall coords + +/// orientation). Matches the convention in `actions::action_index_to_action`: +/// indices < N*N are moves; the next (N-1)^2 are vertical walls; the +/// remaining (N-1)^2 are horizontal walls. +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}"), + } +} + +/// Apply `enrich_action` to every legal index in `mask`. +pub fn enrich_legal_actions(board_size: i32, mask: &[bool]) -> Vec { + mask.iter() + .enumerate() + .filter_map(|(i, &legal)| legal.then(|| enrich_action(board_size, i))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::policy_size; + + #[test] + fn enrich_move_action_round_trips_coords() { + // First action on a 5x5 board: move to (0, 0). + let a = enrich_action(5, 0); + assert_eq!( + a, + EnrichedAction::Move { + index: 0, + to: [0, 0] + } + ); + + // Index N*N - 1 on 5x5 = last move cell = (4, 4). + let a = enrich_action(5, 24); + assert_eq!( + a, + EnrichedAction::Move { + index: 24, + to: [4, 4] + } + ); + } + + #[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; + + // First vertical wall is at index N*N. + let v = enrich_action(n, nn); + assert_eq!( + v, + EnrichedAction::Wall { + index: nn as u32, + row: 0, + col: 0, + orientation: WallOrientation::V + } + ); + + // First horizontal wall is at index N*N + (N-1)^2. + let h = enrich_action(n, nn + walls); + assert_eq!( + h, + EnrichedAction::Wall { + index: (nn + walls) as u32, + row: 0, + col: 0, + orientation: WallOrientation::H + } + ); + } + + #[test] + fn enrich_legal_actions_filters_by_mask() { + let n = 5; + let size = policy_size(n); + let mut mask = vec![false; size]; + mask[0] = true; + mask[(n * n) as usize] = true; // first vertical wall + + let actions = enrich_legal_actions(n, &mask); + assert_eq!(actions.len(), 2); + assert!(matches!( + actions[0], + EnrichedAction::Move { index: 0, .. } + )); + assert!(matches!( + actions[1], + EnrichedAction::Wall { + orientation: WallOrientation::V, + .. + } + )); + } + + #[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"}"# + ); + } +} From 8386d2f8b60323410bf75245e50048996e4f6bea Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 11:56:38 -0400 Subject: [PATCH 15/30] vibe: add play_server config loader and models scan --- deep_quoridor/rust/src/play_server/config.rs | 134 +++++++++++++++++++ deep_quoridor/rust/src/play_server/mod.rs | 2 +- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 deep_quoridor/rust/src/play_server/config.rs diff --git a/deep_quoridor/rust/src/play_server/config.rs b/deep_quoridor/rust/src/play_server/config.rs new file mode 100644 index 00000000..0c7adb35 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/config.rs @@ -0,0 +1,134 @@ +//! Server-side configuration: derived from `/config.yaml` plus the +//! list of selectable models found in `/models/*.onnx`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// What the server needs from the play directory. +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub play_dir: PathBuf, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub default_mcts_c_puct: f32, + /// Filenames (not full paths) of `*.onnx` files in `/models/`. + pub models: Vec, +} + +/// Subset of `config.yaml` we actually parse. Anything else is ignored. +#[derive(Debug, Deserialize)] +struct ConfigFile { + quoridor: QuoridorSection, + #[serde(default)] + alphazero: AlphaZeroSection, +} + +#[derive(Debug, Deserialize)] +struct QuoridorSection { + board_size: i32, + max_walls: i32, + max_steps: i32, +} + +#[derive(Debug, Deserialize, Default)] +struct AlphaZeroSection { + #[serde(default)] + mcts_c_puct: Option, +} + +impl ServerConfig { + pub fn load(play_dir: &Path) -> Result { + let cfg_path = play_dir.join("config.yaml"); + let raw = std::fs::read_to_string(&cfg_path) + .with_context(|| format!("reading {}", cfg_path.display()))?; + let file: ConfigFile = serde_yaml::from_str(&raw) + .with_context(|| format!("parsing {}", cfg_path.display()))?; + + let models_dir = play_dir.join("models"); + let mut models: Vec = std::fs::read_dir(&models_dir) + .with_context(|| format!("reading {}", models_dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("onnx")) + .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) + .collect(); + models.sort(); + + Ok(Self { + play_dir: play_dir.to_path_buf(), + board_size: file.quoridor.board_size, + max_walls: file.quoridor.max_walls, + max_steps: file.quoridor.max_steps, + default_mcts_c_puct: file.alphazero.mcts_c_puct.unwrap_or(1.4), + models, + }) + } + + /// Full path to a chosen model file. Returns `None` if the name isn't in + /// the listed `models`. + pub fn model_path(&self, model: &str) -> Option { + if self.models.iter().any(|m| m == model) { + Some(self.play_dir.join("models").join(model)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn make_play_dir(yaml: &str, model_files: &[&str]) -> PathBuf { + let dir = tempfile::Builder::new() + .prefix("playsrv_test_") + .tempdir() + .expect("tempdir") + .keep(); + fs::write(dir.join("config.yaml"), yaml).unwrap(); + fs::create_dir_all(dir.join("models")).unwrap(); + for f in model_files { + fs::write(dir.join("models").join(f), b"not really onnx").unwrap(); + } + dir + } + + #[test] + fn loads_minimal_config_and_lists_models_sorted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["model_002.onnx", "model_000.onnx", "ignore.txt"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert_eq!(cfg.board_size, 5); + assert_eq!(cfg.max_walls, 2); + assert_eq!(cfg.max_steps, 50); + assert!((cfg.default_mcts_c_puct - 1.4).abs() < 1e-6); + assert_eq!(cfg.models, vec!["model_000.onnx", "model_002.onnx"]); + } + + #[test] + fn picks_up_alphazero_c_puct_when_present() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n\ + alphazero:\n mcts_c_puct: 1.7\n", + &["m.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!((cfg.default_mcts_c_puct - 1.7).abs() < 1e-6); + } + + #[test] + fn model_path_returns_some_for_listed_and_none_for_unlisted() { + let dir = make_play_dir( + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + &["a.onnx", "b.onnx"], + ); + let cfg = ServerConfig::load(&dir).unwrap(); + assert!(cfg.model_path("a.onnx").is_some()); + assert!(cfg.model_path("c.onnx").is_none()); + } +} diff --git a/deep_quoridor/rust/src/play_server/mod.rs b/deep_quoridor/rust/src/play_server/mod.rs index 7c618be8..383f3082 100644 --- a/deep_quoridor/rust/src/play_server/mod.rs +++ b/deep_quoridor/rust/src/play_server/mod.rs @@ -3,7 +3,7 @@ //! Architecture overview is in //! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. +pub mod config; pub mod state; -// pub mod config; // added in Task 3 // pub mod session; // added in Task 4 // pub mod handlers; // added in Task 5 From e8a391fc0840f53a6baab83f9d1d8cba93510251 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:00:21 -0400 Subject: [PATCH 16/30] vibe: add GameSession + GameRegistry Co-Authored-By: Claude Sonnet 4.6 --- deep_quoridor/rust/src/play_server/mod.rs | 2 +- deep_quoridor/rust/src/play_server/session.rs | 378 ++++++++++++++++++ 2 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 deep_quoridor/rust/src/play_server/session.rs diff --git a/deep_quoridor/rust/src/play_server/mod.rs b/deep_quoridor/rust/src/play_server/mod.rs index 383f3082..593c6043 100644 --- a/deep_quoridor/rust/src/play_server/mod.rs +++ b/deep_quoridor/rust/src/play_server/mod.rs @@ -4,6 +4,6 @@ //! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. pub mod config; +pub mod session; pub mod state; -// pub mod session; // added in Task 4 // pub mod handlers; // added in Task 5 diff --git a/deep_quoridor/rust/src/play_server/session.rs b/deep_quoridor/rust/src/play_server/session.rs new file mode 100644 index 00000000..2da3f28a --- /dev/null +++ b/deep_quoridor/rust/src/play_server/session.rs @@ -0,0 +1,378 @@ +//! Per-game state (`GameSession`) and the shared registry that the HTTP +//! handlers look up by `game_id`. +//! +//! Each session owns its own `AlphaZeroAgent` so games run independently. The +//! registry holds an `Arc>` per game; an HTTP handler takes +//! the outer `Mutex` briefly to look up the session and then holds the inner +//! `Mutex` for the duration of the move + AI response. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Context, Result}; +use rand::RngCore; + +use crate::agents::ActionSelector; +use crate::agents::alphazero::agent::{AlphaZeroAgent, AlphaZeroAgentConfig}; +use crate::agents::alphazero::evaluator::Evaluator; +use crate::agents::alphazero::mcts::MCTSConfig; +use crate::compact::q_bit_repr::{CompactState, WALL_HORIZONTAL, WALL_VERTICAL}; +use crate::compact::q_game_mechanics::QGameMechanics; +use crate::play_server::config::ServerConfig; +use crate::play_server::state::{ + enrich_action, enrich_legal_actions, EnrichedAction, StateView, WallEntry, WallOrientation, +}; + +pub type GameId = String; + +/// One running game: owns the agent and the board state. +pub struct GameSession { + pub mechanics: QGameMechanics, + pub state: CompactState, + pub agent: AlphaZeroAgent, + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub human_player: i32, + pub last_action: Option, + pub move_history: Vec, +} + +impl GameSession { + /// Construct an agent config matching the server's notion of "play-mode": + /// the requested `mcts_n`, temperature 0 (argmax visits), deterministic + /// tie-break, no Dirichlet noise, and the server's max_steps as the MCTS + /// search cap. + pub fn agent_config(mcts_n: u32, c_puct: f32, max_steps: i32) -> AlphaZeroAgentConfig { + AlphaZeroAgentConfig { + mcts: MCTSConfig { + n: Some(mcts_n), + k: None, + ucb_c: c_puct, + noise_epsilon: 0.0, + noise_alpha: None, + max_steps: Some(max_steps), + penalize_visited_states: false, + }, + temperature: 0.0, + drop_t_on_step: None, + penalize_visited_states: false, + deterministic_tie_break: true, + } + } + + /// Build a session that loads ONNX from disk. The session's mechanics + + /// initial state come from `cfg`. + pub fn new_from_onnx( + cfg: &ServerConfig, + model_path: &std::path::Path, + mcts_n: u32, + human_player: i32, + ) -> Result { + let mechanics = QGameMechanics::new( + cfg.board_size as usize, + cfg.max_walls as usize, + cfg.max_steps as usize, + ); + let state = mechanics.create_initial_state(); + let model_str = model_path + .to_str() + .ok_or_else(|| anyhow!("model path is not valid UTF-8"))?; + let agent_config = Self::agent_config(mcts_n, cfg.default_mcts_c_puct, cfg.max_steps); + let agent = AlphaZeroAgent::new(model_str, agent_config) + .context("constructing AlphaZeroAgent")?; + Ok(Self { + mechanics, + state, + agent, + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + human_player, + last_action: None, + move_history: Vec::new(), + }) + } + + /// Test-only variant that injects a fake evaluator instead of loading ORT. + #[cfg(test)] + pub fn new_with_evaluator( + board_size: i32, + max_walls: i32, + max_steps: i32, + evaluator: Box, + mcts_n: u32, + c_puct: f32, + 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(); + let agent_config = Self::agent_config(mcts_n, c_puct, max_steps); + let agent = AlphaZeroAgent::with_evaluator(evaluator, agent_config); + Self { + mechanics, + state, + agent, + 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 + } + + 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 + } + + fn legal_mask(&mut self) -> Vec { + self.mechanics.get_action_mask_immut(self.state) + } + + /// Apply one action (no matter whose turn). Records it in `last_action` + + /// `move_history`. Returns an error if the action is illegal. + pub fn apply_action(&mut self, action_index: u32) -> Result<()> { + if self.is_game_over() { + return Err(anyhow!("game is already over")); + } + let mask = self.legal_mask(); + let idx = action_index as usize; + if idx >= mask.len() || !mask[idx] { + return Err(anyhow!("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(()) + } + + /// Run the AI for one move on the current state. Errors if it's actually + /// the human's turn or the game is over. + pub fn ai_step(&mut self) -> Result<()> { + if self.is_game_over() { + return Ok(()); + } + if self.current_player() == self.human_player { + return Err(anyhow!("not AI's turn")); + } + let mask = self.legal_mask(); + let (action_idx, _policy) = self + .agent + .select_action(self.state, &self.mechanics, &mask) + .context("AI MCTS selection")?; + self.last_action = Some(enrich_action(self.board_size, action_idx)); + self.move_history.push(action_idx as u32); + self.mechanics + .apply_action_index(&mut self.state, action_idx); + Ok(()) + } + + /// Build the JSON-facing snapshot the client renders from. + pub fn view(&mut self) -> StateView { + let mask = self.legal_mask(); + let legal_actions = enrich_legal_actions(self.board_size, &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 p1w = repr.get_walls_remaining(self.state, 0) as i32; + let p2w = repr.get_walls_remaining(self.state, 1) as i32; + let completed_steps = repr.get_completed_steps(self.state) as i32; + let winner = if self.mechanics.check_win(self.state, 0) { + Some(0) + } else if self.mechanics.check_win(self.state, 1) { + Some(1) + } else { + None + }; + + 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: p1w, + p2_walls: p2w, + walls: list_walls(&self.mechanics, self.state, self.board_size), + legal_actions, + completed_steps, + winner, + human_player: self.human_player, + last_action: self.last_action.clone(), + move_history: self.move_history.clone(), + } + } +} + +/// Iterate every potential wall slot (`(N-1)^2` for each orientation) and ask +/// the mechanics whether a wall is currently present at that location. +/// +/// The `QBitRepr::get_wall` API takes `orientation: usize` where +/// `WALL_VERTICAL=0` and `WALL_HORIZONTAL=1`. +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 +} + +/// Thread-safe map `game_id -> GameSession`. +#[derive(Clone, Default)] +pub struct GameRegistry { + inner: Arc>>>>, +} + +impl GameRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, session: GameSession) -> GameId { + let id = new_game_id(); + self.inner + .lock() + .unwrap() + .insert(id.clone(), Arc::new(Mutex::new(session))); + id + } + + pub fn get(&self, game_id: &str) -> Option>> { + self.inner.lock().unwrap().get(game_id).cloned() + } +} + +fn new_game_id() -> GameId { + let mut bytes = [0u8; 4]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::evaluator::UniformMockEvaluator; + + fn mock_session(human_player: i32) -> GameSession { + GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ) + } + + fn register(reg: &GameRegistry, s: GameSession) -> String { + reg.insert(s) + } + + #[test] + fn initial_view_has_pawns_on_home_rows_and_no_walls() { + let mut s = mock_session(0); + let v = s.view(); + assert_eq!(v.board_size, 5); + assert_eq!(v.max_walls, 2); + assert_eq!(v.current_player, 0); + assert_eq!(v.completed_steps, 0); + assert!(v.walls.is_empty()); + assert_eq!(v.p1_walls, 2); + assert_eq!(v.p2_walls, 2); + assert!(v.winner.is_none()); + assert_eq!(v.human_player, 0); + assert!(v.last_action.is_none()); + assert!(v.move_history.is_empty()); + let has_move = v + .legal_actions + .iter() + .any(|a| matches!(a, EnrichedAction::Move { .. })); + assert!(has_move); + } + + #[test] + fn apply_action_records_last_action_and_advances_player() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let first_legal_move = mask + .iter() + .enumerate() + .find(|&(_, &b)| b) + .map(|(i, _)| i as u32) + .expect("at least one legal action"); + s.apply_action(first_legal_move).unwrap(); + assert_eq!(s.move_history, vec![first_legal_move]); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); + } + + #[test] + fn apply_action_rejects_illegal_index() { + let mut s = mock_session(0); + let mask = s.legal_mask(); + let illegal = mask + .iter() + .enumerate() + .find(|&(_, &b)| !b) + .map(|(i, _)| i as u32) + .expect("at least one illegal action"); + let err = s.apply_action(illegal).unwrap_err(); + assert!(err.to_string().contains("not legal")); + } + + #[test] + fn ai_step_errors_when_its_human_turn() { + let mut s = mock_session(0); + let err = s.ai_step().unwrap_err(); + assert!(err.to_string().contains("not AI's turn")); + } + + #[test] + fn ai_step_runs_when_its_ai_turn() { + let mut s = mock_session(1); + s.ai_step().unwrap(); + assert_eq!(s.move_history.len(), 1); + assert!(s.last_action.is_some()); + assert_eq!(s.current_player(), 1); + } + + #[test] + fn registry_insert_and_get_round_trip() { + let reg = GameRegistry::new(); + let id = register(®, mock_session(0)); + assert!(reg.get(&id).is_some()); + assert!(reg.get("does-not-exist").is_none()); + } + + #[test] + fn game_id_is_8_hex_chars() { + let id = new_game_id(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } +} From d2d31a9fd6076466107aaada3e6cb190c4960c23 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:07:28 -0400 Subject: [PATCH 17/30] vibe: add play_server JSON handlers Co-Authored-By: Claude Sonnet 4.6 --- .../rust/src/play_server/handlers.rs | 284 ++++++++++++++++++ deep_quoridor/rust/src/play_server/mod.rs | 2 +- deep_quoridor/rust/src/play_server/state.rs | 16 + 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 deep_quoridor/rust/src/play_server/handlers.rs diff --git a/deep_quoridor/rust/src/play_server/handlers.rs b/deep_quoridor/rust/src/play_server/handlers.rs new file mode 100644 index 00000000..c30e6152 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/handlers.rs @@ -0,0 +1,284 @@ +//! Pure-function HTTP handlers for the play server. +//! +//! Each handler takes the inputs it needs (registry + parsed JSON) and returns +//! a `Result` where `T` is `serde::Serialize`. Task 6 wires +//! these into `tiny_http` requests and maps errors to status codes. + +use serde::{Deserialize, Serialize}; + +use crate::play_server::config::ServerConfig; +use crate::play_server::session::{GameRegistry, GameSession}; +use crate::play_server::state::StateView; + +/// Error kind exposed to the HTTP layer. Each variant maps to one status code. +#[derive(Debug)] +pub enum HandlerError { + /// 400: client sent something the server cannot honor (unknown model, + /// illegal move, malformed JSON the handler caught itself). + BadRequest(String), + /// 404: `game_id` not found in the registry. + NotFound(String), + /// 500: internal failure (ORT load, MCTS, IO). + Internal(String), +} + +impl HandlerError { + pub fn message(&self) -> &str { + match self { + HandlerError::BadRequest(m) | HandlerError::NotFound(m) | HandlerError::Internal(m) => { + m + } + } + } +} + +#[derive(Debug, Serialize)] +pub struct ConfigView { + pub board_size: i32, + pub max_walls: i32, + pub max_steps: i32, + pub models: Vec, + pub default_mcts_n: u32, +} + +#[derive(Deserialize)] +pub struct NewGameRequest { + pub model: String, + pub mcts_n: u32, + pub human_player: i32, +} + +#[derive(Debug, Serialize)] +pub struct NewGameResponse { + pub game_id: String, + pub state: StateView, +} + +#[derive(Deserialize)] +pub struct MoveRequest { + pub action_index: u32, +} + +#[derive(Debug, Serialize)] +pub struct StateResponse { + pub state: StateView, +} + +/// `GET /api/config` — server tells the client what board/models are available. +pub fn get_config(cfg: &ServerConfig, default_mcts_n: u32) -> ConfigView { + ConfigView { + board_size: cfg.board_size, + max_walls: cfg.max_walls, + max_steps: cfg.max_steps, + models: cfg.models.clone(), + default_mcts_n, + } +} + +/// `POST /api/games` — create a new game with the chosen model + mcts_n. +pub fn create_game( + cfg: &ServerConfig, + registry: &GameRegistry, + req: NewGameRequest, +) -> Result { + if req.human_player != 0 && req.human_player != 1 { + return Err(HandlerError::BadRequest(format!( + "human_player must be 0 or 1, got {}", + req.human_player + ))); + } + let model_path = cfg + .model_path(&req.model) + .ok_or_else(|| HandlerError::BadRequest(format!("unknown model: {}", req.model)))?; + let mut session = GameSession::new_from_onnx(cfg, &model_path, req.mcts_n, req.human_player) + .map_err(|e| HandlerError::Internal(format!("failed to create session: {e:#}")))?; + // If the AI moves first, take its move now so the initial state the client + // renders already shows it. + if session.human_player != session_current_player(&mut session) { + session + .ai_step() + .map_err(|e| HandlerError::Internal(format!("AI opening move failed: {e:#}")))?; + } + let state = session.view(); + let game_id = registry.insert(session); + Ok(NewGameResponse { game_id, state }) +} + +/// `GET /api/games/` — fetch the current state of an existing game. +pub fn get_game(registry: &GameRegistry, game_id: &str) -> Result { + let session_arc = registry + .get(game_id) + .ok_or_else(|| HandlerError::NotFound(format!("game {game_id} not found")))?; + let mut session = session_arc.lock().unwrap(); + Ok(StateResponse { + state: session.view(), + }) +} + +/// `POST /api/games//move` — apply the human's move, then (if it's +/// then the AI's turn) the AI's response in the same round-trip. +pub fn apply_move( + registry: &GameRegistry, + game_id: &str, + req: MoveRequest, +) -> Result { + let session_arc = registry + .get(game_id) + .ok_or_else(|| HandlerError::NotFound(format!("game {game_id} not found")))?; + let mut session = session_arc.lock().unwrap(); + session + .apply_action(req.action_index) + .map_err(|e| HandlerError::BadRequest(format!("{e:#}")))?; + // If after the human move it's now the AI's turn (and the game isn't + // over), run one AI step. + if session_should_ai_move(&mut session) { + session + .ai_step() + .map_err(|e| HandlerError::Internal(format!("AI step failed: {e:#}")))?; + } + Ok(StateResponse { + state: session.view(), + }) +} + +/// Helper: read current player without depending on private session API. +fn session_current_player(session: &mut GameSession) -> i32 { + session.view().current_player +} + +/// Helper: should the AI take its move right now? +fn session_should_ai_move(session: &mut GameSession) -> bool { + let v = session.view(); + v.winner.is_none() && v.current_player != v.human_player +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::alphazero::evaluator::UniformMockEvaluator; + use crate::play_server::session::GameSession; + use std::path::PathBuf; + + fn fake_cfg() -> ServerConfig { + ServerConfig { + play_dir: PathBuf::from("/tmp/does-not-matter"), + board_size: 5, + max_walls: 2, + max_steps: 50, + default_mcts_c_puct: 1.4, + models: vec!["a.onnx".to_string(), "b.onnx".to_string()], + } + } + + fn fresh_registry_with_session(human_player: i32) -> (GameRegistry, String) { + let reg = GameRegistry::new(); + let session = GameSession::new_with_evaluator( + 5, + 2, + 50, + Box::new(UniformMockEvaluator), + 8, + 1.4, + human_player, + ); + let id = reg.insert(session); + (reg, id) + } + + #[test] + fn get_config_returns_models_and_dimensions() { + let cfg = fake_cfg(); + let view = get_config(&cfg, 400); + assert_eq!(view.board_size, 5); + assert_eq!(view.max_walls, 2); + assert_eq!(view.max_steps, 50); + assert_eq!(view.models, vec!["a.onnx", "b.onnx"]); + assert_eq!(view.default_mcts_n, 400); + } + + #[test] + fn create_game_rejects_unknown_model() { + let cfg = fake_cfg(); + let reg = GameRegistry::new(); + let req = NewGameRequest { + model: "missing.onnx".to_string(), + mcts_n: 16, + human_player: 0, + }; + let err = create_game(&cfg, ®, req).unwrap_err(); + assert!(matches!(err, HandlerError::BadRequest(_))); + assert!(err.message().contains("unknown model")); + } + + #[test] + fn create_game_rejects_bad_human_player() { + let cfg = fake_cfg(); + let reg = GameRegistry::new(); + let req = NewGameRequest { + model: "a.onnx".to_string(), + mcts_n: 16, + human_player: 7, + }; + let err = create_game(&cfg, ®, req).unwrap_err(); + assert!(matches!(err, HandlerError::BadRequest(_))); + assert!(err.message().contains("human_player")); + } + + #[test] + fn get_game_returns_state_for_known_id() { + let (reg, id) = fresh_registry_with_session(0); + let resp = get_game(®, &id).unwrap(); + assert_eq!(resp.state.board_size, 5); + assert_eq!(resp.state.current_player, 0); + } + + #[test] + fn get_game_404s_for_unknown_id() { + let reg = GameRegistry::new(); + let err = get_game(®, "deadbeef").unwrap_err(); + assert!(matches!(err, HandlerError::NotFound(_))); + } + + #[test] + fn apply_move_rejects_illegal_action() { + let (reg, id) = fresh_registry_with_session(0); + // Pick an action index that is definitely not legal on a fresh + // 5x5/2-wall board: walls require board space and the initial mask + // disallows most wall slots. Use a far-out index. + let req = MoveRequest { + action_index: u32::MAX, + }; + let err = apply_move(®, &id, req).unwrap_err(); + assert!(matches!(err, HandlerError::BadRequest(_))); + } + + #[test] + fn apply_move_404s_for_unknown_id() { + let reg = GameRegistry::new(); + let req = MoveRequest { action_index: 0 }; + let err = apply_move(®, "deadbeef", req).unwrap_err(); + assert!(matches!(err, HandlerError::NotFound(_))); + } + + #[test] + fn apply_move_legal_human_action_advances_state() { + let (reg, id) = fresh_registry_with_session(0); + // Find a legal action from the current view. + let first_legal = { + let session_arc = reg.get(&id).unwrap(); + let mut s = session_arc.lock().unwrap(); + let v = s.view(); + v.legal_actions + .first() + .expect("at least one legal action") + .index() + }; + let req = MoveRequest { + action_index: first_legal, + }; + let resp = apply_move(®, &id, req).unwrap(); + // After the human moves it becomes the AI's turn — the handler then + // runs the AI step automatically — so move_history has at least 2. + assert!(resp.state.move_history.len() >= 1); + } +} diff --git a/deep_quoridor/rust/src/play_server/mod.rs b/deep_quoridor/rust/src/play_server/mod.rs index 593c6043..70e43344 100644 --- a/deep_quoridor/rust/src/play_server/mod.rs +++ b/deep_quoridor/rust/src/play_server/mod.rs @@ -4,6 +4,6 @@ //! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. pub mod config; +pub mod handlers; pub mod session; pub mod state; -// pub mod handlers; // added in Task 5 diff --git a/deep_quoridor/rust/src/play_server/state.rs b/deep_quoridor/rust/src/play_server/state.rs index 8ea74957..24230ae6 100644 --- a/deep_quoridor/rust/src/play_server/state.rs +++ b/deep_quoridor/rust/src/play_server/state.rs @@ -55,6 +55,16 @@ pub struct WallEntry { pub orientation: WallOrientation, } +impl EnrichedAction { + /// The bare action index common to all variants. + pub fn index(&self) -> u32 { + match self { + EnrichedAction::Move { index, .. } => *index, + EnrichedAction::Wall { index, .. } => *index, + } + } +} + /// Map an action index to its semantic enrichment (move dest / wall coords + /// orientation). Matches the convention in `actions::action_index_to_action`: /// indices < N*N are moves; the next (N-1)^2 are vertical walls; the @@ -95,6 +105,12 @@ mod tests { use super::*; use crate::actions::policy_size; + #[test] + fn enriched_action_index_returns_bare_index() { + assert_eq!(enrich_action(5, 7).index(), 7); + assert_eq!(enrich_action(5, 25).index(), 25); // first vertical wall on 5x5 + } + #[test] fn enrich_move_action_round_trips_coords() { // First action on a 5x5 board: move to (0, 0). From 1dc17ad1b35cff5afe4136aee0579f4a4074aa2f Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:11:33 -0400 Subject: [PATCH 18/30] vibe: tighten handler test to assert AI step ran Comment said move_history has at least 2 entries after the human+AI atomic round, but the assertion was >= 1 which would pass even if the AI step silently did not fire. Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/src/play_server/handlers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deep_quoridor/rust/src/play_server/handlers.rs b/deep_quoridor/rust/src/play_server/handlers.rs index c30e6152..cd9b8744 100644 --- a/deep_quoridor/rust/src/play_server/handlers.rs +++ b/deep_quoridor/rust/src/play_server/handlers.rs @@ -279,6 +279,6 @@ mod tests { let resp = apply_move(®, &id, req).unwrap(); // After the human moves it becomes the AI's turn — the handler then // runs the AI step automatically — so move_history has at least 2. - assert!(resp.state.move_history.len() >= 1); + assert!(resp.state.move_history.len() >= 2); } } From 9d4fa7ff35e035ce9deeb20feeb98b11d2eee689 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:16:32 -0400 Subject: [PATCH 19/30] vibe: add play_server tiny_http binary --- deep_quoridor/rust/src/bin/play_server.rs | 192 ++++++++++++++++++ .../rust/src/play_server/assets/app.css | 1 + .../rust/src/play_server/assets/app.js | 8 + .../rust/src/play_server/assets/index.html | 15 ++ 4 files changed, 216 insertions(+) create mode 100644 deep_quoridor/rust/src/bin/play_server.rs create mode 100644 deep_quoridor/rust/src/play_server/assets/app.css create mode 100644 deep_quoridor/rust/src/play_server/assets/app.js create mode 100644 deep_quoridor/rust/src/play_server/assets/index.html diff --git a/deep_quoridor/rust/src/bin/play_server.rs b/deep_quoridor/rust/src/bin/play_server.rs new file mode 100644 index 00000000..f34a4cdd --- /dev/null +++ b/deep_quoridor/rust/src/bin/play_server.rs @@ -0,0 +1,192 @@ +//! Local web server for playing Quoridor against the AlphaZero agent. +//! +//! Architecture and HTTP API are documented in +//! `docs/superpowers/specs/2026-05-29-quoridor-play-server-design.md`. +//! +//! Threading model: one `tiny_http::Server` accepts requests in the main +//! thread and dispatches each to a fresh worker thread that holds a clone of +//! the `Arc`-backed `GameRegistry`. Per-session locking keeps games +//! independent. + +use std::path::PathBuf; +use std::sync::Arc; +use std::thread; + +use anyhow::{Context, Result}; +use clap::Parser; +use serde_json::{json, Value}; +use tiny_http::{Header, Method, Request, Response, Server}; + +use quoridor_rs::play_server::config::ServerConfig; +use quoridor_rs::play_server::handlers::{ + apply_move, create_game, get_config, get_game, HandlerError, MoveRequest, NewGameRequest, +}; +use quoridor_rs::play_server::session::GameRegistry; + +const INDEX_HTML: &str = include_str!("../play_server/assets/index.html"); +const APP_CSS: &str = include_str!("../play_server/assets/app.css"); +const APP_JS: &str = include_str!("../play_server/assets/app.js"); + +#[derive(Parser)] +#[command( + name = "play_server", + about = "Local Quoridor play server (browser vs AlphaZero)" +)] +struct Cli { + /// Directory containing `config.yaml` and `models/*.onnx`. + #[arg(long)] + play_dir: PathBuf, + + /// TCP port to listen on. + #[arg(long, default_value_t = 8080)] + port: u16, + + /// Bind address. Use `0.0.0.0` for LAN access. + #[arg(long, default_value = "127.0.0.1")] + bind: String, + + /// Default MCTS simulations per move shown in the UI slider. + #[arg(long, default_value_t = 400)] + default_mcts_n: u32, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let cfg = Arc::new( + ServerConfig::load(&cli.play_dir).context("loading server config")?, + ); + let registry = GameRegistry::new(); + let bind = format!("{}:{}", cli.bind, cli.port); + let server = Server::http(&bind) + .map_err(|e| anyhow::anyhow!("failed to bind {bind}: {e}"))?; + eprintln!( + "play_server listening on http://{bind} (board {}x{}, {} model(s))", + cfg.board_size, + cfg.board_size, + cfg.models.len() + ); + + for request in server.incoming_requests() { + let cfg = Arc::clone(&cfg); + let registry = registry.clone(); + let default_mcts_n = cli.default_mcts_n; + thread::spawn(move || { + if let Err(e) = handle_request(request, &cfg, ®istry, default_mcts_n) { + eprintln!("request handler error: {e:#}"); + } + }); + } + Ok(()) +} + +fn handle_request( + mut req: Request, + cfg: &ServerConfig, + registry: &GameRegistry, + default_mcts_n: u32, +) -> Result<()> { + let method = req.method().clone(); + let url = req.url().to_string(); + // Strip query string if any. + let path = url.split('?').next().unwrap_or(&url).to_string(); + + let result: Result>>, HandlerError> = (|| { + match (&method, path.as_str()) { + (&Method::Get, "/") => Ok(html_response(INDEX_HTML)), + (&Method::Get, "/static/app.css") => Ok(text_response("text/css", APP_CSS)), + (&Method::Get, "/static/app.js") => { + Ok(text_response("application/javascript", APP_JS)) + } + (&Method::Get, "/api/config") => { + let view = get_config(cfg, default_mcts_n); + Ok(json_response(serde_json::to_value(view).map_err(|e| { + HandlerError::Internal(format!("serializing config: {e}")) + })?)) + } + (&Method::Post, "/api/games") => { + let body = read_body(&mut req) + .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; + let parsed: NewGameRequest = serde_json::from_str(&body) + .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; + let resp = create_game(cfg, registry, parsed)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + (&Method::Post, p) + if p.starts_with("/api/games/") && p.ends_with("/move") => + { + let id = &p["/api/games/".len()..p.len() - "/move".len()]; + let body = read_body(&mut req) + .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; + let parsed: MoveRequest = serde_json::from_str(&body) + .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; + let resp = apply_move(registry, id, parsed)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + (&Method::Get, p) + if p.starts_with("/api/games/") + && !p[("/api/games/".len())..].is_empty() + && !p.ends_with("/move") => + { + let id = &p["/api/games/".len()..]; + let resp = get_game(registry, id)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + _ => Err(HandlerError::NotFound(format!("no route for {} {}", method, path))), + } + })(); + + let response = match result { + Ok(r) => r, + Err(e) => error_response(&e), + }; + req.respond(response).context("writing HTTP response") +} + +fn read_body(req: &mut Request) -> std::io::Result { + let mut buf = String::new(); + req.as_reader().read_to_string(&mut buf)?; + Ok(buf) +} + +fn html_response(body: &'static str) -> Response>> { + text_response("text/html; charset=utf-8", body) +} + +fn text_response( + content_type: &str, + body: &'static str, +) -> Response>> { + Response::from_string(body).with_header( + Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) + .expect("content-type header"), + ) +} + +fn json_response(value: Value) -> Response>> { + let body = value.to_string(); + Response::from_string(body).with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("content-type header"), + ) +} + +fn error_response(err: &HandlerError) -> Response>> { + let status = match err { + HandlerError::BadRequest(_) => 400, + HandlerError::NotFound(_) => 404, + HandlerError::Internal(_) => 500, + }; + let body = json!({ "error": err.message() }).to_string(); + Response::from_string(body) + .with_status_code(status) + .with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("content-type header"), + ) +} diff --git a/deep_quoridor/rust/src/play_server/assets/app.css b/deep_quoridor/rust/src/play_server/assets/app.css new file mode 100644 index 00000000..424a3be2 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/assets/app.css @@ -0,0 +1 @@ +body { font-family: system-ui, sans-serif; margin: 2rem; } diff --git a/deep_quoridor/rust/src/play_server/assets/app.js b/deep_quoridor/rust/src/play_server/assets/app.js new file mode 100644 index 00000000..d1e45f4e --- /dev/null +++ b/deep_quoridor/rust/src/play_server/assets/app.js @@ -0,0 +1,8 @@ +// Replaced in Task 8. +fetch("/api/config") + .then((r) => r.json()) + .then((cfg) => { + document.getElementById("status").textContent = + "Server up. Board " + cfg.board_size + "x" + cfg.board_size + + ", " + cfg.models.length + " model(s) available."; + }); diff --git a/deep_quoridor/rust/src/play_server/assets/index.html b/deep_quoridor/rust/src/play_server/assets/index.html new file mode 100644 index 00000000..91ea4aa3 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/assets/index.html @@ -0,0 +1,15 @@ + + + + + Quoridor — play vs AlphaZero + + + +
+

Quoridor play server

+

Loading…

+
+ + + From 8499e08a234e8888ad7ba73aa024243ebb385fb1 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:24:46 -0400 Subject: [PATCH 20/30] vibe: add play_server end-to-end test --- deep_quoridor/rust/Cargo.toml | 4 + deep_quoridor/rust/src/bin/play_server.rs | 123 +-------------- deep_quoridor/rust/src/play_server/http.rs | 132 ++++++++++++++++ deep_quoridor/rust/src/play_server/mod.rs | 1 + deep_quoridor/rust/tests/play_server_e2e.rs | 157 ++++++++++++++++++++ 5 files changed, 296 insertions(+), 121 deletions(-) create mode 100644 deep_quoridor/rust/src/play_server/http.rs create mode 100644 deep_quoridor/rust/tests/play_server_e2e.rs diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml index 9f3e4878..14d0d1a7 100644 --- a/deep_quoridor/rust/Cargo.toml +++ b/deep_quoridor/rust/Cargo.toml @@ -63,6 +63,10 @@ gpu = ["binary", "ort/cuda", "ort/load-dynamic"] tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] } ureq = { version = "2", default-features = false, features = ["json"] } +# Mirrored from optional `binary`-feature deps so integration tests can name +# these types directly (Cargo resolves a separate copy per target otherwise). +tiny_http = "0.12" +serde_json = "1" [profile.release] # Enable optimizations for better performance diff --git a/deep_quoridor/rust/src/bin/play_server.rs b/deep_quoridor/rust/src/bin/play_server.rs index f34a4cdd..611dd6bc 100644 --- a/deep_quoridor/rust/src/bin/play_server.rs +++ b/deep_quoridor/rust/src/bin/play_server.rs @@ -14,19 +14,12 @@ use std::thread; use anyhow::{Context, Result}; use clap::Parser; -use serde_json::{json, Value}; -use tiny_http::{Header, Method, Request, Response, Server}; +use tiny_http::Server; use quoridor_rs::play_server::config::ServerConfig; -use quoridor_rs::play_server::handlers::{ - apply_move, create_game, get_config, get_game, HandlerError, MoveRequest, NewGameRequest, -}; +use quoridor_rs::play_server::http::handle_request; use quoridor_rs::play_server::session::GameRegistry; -const INDEX_HTML: &str = include_str!("../play_server/assets/index.html"); -const APP_CSS: &str = include_str!("../play_server/assets/app.css"); -const APP_JS: &str = include_str!("../play_server/assets/app.js"); - #[derive(Parser)] #[command( name = "play_server", @@ -78,115 +71,3 @@ fn main() -> Result<()> { } Ok(()) } - -fn handle_request( - mut req: Request, - cfg: &ServerConfig, - registry: &GameRegistry, - default_mcts_n: u32, -) -> Result<()> { - let method = req.method().clone(); - let url = req.url().to_string(); - // Strip query string if any. - let path = url.split('?').next().unwrap_or(&url).to_string(); - - let result: Result>>, HandlerError> = (|| { - match (&method, path.as_str()) { - (&Method::Get, "/") => Ok(html_response(INDEX_HTML)), - (&Method::Get, "/static/app.css") => Ok(text_response("text/css", APP_CSS)), - (&Method::Get, "/static/app.js") => { - Ok(text_response("application/javascript", APP_JS)) - } - (&Method::Get, "/api/config") => { - let view = get_config(cfg, default_mcts_n); - Ok(json_response(serde_json::to_value(view).map_err(|e| { - HandlerError::Internal(format!("serializing config: {e}")) - })?)) - } - (&Method::Post, "/api/games") => { - let body = read_body(&mut req) - .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; - let parsed: NewGameRequest = serde_json::from_str(&body) - .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; - let resp = create_game(cfg, registry, parsed)?; - Ok(json_response(serde_json::to_value(resp).map_err(|e| { - HandlerError::Internal(format!("serializing response: {e}")) - })?)) - } - (&Method::Post, p) - if p.starts_with("/api/games/") && p.ends_with("/move") => - { - let id = &p["/api/games/".len()..p.len() - "/move".len()]; - let body = read_body(&mut req) - .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; - let parsed: MoveRequest = serde_json::from_str(&body) - .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; - let resp = apply_move(registry, id, parsed)?; - Ok(json_response(serde_json::to_value(resp).map_err(|e| { - HandlerError::Internal(format!("serializing response: {e}")) - })?)) - } - (&Method::Get, p) - if p.starts_with("/api/games/") - && !p[("/api/games/".len())..].is_empty() - && !p.ends_with("/move") => - { - let id = &p["/api/games/".len()..]; - let resp = get_game(registry, id)?; - Ok(json_response(serde_json::to_value(resp).map_err(|e| { - HandlerError::Internal(format!("serializing response: {e}")) - })?)) - } - _ => Err(HandlerError::NotFound(format!("no route for {} {}", method, path))), - } - })(); - - let response = match result { - Ok(r) => r, - Err(e) => error_response(&e), - }; - req.respond(response).context("writing HTTP response") -} - -fn read_body(req: &mut Request) -> std::io::Result { - let mut buf = String::new(); - req.as_reader().read_to_string(&mut buf)?; - Ok(buf) -} - -fn html_response(body: &'static str) -> Response>> { - text_response("text/html; charset=utf-8", body) -} - -fn text_response( - content_type: &str, - body: &'static str, -) -> Response>> { - Response::from_string(body).with_header( - Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) - .expect("content-type header"), - ) -} - -fn json_response(value: Value) -> Response>> { - let body = value.to_string(); - Response::from_string(body).with_header( - Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) - .expect("content-type header"), - ) -} - -fn error_response(err: &HandlerError) -> Response>> { - let status = match err { - HandlerError::BadRequest(_) => 400, - HandlerError::NotFound(_) => 404, - HandlerError::Internal(_) => 500, - }; - let body = json!({ "error": err.message() }).to_string(); - Response::from_string(body) - .with_status_code(status) - .with_header( - Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) - .expect("content-type header"), - ) -} diff --git a/deep_quoridor/rust/src/play_server/http.rs b/deep_quoridor/rust/src/play_server/http.rs new file mode 100644 index 00000000..50b32163 --- /dev/null +++ b/deep_quoridor/rust/src/play_server/http.rs @@ -0,0 +1,132 @@ +//! HTTP routing for the play server. +//! +//! Lives in the library (not the binary) so both `bin/play_server.rs` and the +//! end-to-end integration test (`tests/play_server_e2e.rs`) can share one +//! source of truth for request dispatch + response shaping. + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; +use tiny_http::{Header, Method, Request, Response}; + +use crate::play_server::config::ServerConfig; +use crate::play_server::handlers::{ + HandlerError, MoveRequest, NewGameRequest, apply_move, create_game, get_config, get_game, +}; +use crate::play_server::session::GameRegistry; + +pub const INDEX_HTML: &str = include_str!("assets/index.html"); +pub const APP_CSS: &str = include_str!("assets/app.css"); +pub const APP_JS: &str = include_str!("assets/app.js"); + +/// Route a single request to the appropriate handler and write the response. +pub fn handle_request( + mut req: Request, + cfg: &ServerConfig, + registry: &GameRegistry, + default_mcts_n: u32, +) -> Result<()> { + let method = req.method().clone(); + let url = req.url().to_string(); + // Strip query string if any. + let path = url.split('?').next().unwrap_or(&url).to_string(); + + let result: Result>>, HandlerError> = (|| { + match (&method, path.as_str()) { + (&Method::Get, "/") => Ok(html_response(INDEX_HTML)), + (&Method::Get, "/static/app.css") => Ok(text_response("text/css", APP_CSS)), + (&Method::Get, "/static/app.js") => { + Ok(text_response("application/javascript", APP_JS)) + } + (&Method::Get, "/api/config") => { + let view = get_config(cfg, default_mcts_n); + Ok(json_response(serde_json::to_value(view).map_err(|e| { + HandlerError::Internal(format!("serializing config: {e}")) + })?)) + } + (&Method::Post, "/api/games") => { + let body = read_body(&mut req) + .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; + let parsed: NewGameRequest = serde_json::from_str(&body) + .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; + let resp = create_game(cfg, registry, parsed)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + (&Method::Post, p) + if p.starts_with("/api/games/") && p.ends_with("/move") => + { + let id = &p["/api/games/".len()..p.len() - "/move".len()]; + let body = read_body(&mut req) + .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; + let parsed: MoveRequest = serde_json::from_str(&body) + .map_err(|e| HandlerError::BadRequest(format!("invalid JSON: {e}")))?; + let resp = apply_move(registry, id, parsed)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + (&Method::Get, p) + if p.starts_with("/api/games/") + && !p[("/api/games/".len())..].is_empty() + && !p.ends_with("/move") => + { + let id = &p["/api/games/".len()..]; + let resp = get_game(registry, id)?; + Ok(json_response(serde_json::to_value(resp).map_err(|e| { + HandlerError::Internal(format!("serializing response: {e}")) + })?)) + } + _ => Err(HandlerError::NotFound(format!("no route for {} {}", method, path))), + } + })(); + + let response = match result { + Ok(r) => r, + Err(e) => error_response(&e), + }; + req.respond(response).context("writing HTTP response") +} + +fn read_body(req: &mut Request) -> std::io::Result { + let mut buf = String::new(); + req.as_reader().read_to_string(&mut buf)?; + Ok(buf) +} + +fn html_response(body: &'static str) -> Response>> { + text_response("text/html; charset=utf-8", body) +} + +fn text_response( + content_type: &str, + body: &'static str, +) -> Response>> { + Response::from_string(body).with_header( + Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) + .expect("content-type header"), + ) +} + +fn json_response(value: Value) -> Response>> { + let body = value.to_string(); + Response::from_string(body).with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("content-type header"), + ) +} + +fn error_response(err: &HandlerError) -> Response>> { + let status = match err { + HandlerError::BadRequest(_) => 400, + HandlerError::NotFound(_) => 404, + HandlerError::Internal(_) => 500, + }; + let body = json!({ "error": err.message() }).to_string(); + Response::from_string(body) + .with_status_code(status) + .with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("content-type header"), + ) +} diff --git a/deep_quoridor/rust/src/play_server/mod.rs b/deep_quoridor/rust/src/play_server/mod.rs index 70e43344..588efb8e 100644 --- a/deep_quoridor/rust/src/play_server/mod.rs +++ b/deep_quoridor/rust/src/play_server/mod.rs @@ -5,5 +5,6 @@ pub mod config; pub mod handlers; +pub mod http; pub mod session; pub mod state; diff --git a/deep_quoridor/rust/tests/play_server_e2e.rs b/deep_quoridor/rust/tests/play_server_e2e.rs new file mode 100644 index 00000000..bb9a702e --- /dev/null +++ b/deep_quoridor/rust/tests/play_server_e2e.rs @@ -0,0 +1,157 @@ +//! End-to-end test: boot the play server in-process, play a few moves +//! against the real B5W2 ONNX fixture, assert state transitions. +//! +//! Run: cargo test --no-default-features --features binary --test play_server_e2e -- --nocapture +#![cfg(feature = "binary")] + +use std::net::TcpStream; +use std::path::PathBuf; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use quoridor_rs::play_server::config::ServerConfig; +use quoridor_rs::play_server::http::handle_request; +use quoridor_rs::play_server::session::GameRegistry; + +fn fixture_onnx_path() -> Option { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fallback = root.join("fixtures").join("alphazero_B5W2_mv1.onnx"); + let p = std::env::var("DEEP_QUORIDOR_ONNX_MODEL") + .map(PathBuf::from) + .unwrap_or(fallback); + if p.exists() { Some(p) } else { None } +} + +#[test] +fn play_server_serves_a_few_moves_against_real_model() { + let onnx = match fixture_onnx_path() { + Some(p) => p, + None => { + eprintln!("skipping: B5W2 ONNX fixture not available"); + return; + } + }; + + // Build temp play_dir: config.yaml + models/alphazero_B5W2_mv1.onnx + let temp = tempfile::tempdir().expect("tempdir"); + let models_dir = temp.path().join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model_dst = models_dir.join("alphazero_B5W2_mv1.onnx"); + std::fs::copy(&onnx, &model_dst).expect("copy model"); + std::fs::write( + temp.path().join("config.yaml"), + "quoridor:\n board_size: 5\n max_walls: 2\n max_steps: 50\n", + ) + .unwrap(); + + let cfg = Arc::new(ServerConfig::load(temp.path()).expect("config load")); + let registry = GameRegistry::new(); + + // Bind to port 0; read assigned port. + let server = + Arc::new(tiny_http::Server::http("127.0.0.1:0").expect("bind tiny_http")); + let addr = server.server_addr(); + let port = match addr { + tiny_http::ListenAddr::IP(sock) => sock.port(), + _ => panic!("expected IP listen addr"), + }; + + // Worker thread runs the handler loop until the server is unblocked. + let worker_server = Arc::clone(&server); + let worker_cfg = Arc::clone(&cfg); + let worker_registry = registry.clone(); + let worker = thread::spawn(move || { + for request in worker_server.incoming_requests() { + if let Err(e) = handle_request(request, &worker_cfg, &worker_registry, 8) { + eprintln!("test worker error: {e:#}"); + } + } + }); + + let base = format!("http://127.0.0.1:{port}"); + let http = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build(); + + // 1. GET /api/config + let cfg_view: serde_json::Value = http + .get(&format!("{base}/api/config")) + .call() + .expect("GET /api/config") + .into_json() + .expect("config json"); + assert_eq!(cfg_view["board_size"], 5); + assert_eq!(cfg_view["max_walls"], 2); + let models = cfg_view["models"].as_array().expect("models array"); + assert!( + models + .iter() + .any(|m| m.as_str() == Some("alphazero_B5W2_mv1.onnx")) + ); + + // 2. POST /api/games + let new_game: serde_json::Value = http + .post(&format!("{base}/api/games")) + .send_json(serde_json::json!({ + "model": "alphazero_B5W2_mv1.onnx", + "mcts_n": 8, + "human_player": 0, + })) + .expect("POST /api/games") + .into_json() + .expect("new-game json"); + let game_id = new_game["game_id"].as_str().expect("game_id").to_string(); + let mut state = new_game["state"].clone(); + // human_player == 0 means human moves first. + assert_eq!(state["current_player"].as_i64(), Some(0)); + + // 3. GET /api/games/ to confirm the registry stored the game. + let fetched: serde_json::Value = http + .get(&format!("{base}/api/games/{game_id}")) + .call() + .expect("GET /api/games/") + .into_json() + .expect("game json"); + assert_eq!(fetched["state"]["current_player"].as_i64(), Some(0)); + + // 4. Play up to 10 human moves, picking the first legal move action. + let mut moves = 0; + while state["winner"].is_null() && moves < 10 { + let legal = state["legal_actions"].as_array().expect("legal_actions"); + let move_idx = legal + .iter() + .find_map(|a| { + if a["kind"] == "move" { + a["index"].as_u64() + } else { + None + } + }) + .expect("at least one legal pawn move"); + let resp: serde_json::Value = http + .post(&format!("{base}/api/games/{game_id}/move")) + .send_json(serde_json::json!({ "action_index": move_idx })) + .expect("POST /move") + .into_json() + .expect("move json"); + state = resp["state"].clone(); + moves += 1; + // Each round-trip adds at least the human's move; if the game isn't + // over, the handler also runs an AI step (so +2). + let history_len = state["move_history"].as_array().unwrap().len(); + assert!( + history_len >= 2 * moves - 1, + "after {moves} human move(s), move_history len = {history_len}", + ); + } + + assert!(moves > 0, "played zero moves"); + + // 5. Unblock the listener and join the worker. + server.unblock(); + // tiny_http's unblock won't fire until the next incoming_requests poll + // returns, so prod the listener with a stray TCP connect. + let _ = TcpStream::connect(("127.0.0.1", port)); + worker.join().expect("worker join"); +} From 4ef0a96024432a76ec3ae3b62eb0be01b0d0aca0 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:34:24 -0400 Subject: [PATCH 21/30] vibe: add playable Quoridor frontend assets Vanilla HTML/CSS/JS implementing the spec: - (2N-1)x(2N-1) CSS grid with alternating pawn-cell, wall-slot, and post tracks. - Click handlers attached to legal-action targets keyed by the bare action index (no client-side encoding logic). - Mirrors coordinates 180 deg when human_player == 1 so the human's home row always sits at the bottom of the rendered board. - Side panel with new-game form (model select, mcts_n slider, who- goes-first toggle), turn indicator with AI-thinking spinner, walls remaining per player, step counter, and a game-over banner. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rust/src/play_server/assets/app.css | 208 +++++++++++- .../rust/src/play_server/assets/app.js | 297 +++++++++++++++++- .../rust/src/play_server/assets/index.html | 53 +++- 3 files changed, 547 insertions(+), 11 deletions(-) diff --git a/deep_quoridor/rust/src/play_server/assets/app.css b/deep_quoridor/rust/src/play_server/assets/app.css index 424a3be2..a4833d4f 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.css +++ b/deep_quoridor/rust/src/play_server/assets/app.css @@ -1 +1,207 @@ -body { font-family: system-ui, sans-serif; margin: 2rem; } +:root { + --pawn-size: 56px; + --post-size: 14px; + --pawn-p1: #2255cc; + --pawn-p2: #cc3322; + --wall: #553311; + --legal-move: rgba(80, 160, 80, 0.55); + --legal-wall: rgba(180, 130, 60, 0.5); + --last-action: gold; + --bg: #f6f3ee; + --cell: #e2cda9; + --border: #2b2b2b; +} + +* { box-sizing: border-box; } + +body { + font-family: system-ui, -apple-system, sans-serif; + margin: 0; + background: var(--bg); + color: #222; +} + +header { + padding: 1rem 1.5rem; + background: var(--border); + color: white; +} + +header h1 { + margin: 0; + font-size: 1.4rem; + font-weight: 600; +} + +main { + display: flex; + flex-direction: row; + gap: 2rem; + padding: 1.5rem; + align-items: flex-start; +} + +/* ---- Board ---- */ + +#board-wrapper { + position: relative; +} + +#board { + display: grid; + gap: 0; + border: 4px solid var(--border); + background: var(--border); +} + +.cell { + background: var(--cell); + position: relative; +} + +.pawn-cell { background: var(--cell); } +.wall-h-half, .wall-v-half, .post { background: var(--border); } + +/* Pawn pieces */ +.pawn { + position: absolute; + top: 8%; left: 8%; right: 8%; bottom: 8%; + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} +.pawn.p1 { background: var(--pawn-p1); } +.pawn.p2 { background: var(--pawn-p2); } + +/* Placed walls fill the slot */ +.wall-placed-h, .wall-placed-v { background: var(--wall); } + +/* Last-action highlights */ +.last-move::after { + content: ""; + position: absolute; + inset: 4px; + border: 2px solid var(--last-action); + border-radius: 4px; + pointer-events: none; +} +.last-wall { outline: 2px solid var(--last-action); outline-offset: -2px; } + +/* Legal-move highlights (clickable destinations) */ +.legal-move { cursor: pointer; } +.legal-move::before { + content: ""; + position: absolute; + inset: 30%; + border-radius: 50%; + background: var(--legal-move); +} +.legal-move:hover::before { inset: 20%; } + +/* Legal wall slot highlights (clickable empty slots) */ +.legal-wall-h, .legal-wall-v { + cursor: pointer; + background: var(--cell); +} +.legal-wall-h:hover, .legal-wall-v:hover { + background: var(--legal-wall); +} + +/* ---- Side panel ---- */ + +#side-panel { + width: 280px; + display: flex; + flex-direction: column; + gap: 1rem; +} + +#side-panel fieldset { + border: 1px solid #bbb; + padding: 0.75rem; + background: white; +} + +#side-panel legend { + font-weight: 600; + padding: 0 0.3rem; +} + +#side-panel label { + display: block; + margin: 0.4rem 0; + font-size: 0.95rem; +} + +#side-panel input[type="range"] { width: 100%; } +#side-panel select { width: 100%; padding: 0.3rem; } + +.who-goes-first label { + display: inline-block; + margin-right: 1rem; + font-weight: normal; +} + +#new-game-button { + margin-top: 0.5rem; + width: 100%; + padding: 0.6rem; + font-size: 1rem; + background: var(--pawn-p1); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} +#new-game-button:hover { background: #1a44aa; } +#new-game-button:disabled { background: #888; cursor: not-allowed; } + +#status-panel { + background: white; + border: 1px solid #bbb; + padding: 0.75rem; +} + +#status-panel h2 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} + +.muted { color: #666; font-size: 0.85rem; margin: 0.5rem 0 0; } +.error { color: #b00; font-size: 0.9rem; } + +/* ---- Spinner ---- */ + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid #888; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-left: 0.4em; +} +.spinner[hidden] { display: none; } + +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---- Game-over banner ---- */ + +#game-over-banner { + display: none; + position: absolute; + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + padding: 1.2rem 2rem; + background: rgba(0, 0, 0, 0.88); + color: white; + font-size: 1.6rem; + font-weight: 600; + border-radius: 8px; + pointer-events: none; + text-align: center; + letter-spacing: 0.5px; +} diff --git a/deep_quoridor/rust/src/play_server/assets/app.js b/deep_quoridor/rust/src/play_server/assets/app.js index d1e45f4e..cbc50a70 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.js +++ b/deep_quoridor/rust/src/play_server/assets/app.js @@ -1,8 +1,291 @@ -// Replaced in Task 8. -fetch("/api/config") - .then((r) => r.json()) - .then((cfg) => { - document.getElementById("status").textContent = - "Server up. Board " + cfg.board_size + "x" + cfg.board_size + - ", " + cfg.models.length + " model(s) available."; +// Quoridor play server — vanilla JS frontend. +// +// Coordinates: the server speaks absolute Quoridor coordinates with (0,0) at +// the top-left and player 0 starting on row 0. We always render the human's +// home row at the bottom, so when `human_player == 1` we mirror coordinates +// 180° before placing anything on the board grid. +// +// The board is a (2N-1) x (2N-1) CSS grid alternating pawn cells, wall slots, +// and wall posts. Server `legal_actions` already carry the kind/coords so the +// client never has to mirror Python's action-encoding logic. + +const STATE = { + cfg: null, // /api/config response + gameId: null, + view: null, // last StateView from server + pending: false, +}; + +const $ = (sel) => document.querySelector(sel); + +function make(tag, attrs = {}, children = []) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") el.className = v; + else if (k === "text") el.textContent = v; + else if (k.startsWith("on") && typeof v === "function") { + el.addEventListener(k.slice(2), v); + } else { + el.setAttribute(k, v); + } + } + for (const c of children) el.appendChild(c); + return el; +} + +async function fetchJson(url, options) { + const resp = await fetch(url, options); + if (!resp.ok) { + let detail = resp.statusText; + try { + const body = await resp.json(); + if (body && body.error) detail = body.error; + } catch (_) { /* non-JSON error body */ } + throw new Error(detail); + } + return resp.json(); +} + +async function init() { + try { + STATE.cfg = await fetchJson("/api/config"); + renderSetup(); + } catch (e) { + showError("Failed to load /api/config: " + e.message); + } +} + +function renderSetup() { + const sel = $("#model-select"); + sel.innerHTML = ""; + for (const name of STATE.cfg.models) { + sel.appendChild(make("option", { value: name, text: name })); + } + if (STATE.cfg.models.length === 0) { + sel.appendChild(make("option", { value: "", text: "(no models found)" })); + $("#new-game-button").disabled = true; + } + + const slider = $("#mcts-n"); + slider.value = STATE.cfg.default_mcts_n; + $("#mcts-n-display").textContent = slider.value; + + $("#board-size-display").textContent = + `${STATE.cfg.board_size}x${STATE.cfg.board_size}, ${STATE.cfg.max_walls} walls each`; +} + +async function startGame() { + const body = { + model: $("#model-select").value, + mcts_n: parseInt($("#mcts-n").value, 10), + human_player: parseInt( + document.querySelector('input[name="human-player"]:checked').value, + 10, + ), + }; + clearError(); + setPending(true); + try { + const data = await fetchJson("/api/games", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + STATE.gameId = data.game_id; + STATE.view = data.state; + render(); + } catch (e) { + showError("New game failed: " + e.message); + } finally { + setPending(false); + } +} + +async function sendMove(actionIndex) { + if (STATE.pending || !STATE.gameId) return; + clearError(); + setPending(true); + try { + const data = await fetchJson(`/api/games/${STATE.gameId}/move`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action_index: actionIndex }), + }); + STATE.view = data.state; + render(); + } catch (e) { + showError("Move rejected: " + e.message); + } finally { + setPending(false); + } +} + +function setPending(p) { + STATE.pending = p; + $("#spinner").hidden = !p; +} + +function showError(msg) { + const el = $("#error-display"); + el.textContent = msg; + el.hidden = false; +} +function clearError() { + const el = $("#error-display"); + el.textContent = ""; + el.hidden = true; +} + +// ---- coordinate transforms ---- + +function mirrorPawn(r, c) { + const N = STATE.view.board_size; + return STATE.view.human_player === 1 ? [N - 1 - r, N - 1 - c] : [r, c]; +} + +// A wall at server (r, c) sits between rows r and r+1, spanning cols c and +// c+1. Under 180 deg rotation it lives between rows (N-2-r) and (N-1-r), +// spanning cols (N-2-c) and (N-1-c) -- same orientation. +function mirrorWall(r, c) { + const N = STATE.view.board_size; + return STATE.view.human_player === 1 ? [N - 2 - r, N - 2 - c] : [r, c]; +} + +// ---- render ---- + +function render() { + const v = STATE.view; + const N = v.board_size; + const size = 2 * N - 1; + const board = $("#board"); + board.innerHTML = ""; + + // Alternating column/row sizes: pawn cell, post, pawn cell, post, ... + const tracks = Array.from({ length: size }, (_, i) => + i % 2 === 0 ? "var(--pawn-size)" : "var(--post-size)", + ).join(" "); + board.style.gridTemplateColumns = tracks; + board.style.gridTemplateRows = tracks; + + // Build the grid and remember each cell so we can decorate. + const cells = []; + for (let gr = 0; gr < size; gr++) { + cells.push([]); + for (let gc = 0; gc < size; gc++) { + const isRowEven = gr % 2 === 0; + const isColEven = gc % 2 === 0; + let cls = "post"; + if (isRowEven && isColEven) cls = "pawn-cell"; + else if (!isRowEven && isColEven) cls = "wall-h-half"; + else if (isRowEven && !isColEven) cls = "wall-v-half"; + const el = make("div", { class: `cell ${cls}` }); + cells[gr].push(el); + board.appendChild(el); + } + } + + // Pawns -- keep colors tied to the server player index so walls-left + // counters line up with the pawn colors regardless of orientation. + const [p1r, p1c] = mirrorPawn(v.p1_pos[0], v.p1_pos[1]); + const [p2r, p2c] = mirrorPawn(v.p2_pos[0], v.p2_pos[1]); + cells[2 * p1r][2 * p1c].appendChild(make("div", { class: "pawn p1" })); + cells[2 * p2r][2 * p2c].appendChild(make("div", { class: "pawn p2" })); + + // Placed walls + for (const w of v.walls) { + const [dr, dc] = mirrorWall(w.row, w.col); + if (w.orientation === "h") { + cells[2 * dr + 1][2 * dc].classList.add("wall-placed-h"); + cells[2 * dr + 1][2 * dc + 1].classList.add("wall-placed-h"); + cells[2 * dr + 1][2 * dc + 2].classList.add("wall-placed-h"); + } else { + cells[2 * dr][2 * dc + 1].classList.add("wall-placed-v"); + cells[2 * dr + 1][2 * dc + 1].classList.add("wall-placed-v"); + cells[2 * dr + 2][2 * dc + 1].classList.add("wall-placed-v"); + } + } + + // Last-action highlight + if (v.last_action) { + const la = v.last_action; + if (la.kind === "move") { + const [dr, dc] = mirrorPawn(la.to[0], la.to[1]); + cells[2 * dr][2 * dc].classList.add("last-move"); + } else { + const [dr, dc] = mirrorWall(la.row, la.col); + if (la.orientation === "h") { + cells[2 * dr + 1][2 * dc].classList.add("last-wall"); + cells[2 * dr + 1][2 * dc + 1].classList.add("last-wall"); + cells[2 * dr + 1][2 * dc + 2].classList.add("last-wall"); + } else { + cells[2 * dr][2 * dc + 1].classList.add("last-wall"); + cells[2 * dr + 1][2 * dc + 1].classList.add("last-wall"); + cells[2 * dr + 2][2 * dc + 1].classList.add("last-wall"); + } + } + } + + // Click handlers on legal actions -- only when it's the human's turn. + const humanTurn = v.winner === null && v.current_player === v.human_player; + if (humanTurn) { + for (const a of v.legal_actions) { + if (a.kind === "move") { + const [dr, dc] = mirrorPawn(a.to[0], a.to[1]); + const cell = cells[2 * dr][2 * dc]; + cell.classList.add("legal-move"); + cell.addEventListener("click", () => sendMove(a.index)); + } else { + const [dr, dc] = mirrorWall(a.row, a.col); + const tag = (gr, gc) => { + const c = cells[gr][gc]; + c.classList.add(`legal-wall-${a.orientation}`); + c.addEventListener("click", () => sendMove(a.index)); + }; + if (a.orientation === "h") { + tag(2 * dr + 1, 2 * dc); + tag(2 * dr + 1, 2 * dc + 1); + tag(2 * dr + 1, 2 * dc + 2); + } else { + tag(2 * dr, 2 * dc + 1); + tag(2 * dr + 1, 2 * dc + 1); + tag(2 * dr + 2, 2 * dc + 1); + } + } + } + } + + // Status panel + const turnEl = $("#turn-display"); + if (v.winner !== null) { + turnEl.textContent = "Game over"; + } else if (v.current_player === v.human_player) { + turnEl.textContent = "Your move"; + } else { + turnEl.textContent = "AI thinking"; + } + $("#walls-p1").textContent = v.p1_walls; + $("#walls-p2").textContent = v.p2_walls; + $("#completed-steps").textContent = `${v.completed_steps} / ${v.max_steps}`; + + // Game-over banner + const banner = $("#game-over-banner"); + if (v.winner === null) { + if (v.completed_steps >= v.max_steps) { + banner.style.display = "block"; + banner.textContent = "Draw (move limit reached)"; + } else { + banner.style.display = "none"; + } + } else { + banner.style.display = "block"; + banner.textContent = v.winner === v.human_player ? "You won!" : "AI won"; + } +} + +document.addEventListener("DOMContentLoaded", () => { + init(); + $("#mcts-n").addEventListener("input", (e) => { + $("#mcts-n-display").textContent = e.target.value; }); + $("#new-game-button").addEventListener("click", startGame); +}); diff --git a/deep_quoridor/rust/src/play_server/assets/index.html b/deep_quoridor/rust/src/play_server/assets/index.html index 91ea4aa3..644a51dc 100644 --- a/deep_quoridor/rust/src/play_server/assets/index.html +++ b/deep_quoridor/rust/src/play_server/assets/index.html @@ -2,14 +2,61 @@ - Quoridor — play vs AlphaZero + Quoridor — vs AlphaZero +
+

Quoridor vs AlphaZero

+
-

Quoridor play server

-

Loading…

+
+
+
+
+ +
+ From ab19c8480f5a6e9db2e3145ec4cb9076500a424d Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:34:54 -0400 Subject: [PATCH 22/30] vibe: gate test-only Evaluator import behind cfg(test) Silences a longstanding unused_imports warning -- the trait is only referenced inside #[cfg(test)] via new_with_evaluator. Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/src/play_server/session.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/deep_quoridor/rust/src/play_server/session.rs b/deep_quoridor/rust/src/play_server/session.rs index 2da3f28a..d486e48c 100644 --- a/deep_quoridor/rust/src/play_server/session.rs +++ b/deep_quoridor/rust/src/play_server/session.rs @@ -14,6 +14,7 @@ use rand::RngCore; use crate::agents::ActionSelector; use crate::agents::alphazero::agent::{AlphaZeroAgent, AlphaZeroAgentConfig}; +#[cfg(test)] use crate::agents::alphazero::evaluator::Evaluator; use crate::agents::alphazero::mcts::MCTSConfig; use crate::compact::q_bit_repr::{CompactState, WALL_HORIZONTAL, WALL_VERTICAL}; From 830413500087d1f4a74dd9ea9bfec50f8f4a5aa0 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 12:36:09 -0400 Subject: [PATCH 23/30] vibe: cargo fmt Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/src/bin/play_server.rs | 7 ++---- deep_quoridor/rust/src/play_server/http.rs | 25 ++++++++----------- deep_quoridor/rust/src/play_server/session.rs | 15 +++++------ deep_quoridor/rust/src/play_server/state.rs | 14 ++++++----- deep_quoridor/rust/tests/play_server_e2e.rs | 3 +-- 5 files changed, 29 insertions(+), 35 deletions(-) diff --git a/deep_quoridor/rust/src/bin/play_server.rs b/deep_quoridor/rust/src/bin/play_server.rs index 611dd6bc..4ec5c34d 100644 --- a/deep_quoridor/rust/src/bin/play_server.rs +++ b/deep_quoridor/rust/src/bin/play_server.rs @@ -45,13 +45,10 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); - let cfg = Arc::new( - ServerConfig::load(&cli.play_dir).context("loading server config")?, - ); + let cfg = Arc::new(ServerConfig::load(&cli.play_dir).context("loading server config")?); let registry = GameRegistry::new(); let bind = format!("{}:{}", cli.bind, cli.port); - let server = Server::http(&bind) - .map_err(|e| anyhow::anyhow!("failed to bind {bind}: {e}"))?; + let server = Server::http(&bind).map_err(|e| anyhow::anyhow!("failed to bind {bind}: {e}"))?; eprintln!( "play_server listening on http://{bind} (board {}x{}, {} model(s))", cfg.board_size, diff --git a/deep_quoridor/rust/src/play_server/http.rs b/deep_quoridor/rust/src/play_server/http.rs index 50b32163..96a65d23 100644 --- a/deep_quoridor/rust/src/play_server/http.rs +++ b/deep_quoridor/rust/src/play_server/http.rs @@ -30,13 +30,11 @@ pub fn handle_request( // Strip query string if any. let path = url.split('?').next().unwrap_or(&url).to_string(); - let result: Result>>, HandlerError> = (|| { - match (&method, path.as_str()) { + let result: Result>>, HandlerError> = + (|| match (&method, path.as_str()) { (&Method::Get, "/") => Ok(html_response(INDEX_HTML)), (&Method::Get, "/static/app.css") => Ok(text_response("text/css", APP_CSS)), - (&Method::Get, "/static/app.js") => { - Ok(text_response("application/javascript", APP_JS)) - } + (&Method::Get, "/static/app.js") => Ok(text_response("application/javascript", APP_JS)), (&Method::Get, "/api/config") => { let view = get_config(cfg, default_mcts_n); Ok(json_response(serde_json::to_value(view).map_err(|e| { @@ -53,9 +51,7 @@ pub fn handle_request( HandlerError::Internal(format!("serializing response: {e}")) })?)) } - (&Method::Post, p) - if p.starts_with("/api/games/") && p.ends_with("/move") => - { + (&Method::Post, p) if p.starts_with("/api/games/") && p.ends_with("/move") => { let id = &p["/api/games/".len()..p.len() - "/move".len()]; let body = read_body(&mut req) .map_err(|e| HandlerError::BadRequest(format!("reading body: {e}")))?; @@ -77,9 +73,11 @@ pub fn handle_request( HandlerError::Internal(format!("serializing response: {e}")) })?)) } - _ => Err(HandlerError::NotFound(format!("no route for {} {}", method, path))), - } - })(); + _ => Err(HandlerError::NotFound(format!( + "no route for {} {}", + method, path + ))), + })(); let response = match result { Ok(r) => r, @@ -98,10 +96,7 @@ fn html_response(body: &'static str) -> Response>> { text_response("text/html; charset=utf-8", body) } -fn text_response( - content_type: &str, - body: &'static str, -) -> Response>> { +fn text_response(content_type: &str, body: &'static str) -> Response>> { Response::from_string(body).with_header( Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) .expect("content-type header"), diff --git a/deep_quoridor/rust/src/play_server/session.rs b/deep_quoridor/rust/src/play_server/session.rs index d486e48c..138162c3 100644 --- a/deep_quoridor/rust/src/play_server/session.rs +++ b/deep_quoridor/rust/src/play_server/session.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use rand::RngCore; use crate::agents::ActionSelector; @@ -21,7 +21,7 @@ use crate::compact::q_bit_repr::{CompactState, WALL_HORIZONTAL, WALL_VERTICAL}; use crate::compact::q_game_mechanics::QGameMechanics; use crate::play_server::config::ServerConfig; use crate::play_server::state::{ - enrich_action, enrich_legal_actions, EnrichedAction, StateView, WallEntry, WallOrientation, + EnrichedAction, StateView, WallEntry, WallOrientation, enrich_action, enrich_legal_actions, }; pub type GameId = String; @@ -80,8 +80,8 @@ impl GameSession { .to_str() .ok_or_else(|| anyhow!("model path is not valid UTF-8"))?; let agent_config = Self::agent_config(mcts_n, cfg.default_mcts_c_puct, cfg.max_steps); - let agent = AlphaZeroAgent::new(model_str, agent_config) - .context("constructing AlphaZeroAgent")?; + let agent = + AlphaZeroAgent::new(model_str, agent_config).context("constructing AlphaZeroAgent")?; Ok(Self { mechanics, state, @@ -221,9 +221,10 @@ impl GameSession { 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 (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 diff --git a/deep_quoridor/rust/src/play_server/state.rs b/deep_quoridor/rust/src/play_server/state.rs index 24230ae6..8d2f9220 100644 --- a/deep_quoridor/rust/src/play_server/state.rs +++ b/deep_quoridor/rust/src/play_server/state.rs @@ -3,14 +3,19 @@ use serde::Serialize; -use crate::actions::{action_index_to_action, ACTION_MOVE, ACTION_WALL_HORIZONTAL, ACTION_WALL_VERTICAL}; +use crate::actions::{ + ACTION_MOVE, ACTION_WALL_HORIZONTAL, ACTION_WALL_VERTICAL, action_index_to_action, +}; /// Single legal action carried over the wire. The client never needs to know /// the action-index encoding; it just looks at `kind` and the coords. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum EnrichedAction { - Move { index: u32, to: [i32; 2] }, + Move { + index: u32, + to: [i32; 2], + }, Wall { index: u32, row: i32, @@ -175,10 +180,7 @@ mod tests { let actions = enrich_legal_actions(n, &mask); assert_eq!(actions.len(), 2); - assert!(matches!( - actions[0], - EnrichedAction::Move { index: 0, .. } - )); + assert!(matches!(actions[0], EnrichedAction::Move { index: 0, .. })); assert!(matches!( actions[1], EnrichedAction::Wall { diff --git a/deep_quoridor/rust/tests/play_server_e2e.rs b/deep_quoridor/rust/tests/play_server_e2e.rs index bb9a702e..e6bd78a8 100644 --- a/deep_quoridor/rust/tests/play_server_e2e.rs +++ b/deep_quoridor/rust/tests/play_server_e2e.rs @@ -49,8 +49,7 @@ fn play_server_serves_a_few_moves_against_real_model() { let registry = GameRegistry::new(); // Bind to port 0; read assigned port. - let server = - Arc::new(tiny_http::Server::http("127.0.0.1:0").expect("bind tiny_http")); + let server = Arc::new(tiny_http::Server::http("127.0.0.1:0").expect("bind tiny_http")); let addr = server.server_addr(); let port = match addr { tiny_http::ListenAddr::IP(sock) => sock.port(), From d9cdd9164c463c58a02f6a3d83ade664269ce5a8 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 14:48:00 -0400 Subject: [PATCH 24/30] vibe: fix frontend UI issues and refine board palette Four issues addressed: - Empty wall slots now use a mid-tone groove color (--slot) that is clearly distinct from both pawn cells (--cell, lighter) and placed walls (--wall, dark walnut). Previously empty slots and placed walls were both dark, which made a placed vertical wall look like a cross at the post intersection. - Hovering a legal wall now highlights the entire wall (both halves + the post between them) by tagging the three cells with a shared data-wall-group and synchronizing mouseenter/leave on all of them. - Removed the cross artifact on placed vertical walls -- a side effect of the slot-color change above. - The UI now reflects the human's move immediately via an optimistic local update; the server's atomic human+AI response then replaces the state when it arrives. Errors roll back via GET /api/games/. Also refines the palette toward a warm wood-and-stone aesthetic: warmer beiges, walnut walls, inset pawn shading, board border with depth. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rust/src/play_server/assets/app.css | 250 +++++++++++++----- .../rust/src/play_server/assets/app.js | 165 ++++++++---- 2 files changed, 299 insertions(+), 116 deletions(-) diff --git a/deep_quoridor/rust/src/play_server/assets/app.css b/deep_quoridor/rust/src/play_server/assets/app.css index a4833d4f..09e9cf17 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.css +++ b/deep_quoridor/rust/src/play_server/assets/app.css @@ -1,43 +1,74 @@ +/* Quoridor play server -- warm wood-and-stone palette. + * + * The board reads as three distinct surfaces: + * --cell pawn squares (lightest) + * --slot empty wall channels (mid-tone groove) + * --wall placed wall pieces (darkest, walnut) + * + * Keeping these three values clearly separated is what makes a placed + * vertical wall look like a single vertical bar rather than a cross at + * the post intersection -- the perpendicular empty channels stay + * groove-tan, not wall-walnut. + */ + :root { --pawn-size: 56px; --post-size: 14px; - --pawn-p1: #2255cc; - --pawn-p2: #cc3322; - --wall: #553311; - --legal-move: rgba(80, 160, 80, 0.55); - --legal-wall: rgba(180, 130, 60, 0.5); - --last-action: gold; - --bg: #f6f3ee; - --cell: #e2cda9; - --border: #2b2b2b; + + --bg: #f1e7d3; + --cell: #ead7ad; + --slot: #c9b07a; + --post: #b59b65; + --wall: #3a2412; + --wall-preview: rgba(58, 36, 18, 0.55); + + --pawn-p1: #1e3a8a; + --pawn-p2: #b91c1c; + --legal-move: rgba(34, 113, 50, 0.65); + + --last-action: #d97706; + + --board-frame: #2b1a0c; + --card-bg: #fffaf1; + --card-border: #c9b48a; + --ink: #2a1f10; + --ink-muted: #6b5a3f; } * { box-sizing: border-box; } body { - font-family: system-ui, -apple-system, sans-serif; + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; margin: 0; - background: var(--bg); - color: #222; + background: + radial-gradient(circle at 20% 10%, rgba(255, 245, 220, 0.45), transparent 50%), + radial-gradient(circle at 85% 90%, rgba(120, 80, 30, 0.18), transparent 55%), + var(--bg); + color: var(--ink); + min-height: 100vh; } header { - padding: 1rem 1.5rem; - background: var(--border); - color: white; + padding: 1.25rem 1.75rem; + background: var(--board-frame); + color: #f3e6c8; + border-bottom: 3px solid #1a0f06; + box-shadow: 0 2px 0 rgba(255, 255, 255, 0.04) inset; } header h1 { margin: 0; - font-size: 1.4rem; + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; font-weight: 600; + font-size: 1.6rem; + letter-spacing: 0.04em; } main { display: flex; flex-direction: row; gap: 2rem; - padding: 1.5rem; + padding: 2rem; align-items: flex-start; } @@ -50,91 +81,150 @@ main { #board { display: grid; gap: 0; - border: 4px solid var(--border); - background: var(--border); + border: 6px solid var(--board-frame); + border-radius: 3px; + background: var(--board-frame); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 12px 28px rgba(40, 24, 8, 0.35), + 0 2px 4px rgba(40, 24, 8, 0.2); } .cell { - background: var(--cell); position: relative; } -.pawn-cell { background: var(--cell); } -.wall-h-half, .wall-v-half, .post { background: var(--border); } +/* Surface colors per cell type */ +.pawn-cell { background: var(--cell); } +.wall-h-half, +.wall-v-half { background: var(--slot); } +.post { background: var(--post); } + +/* Subtle inset to suggest carved board */ +.pawn-cell::after { + content: ""; + position: absolute; + inset: 0; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04); + pointer-events: none; +} + +/* Placed walls -- darkest surface, beat any other class. */ +.wall-placed-h, +.wall-placed-v, +.wall-placed-h.post, +.wall-placed-v.post { + background: var(--wall); +} /* Pawn pieces */ .pawn { position: absolute; - top: 8%; left: 8%; right: 8%; bottom: 8%; + top: 12%; left: 12%; right: 12%; bottom: 12%; border-radius: 50%; - border: 2px solid white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + border: 2px solid #fdf4d8; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.35), + inset 0 -3px 4px rgba(0, 0, 0, 0.25), + inset 0 2px 3px rgba(255, 255, 255, 0.35); } .pawn.p1 { background: var(--pawn-p1); } .pawn.p2 { background: var(--pawn-p2); } -/* Placed walls fill the slot */ -.wall-placed-h, .wall-placed-v { background: var(--wall); } - /* Last-action highlights */ .last-move::after { content: ""; position: absolute; inset: 4px; - border: 2px solid var(--last-action); + border: 2px dashed var(--last-action); border-radius: 4px; pointer-events: none; } -.last-wall { outline: 2px solid var(--last-action); outline-offset: -2px; } +.last-wall { + outline: 2px solid var(--last-action); + outline-offset: -2px; +} -/* Legal-move highlights (clickable destinations) */ -.legal-move { cursor: pointer; } +/* Legal-move highlights (clickable pawn destinations) */ +.legal-move { + cursor: pointer; +} .legal-move::before { content: ""; position: absolute; - inset: 30%; + inset: 32%; border-radius: 50%; background: var(--legal-move); + transition: inset 120ms ease-out; } -.legal-move:hover::before { inset: 20%; } +.legal-move:hover::before { inset: 18%; } -/* Legal wall slot highlights (clickable empty slots) */ -.legal-wall-h, .legal-wall-v { +/* Legal wall slots — clickable; the cell stays in its base color until + * the whole group is hovered, when JS adds `.wall-hover` to all 3 cells + * that make up the wall. */ +.legal-wall-h, +.legal-wall-v { cursor: pointer; - background: var(--cell); } -.legal-wall-h:hover, .legal-wall-v:hover { - background: var(--legal-wall); +.legal-wall-h:hover, +.legal-wall-v:hover { + /* faint hint that this cell itself is interactive even before the + * group hover synchronizes -- mostly invisible thanks to overlap with + * .wall-hover, but covers brief mouseenter races. */ + filter: brightness(1.04); +} + +/* Group-hover preview: all 3 cells of a legal wall pre-painted in a + * translucent walnut, so the player sees the full wall they're about to + * place. */ +.wall-hover { + background: var(--wall-preview) !important; } /* ---- Side panel ---- */ #side-panel { - width: 280px; + width: 300px; display: flex; flex-direction: column; - gap: 1rem; + gap: 1.25rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } #side-panel fieldset { - border: 1px solid #bbb; - padding: 0.75rem; - background: white; + border: 1px solid var(--card-border); + border-radius: 4px; + padding: 1rem; + background: var(--card-bg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } #side-panel legend { + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; font-weight: 600; - padding: 0 0.3rem; + padding: 0 0.4rem; + letter-spacing: 0.02em; + color: var(--ink); } #side-panel label { display: block; - margin: 0.4rem 0; + margin: 0.5rem 0; font-size: 0.95rem; } -#side-panel input[type="range"] { width: 100%; } -#side-panel select { width: 100%; padding: 0.3rem; } +#side-panel input[type="range"] { + width: 100%; + accent-color: var(--wall); +} +#side-panel select { + width: 100%; + padding: 0.4rem; + border: 1px solid var(--card-border); + border-radius: 3px; + background: white; + font: inherit; +} .who-goes-first label { display: inline-block; @@ -143,32 +233,49 @@ main { } #new-game-button { - margin-top: 0.5rem; + margin-top: 0.6rem; width: 100%; - padding: 0.6rem; + padding: 0.7rem; font-size: 1rem; - background: var(--pawn-p1); - color: white; + font-weight: 600; + letter-spacing: 0.03em; + background: var(--board-frame); + color: #f3e6c8; border: none; - border-radius: 4px; + border-radius: 3px; cursor: pointer; + transition: background 120ms ease-out; +} +#new-game-button:hover { background: #1f1207; } +#new-game-button:disabled { + background: #8a7a5e; + color: #ede1c1; + cursor: not-allowed; } -#new-game-button:hover { background: #1a44aa; } -#new-game-button:disabled { background: #888; cursor: not-allowed; } #status-panel { - background: white; - border: 1px solid #bbb; - padding: 0.75rem; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 4px; + padding: 1rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } #status-panel h2 { - margin: 0 0 0.5rem; - font-size: 1.1rem; + margin: 0 0 0.6rem; + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.03em; } -.muted { color: #666; font-size: 0.85rem; margin: 0.5rem 0 0; } -.error { color: #b00; font-size: 0.9rem; } +#status-panel p { + margin: 0.35rem 0; + font-size: 0.95rem; +} + +.muted { color: var(--ink-muted); font-size: 0.85rem; margin: 0.6rem 0 0; } +.error { color: #9b1c1c; font-size: 0.9rem; } /* ---- Spinner ---- */ @@ -176,7 +283,7 @@ main { display: inline-block; width: 14px; height: 14px; - border: 2px solid #888; + border: 2px solid var(--ink-muted); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; @@ -192,16 +299,19 @@ main { #game-over-banner { display: none; position: absolute; - top: 40%; + top: 42%; left: 50%; transform: translate(-50%, -50%); - padding: 1.2rem 2rem; - background: rgba(0, 0, 0, 0.88); - color: white; - font-size: 1.6rem; + padding: 1.4rem 2.4rem; + background: rgba(20, 12, 4, 0.92); + color: #f3e6c8; + font-family: "Iowan Old Style", "Hoefler Text", Georgia, serif; + font-size: 1.7rem; font-weight: 600; - border-radius: 8px; + letter-spacing: 0.05em; + border-radius: 4px; pointer-events: none; text-align: center; - letter-spacing: 0.5px; + border: 1px solid rgba(255, 230, 180, 0.2); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); } diff --git a/deep_quoridor/rust/src/play_server/assets/app.js b/deep_quoridor/rust/src/play_server/assets/app.js index cbc50a70..d78d76fa 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.js +++ b/deep_quoridor/rust/src/play_server/assets/app.js @@ -1,18 +1,25 @@ -// Quoridor play server — vanilla JS frontend. +// Quoridor play server -- vanilla JS frontend. // -// Coordinates: the server speaks absolute Quoridor coordinates with (0,0) at -// the top-left and player 0 starting on row 0. We always render the human's -// home row at the bottom, so when `human_player == 1` we mirror coordinates -// 180° before placing anything on the board grid. +// Coordinates: the server speaks absolute Quoridor coordinates with (0,0) +// at the top-left and player 0 starting on row 0. We always render the +// human's home row at the bottom, so when `human_player == 1` we mirror +// coordinates 180 deg before placing anything on the board grid. // -// The board is a (2N-1) x (2N-1) CSS grid alternating pawn cells, wall slots, -// and wall posts. Server `legal_actions` already carry the kind/coords so the -// client never has to mirror Python's action-encoding logic. +// The board is a (2N-1) x (2N-1) CSS grid alternating pawn cells, wall +// slots, and wall posts. Server `legal_actions` already carry the +// kind/coords so the client never has to mirror Python's action-encoding +// logic. +// +// Move flow uses an optimistic update: when the human clicks an action, +// we immediately apply a local approximation of the new state (move the +// pawn, place the wall, flip the turn) so the UI reflects the click +// without waiting for the AI. When the server responds with the +// authoritative post-AI state, we replace the local state and re-render. const STATE = { cfg: null, // /api/config response gameId: null, - view: null, // last StateView from server + view: null, // last StateView (server- or optimistically-derived) pending: false, }; @@ -46,6 +53,8 @@ async function fetchJson(url, options) { return resp.json(); } +// ---- setup ---- + async function init() { try { STATE.cfg = await fetchJson("/api/config"); @@ -101,25 +110,73 @@ async function startGame() { } } -async function sendMove(actionIndex) { +// ---- optimistic update ---- + +// Apply a local approximation of `action` to `view` so the UI reflects +// the human's move immediately. The server's response will overwrite +// this with the authoritative state, which also includes the AI's reply. +// +// We do not attempt to recompute the post-action legal_actions; clicks +// are disabled while we wait on the server. +function applyOptimistic(view, action) { + const o = JSON.parse(JSON.stringify(view)); + const mover = o.current_player; + if (action.kind === "move") { + if (mover === 0) o.p1_pos = action.to; + else o.p2_pos = action.to; + // Detect immediate win: reaching the opposite home row ends the game. + const N = o.board_size; + const goalRow = mover === 0 ? N - 1 : 0; + if (action.to[0] === goalRow) o.winner = mover; + } else { + o.walls.push({ + row: action.row, + col: action.col, + orientation: action.orientation, + }); + if (mover === 0) o.p1_walls -= 1; + else o.p2_walls -= 1; + } + o.last_action = { ...action }; + o.move_history = [...o.move_history, action.index]; + o.current_player = 1 - mover; + o.completed_steps += 1; + o.legal_actions = []; + return o; +} + +async function sendMove(action) { if (STATE.pending || !STATE.gameId) return; clearError(); + + // Optimistic render: show the human's move now. + STATE.view = applyOptimistic(STATE.view, action); + render(); setPending(true); + try { const data = await fetchJson(`/api/games/${STATE.gameId}/move`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action_index: actionIndex }), + body: JSON.stringify({ action_index: action.index }), }); STATE.view = data.state; render(); } catch (e) { showError("Move rejected: " + e.message); + // Roll back to the truth from the server. + try { + const fresh = await fetchJson(`/api/games/${STATE.gameId}`); + STATE.view = fresh.state; + render(); + } catch (_) { /* leave optimistic view; user will retry */ } } finally { setPending(false); } } +// ---- ui-state helpers ---- + function setPending(p) { STATE.pending = p; $("#spinner").hidden = !p; @@ -143,14 +200,31 @@ function mirrorPawn(r, c) { return STATE.view.human_player === 1 ? [N - 1 - r, N - 1 - c] : [r, c]; } -// A wall at server (r, c) sits between rows r and r+1, spanning cols c and -// c+1. Under 180 deg rotation it lives between rows (N-2-r) and (N-1-r), -// spanning cols (N-2-c) and (N-1-c) -- same orientation. +// A wall at server (r, c) sits between rows r and r+1, spanning cols c +// and c+1. Under 180 deg rotation it lives between rows (N-2-r) and +// (N-1-r), spanning cols (N-2-c) and (N-1-c) -- same orientation. function mirrorWall(r, c) { const N = STATE.view.board_size; return STATE.view.human_player === 1 ? [N - 2 - r, N - 2 - c] : [r, c]; } +// Return the (gr, gc) grid coordinates of the 3 cells that make up a +// wall at display (dr, dc): the two halves and the post between them. +function wallGroupCells(dr, dc, orientation) { + if (orientation === "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], + ]; +} + // ---- render ---- function render() { @@ -184,8 +258,9 @@ function render() { } } - // Pawns -- keep colors tied to the server player index so walls-left - // counters line up with the pawn colors regardless of orientation. + // Pawns -- colors stay tied to the server player index so the + // walls-left counters in the side panel always match the pawn colors + // regardless of orientation. const [p1r, p1c] = mirrorPawn(v.p1_pos[0], v.p1_pos[1]); const [p2r, p2c] = mirrorPawn(v.p2_pos[0], v.p2_pos[1]); cells[2 * p1r][2 * p1c].appendChild(make("div", { class: "pawn p1" })); @@ -194,14 +269,9 @@ function render() { // Placed walls for (const w of v.walls) { const [dr, dc] = mirrorWall(w.row, w.col); - if (w.orientation === "h") { - cells[2 * dr + 1][2 * dc].classList.add("wall-placed-h"); - cells[2 * dr + 1][2 * dc + 1].classList.add("wall-placed-h"); - cells[2 * dr + 1][2 * dc + 2].classList.add("wall-placed-h"); - } else { - cells[2 * dr][2 * dc + 1].classList.add("wall-placed-v"); - cells[2 * dr + 1][2 * dc + 1].classList.add("wall-placed-v"); - cells[2 * dr + 2][2 * dc + 1].classList.add("wall-placed-v"); + const placedCls = `wall-placed-${w.orientation}`; + for (const [gr, gc] of wallGroupCells(dr, dc, w.orientation)) { + cells[gr][gc].classList.add(placedCls); } } @@ -213,42 +283,45 @@ function render() { cells[2 * dr][2 * dc].classList.add("last-move"); } else { const [dr, dc] = mirrorWall(la.row, la.col); - if (la.orientation === "h") { - cells[2 * dr + 1][2 * dc].classList.add("last-wall"); - cells[2 * dr + 1][2 * dc + 1].classList.add("last-wall"); - cells[2 * dr + 1][2 * dc + 2].classList.add("last-wall"); - } else { - cells[2 * dr][2 * dc + 1].classList.add("last-wall"); - cells[2 * dr + 1][2 * dc + 1].classList.add("last-wall"); - cells[2 * dr + 2][2 * dc + 1].classList.add("last-wall"); + for (const [gr, gc] of wallGroupCells(dr, dc, la.orientation)) { + cells[gr][gc].classList.add("last-wall"); } } } - // Click handlers on legal actions -- only when it's the human's turn. - const humanTurn = v.winner === null && v.current_player === v.human_player; + // Click handlers on legal actions -- only when it's the human's turn + // and we are not waiting on the server. + const humanTurn = + v.winner === null && v.current_player === v.human_player && !STATE.pending; if (humanTurn) { + let wallId = 0; for (const a of v.legal_actions) { if (a.kind === "move") { const [dr, dc] = mirrorPawn(a.to[0], a.to[1]); const cell = cells[2 * dr][2 * dc]; cell.classList.add("legal-move"); - cell.addEventListener("click", () => sendMove(a.index)); + cell.addEventListener("click", () => sendMove(a)); } else { const [dr, dc] = mirrorWall(a.row, a.col); - const tag = (gr, gc) => { - const c = cells[gr][gc]; + const groupCells = wallGroupCells(dr, dc, a.orientation).map( + ([gr, gc]) => cells[gr][gc], + ); + // Tag the three cells so group-hover can find them all. + const groupId = `wall-${wallId++}`; + for (const c of groupCells) { c.classList.add(`legal-wall-${a.orientation}`); - c.addEventListener("click", () => sendMove(a.index)); + c.dataset.wallGroup = groupId; + c.addEventListener("click", () => sendMove(a)); + } + // Group hover: mouseenter on any cell of the group highlights + // all three. Re-querying by attribute keeps the closure free of + // a captured array reference per cell. + const setHover = (on) => { + for (const c of groupCells) c.classList.toggle("wall-hover", on); }; - if (a.orientation === "h") { - tag(2 * dr + 1, 2 * dc); - tag(2 * dr + 1, 2 * dc + 1); - tag(2 * dr + 1, 2 * dc + 2); - } else { - tag(2 * dr, 2 * dc + 1); - tag(2 * dr + 1, 2 * dc + 1); - tag(2 * dr + 2, 2 * dc + 1); + for (const c of groupCells) { + c.addEventListener("mouseenter", () => setHover(true)); + c.addEventListener("mouseleave", () => setHover(false)); } } } From 820f5bae6f4f6c414e072a55f21bda112dc5262e Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 16:21:22 -0400 Subject: [PATCH 25/30] vibe: restore click handlers broken by pending guard The previous commit added && \!STATE.pending to the humanTurn check in render(), intending to suppress clicks while a network request was in flight. But the post-AI render happens before setPending(false) runs (it's in the finally block), so STATE.pending is still true at that point and no click handlers got attached -- the board became inert. Drop the guard. sendMove() already short-circuits on STATE.pending, so gating handler attachment is redundant. Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/src/play_server/assets/app.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/deep_quoridor/rust/src/play_server/assets/app.js b/deep_quoridor/rust/src/play_server/assets/app.js index d78d76fa..a36c6483 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.js +++ b/deep_quoridor/rust/src/play_server/assets/app.js @@ -289,10 +289,11 @@ function render() { } } - // Click handlers on legal actions -- only when it's the human's turn - // and we are not waiting on the server. - const humanTurn = - v.winner === null && v.current_player === v.human_player && !STATE.pending; + // Click handlers on legal actions -- only when it's the human's turn. + // (sendMove() ignores clicks while STATE.pending is true, so we don't + // gate handler attachment on pending here -- if we did, the post-AI + // render would land with pending still true and we'd attach nothing.) + const humanTurn = v.winner === null && v.current_player === v.human_player; if (humanTurn) { let wallId = 0; for (const a of v.legal_actions) { From e016e948c3742694d93f4ae8334637abd723d54f Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 17:07:16 -0400 Subject: [PATCH 26/30] vibe: anchor-only wall hover + put human at bottom Resolves three coupled UI problems: 1. Overlapping wall highlights. The right half of one horizontal wall slot is the left half of the next, so hovering it used to fire mouseenter listeners on both walls' groups -- two walls would light up. Now each wall is interactive only on its display top-left cell (left for H, top for V), so every grid cell triggers at most one wall. 2. Ambiguity at intersection posts. Posts were part of two walls' groups before, so hovering one lit up both H and V. With the anchor-only rule, posts are never any wall's anchor and trigger nothing on hover. 3. Human rendered at the top. The mirror condition was inverted vs the spec's intent: the canonical view in CSS has display row 0 at the top, so to put P0's home row at the bottom we have to vertical- flip the board when the human is P0. (P1 already starts at server row N-1, which is the display bottom without a flip.) Switched to a simple vertical flip -- cols stay put -- so the H wall's left half stays the left half in display space and the anchor-only rule reads naturally as 'extends right (H) or down (V)'. Also drops the now-redundant brightness:hover race-cover style; with anchor-only attachment there's no race. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rust/src/play_server/assets/app.css | 14 ++-- .../rust/src/play_server/assets/app.js | 66 +++++++++++-------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/deep_quoridor/rust/src/play_server/assets/app.css b/deep_quoridor/rust/src/play_server/assets/app.css index 09e9cf17..12807987 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.css +++ b/deep_quoridor/rust/src/play_server/assets/app.css @@ -159,20 +159,14 @@ main { } .legal-move:hover::before { inset: 18%; } -/* Legal wall slots — clickable; the cell stays in its base color until - * the whole group is hovered, when JS adds `.wall-hover` to all 3 cells - * that make up the wall. */ +/* Legal wall anchors -- the click target for each legal wall. The class + * is only applied to the cell at the wall's display top-left (left half + * for H, top half for V). Hovering the anchor triggers JS that adds + * `.wall-hover` to all 3 cells of the wall. */ .legal-wall-h, .legal-wall-v { cursor: pointer; } -.legal-wall-h:hover, -.legal-wall-v:hover { - /* faint hint that this cell itself is interactive even before the - * group hover synchronizes -- mostly invisible thanks to overlap with - * .wall-hover, but covers brief mouseenter races. */ - filter: brightness(1.04); -} /* Group-hover preview: all 3 cells of a legal wall pre-painted in a * translucent walnut, so the player sees the full wall they're about to diff --git a/deep_quoridor/rust/src/play_server/assets/app.js b/deep_quoridor/rust/src/play_server/assets/app.js index a36c6483..f8ddc87a 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.js +++ b/deep_quoridor/rust/src/play_server/assets/app.js @@ -194,18 +194,30 @@ function clearError() { } // ---- coordinate transforms ---- +// +// We always render the human's home row at the bottom. Server P0 +// starts at server row 0; P1 starts at server row N-1. CSS-grid row 0 +// renders at the top of the screen, so: +// +// - human == P0 -> vertical flip (row N-1 - r), cols unchanged. +// - human == P1 -> no flip (P1 already at server row N-1 = bottom). +// +// Cols stay put so a wall's "left" half in server space stays the +// left half in display space; the anchor/extends-right rule the +// hover logic below relies on then needs no further inversion. function mirrorPawn(r, c) { const N = STATE.view.board_size; - return STATE.view.human_player === 1 ? [N - 1 - r, N - 1 - c] : [r, c]; + return STATE.view.human_player === 0 ? [N - 1 - r, c] : [r, c]; } -// A wall at server (r, c) sits between rows r and r+1, spanning cols c -// and c+1. Under 180 deg rotation it lives between rows (N-2-r) and -// (N-1-r), spanning cols (N-2-c) and (N-1-c) -- same orientation. +// Server walls are indexed by their (top, left) corner. After a +// vertical flip, what was server-top becomes display-bottom -- so the +// display (top, left) corner of the wall is at row N-2-r (one less +// than N-1-r because the wall spans two pawn rows). function mirrorWall(r, c) { const N = STATE.view.board_size; - return STATE.view.human_player === 1 ? [N - 2 - r, N - 2 - c] : [r, c]; + return STATE.view.human_player === 0 ? [N - 2 - r, c] : [r, c]; } // Return the (gr, gc) grid coordinates of the 3 cells that make up a @@ -290,12 +302,20 @@ function render() { } // Click handlers on legal actions -- only when it's the human's turn. - // (sendMove() ignores clicks while STATE.pending is true, so we don't - // gate handler attachment on pending here -- if we did, the post-AI - // render would land with pending still true and we'd attach nothing.) + // sendMove() ignores clicks while STATE.pending is true, so we don't + // need to also gate handler attachment on pending. + // + // Anchor-only attachment: each wall is interactive *only* on its + // display-top-left cell -- the left half for horizontal walls, the + // top half for vertical walls. This means: + // - Each grid cell triggers at most one wall, so hovering doesn't + // light up two overlapping walls at once. + // - Intersection posts (the small squares between four pawn cells) + // never trigger a wall, since they are never any wall's anchor. + // - The wall the user sees on hover is the one that "starts here + // and extends right (H) or down (V)" in display coordinates. const humanTurn = v.winner === null && v.current_player === v.human_player; if (humanTurn) { - let wallId = 0; for (const a of v.legal_actions) { if (a.kind === "move") { const [dr, dc] = mirrorPawn(a.to[0], a.to[1]); @@ -304,26 +324,18 @@ function render() { cell.addEventListener("click", () => sendMove(a)); } else { const [dr, dc] = mirrorWall(a.row, a.col); - const groupCells = wallGroupCells(dr, dc, a.orientation).map( + const group = wallGroupCells(dr, dc, a.orientation).map( ([gr, gc]) => cells[gr][gc], ); - // Tag the three cells so group-hover can find them all. - const groupId = `wall-${wallId++}`; - for (const c of groupCells) { - c.classList.add(`legal-wall-${a.orientation}`); - c.dataset.wallGroup = groupId; - c.addEventListener("click", () => sendMove(a)); - } - // Group hover: mouseenter on any cell of the group highlights - // all three. Re-querying by attribute keeps the closure free of - // a captured array reference per cell. - const setHover = (on) => { - for (const c of groupCells) c.classList.toggle("wall-hover", on); - }; - for (const c of groupCells) { - c.addEventListener("mouseenter", () => setHover(true)); - c.addEventListener("mouseleave", () => setHover(false)); - } + const anchor = group[0]; // first cell is the display top-left + anchor.classList.add(`legal-wall-${a.orientation}`); + anchor.addEventListener("click", () => sendMove(a)); + anchor.addEventListener("mouseenter", () => { + for (const c of group) c.classList.add("wall-hover"); + }); + anchor.addEventListener("mouseleave", () => { + for (const c of group) c.classList.remove("wall-hover"); + }); } } } From 29e020329b91af6c53b63762a0de9e43503f290c Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 17:51:25 -0400 Subject: [PATCH 27/30] vibe: fix swapped walls-remaining; reword label The status panel was wired by server player index, so when the human plays as P1 the 'You' row showed P0's wall count and vice versa. Map by role (human vs ai) using human_player instead. Also reword from 'walls left' to 'walls remaining'. Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/src/play_server/assets/app.js | 8 ++++++-- deep_quoridor/rust/src/play_server/assets/index.html | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/deep_quoridor/rust/src/play_server/assets/app.js b/deep_quoridor/rust/src/play_server/assets/app.js index f8ddc87a..4d08dc65 100644 --- a/deep_quoridor/rust/src/play_server/assets/app.js +++ b/deep_quoridor/rust/src/play_server/assets/app.js @@ -349,8 +349,12 @@ function render() { } else { turnEl.textContent = "AI thinking"; } - $("#walls-p1").textContent = v.p1_walls; - $("#walls-p2").textContent = v.p2_walls; + // Map walls-left by role, not server index: when the human is P1 we + // want "You" to show p2_walls, not p1_walls. + const youWalls = v.human_player === 0 ? v.p1_walls : v.p2_walls; + const aiWalls = v.human_player === 0 ? v.p2_walls : v.p1_walls; + $("#walls-you").textContent = youWalls; + $("#walls-ai").textContent = aiWalls; $("#completed-steps").textContent = `${v.completed_steps} / ${v.max_steps}`; // Game-over banner diff --git a/deep_quoridor/rust/src/play_server/assets/index.html b/deep_quoridor/rust/src/play_server/assets/index.html index 644a51dc..ed08b8e3 100644 --- a/deep_quoridor/rust/src/play_server/assets/index.html +++ b/deep_quoridor/rust/src/play_server/assets/index.html @@ -49,8 +49,8 @@

Status

-

You — walls left:

-

AI — walls left:

+

You — walls remaining:

+

AI — walls remaining:

Step:

From 8b0ba1fc599fe19824b3cb2bcba257adb5ed0eda Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Fri, 29 May 2026 18:19:35 -0400 Subject: [PATCH 28/30] vibe: default mcts_n 1000; simpler who-goes-first labels - Bump the default --default-mcts-n CLI value from 400 to 1000 so the slider lands somewhere more capable on startup. - Drop the (P1)/(P2) suffixes from the who-goes-first radio labels; they're noise in a strictly human-vs-AI app. Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/src/bin/play_server.rs | 2 +- deep_quoridor/rust/src/play_server/assets/index.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deep_quoridor/rust/src/bin/play_server.rs b/deep_quoridor/rust/src/bin/play_server.rs index 4ec5c34d..4a120282 100644 --- a/deep_quoridor/rust/src/bin/play_server.rs +++ b/deep_quoridor/rust/src/bin/play_server.rs @@ -39,7 +39,7 @@ struct Cli { bind: String, /// Default MCTS simulations per move shown in the UI slider. - #[arg(long, default_value_t = 400)] + #[arg(long, default_value_t = 1000)] default_mcts_n: u32, } diff --git a/deep_quoridor/rust/src/play_server/assets/index.html b/deep_quoridor/rust/src/play_server/assets/index.html index ed08b8e3..7b2667c7 100644 --- a/deep_quoridor/rust/src/play_server/assets/index.html +++ b/deep_quoridor/rust/src/play_server/assets/index.html @@ -31,8 +31,8 @@

Quoridor vs AlphaZero

Who goes first? - - + +
From 5c0bdc0a5d1dc30d63e1f2e70cec2f875b81b435 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Sun, 31 May 2026 15:27:59 -0400 Subject: [PATCH 29/30] vibe: silence cargo-test filename-collision warning The lib was declared as both cdylib and rlib in Cargo.toml, which triggers cargo issue #6313 (output filename collision) on every cargo test invocation -- three warnings on each run, no errors, but noise that crowds out anything else. Drop cdylib from Cargo.toml and add a pyproject.toml that tells maturin to use the pyo3 bindings; that backend implicitly compiles with --crate-type cdylib for the wheel build, so plain 'maturin build' still produces an importable .so without cdylib being a default cargo output. Verified by building and importing the wheel. Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/Cargo.toml | 7 ++++++- deep_quoridor/rust/pyproject.toml | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 deep_quoridor/rust/pyproject.toml diff --git a/deep_quoridor/rust/Cargo.toml b/deep_quoridor/rust/Cargo.toml index 14d0d1a7..90ba1531 100644 --- a/deep_quoridor/rust/Cargo.toml +++ b/deep_quoridor/rust/Cargo.toml @@ -9,7 +9,12 @@ edition = "2024" [lib] name = "quoridor_rs" -crate-type = ["cdylib", "rlib"] +# Only `rlib` is listed so `cargo test` doesn't emit a `.so` alongside the +# `.rlib` and trigger cargo's "output filename collision" warning (cargo +# issue #6313, surfaces when one target has multiple crate-types and the +# binaries depend on the same lib). The python wheel build sets +# `crate-type = ["cdylib"]` via `[tool.maturin]` in pyproject.toml. +crate-type = ["rlib"] [[bin]] name = "create_policy_db" diff --git a/deep_quoridor/rust/pyproject.toml b/deep_quoridor/rust/pyproject.toml new file mode 100644 index 00000000..bbfb914b --- /dev/null +++ b/deep_quoridor/rust/pyproject.toml @@ -0,0 +1,23 @@ +# Python build configuration for maturin. +# +# Cargo.toml declares `[lib] crate-type = ["rlib"]` so `cargo test` doesn't +# emit overlapping cdylib outputs (cargo issue #6313). The Python wheel +# needs a `cdylib` -- maturin reads `crate-type` from this file and passes +# it through, so wheel builds still produce `libquoridor_rs.so`. + +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "quoridor-rs" +requires-python = ">=3.8" + +[tool.maturin] +# `bindings = "pyo3"` is what makes maturin emit a cdylib even when +# Cargo.toml's `[lib] crate-type` is only `rlib` -- cdylib is the format +# Python loads, but we don't want it as a default cargo output (it would +# collide with the rlib's filename when binaries are built; see cargo +# issue #6313). +bindings = "pyo3" +features = ["pyo3/extension-module"] From de6683c8d02ffd0c45a873016db5286a774b29a0 Mon Sep 17 00:00:00 2001 From: Jon Binney Date: Sun, 31 May 2026 15:27:59 -0400 Subject: [PATCH 30/30] vibe: prefer filter().map() over bool::then in filter_map Clippy lint (clippy::bool_then_in_filter_map). Co-Authored-By: Claude Opus 4.7 (1M context) --- deep_quoridor/rust/src/play_server/state.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deep_quoridor/rust/src/play_server/state.rs b/deep_quoridor/rust/src/play_server/state.rs index 8d2f9220..0b3e0c9a 100644 --- a/deep_quoridor/rust/src/play_server/state.rs +++ b/deep_quoridor/rust/src/play_server/state.rs @@ -101,7 +101,8 @@ pub fn enrich_action(board_size: i32, index: usize) -> EnrichedAction { pub fn enrich_legal_actions(board_size: i32, mask: &[bool]) -> Vec { mask.iter() .enumerate() - .filter_map(|(i, &legal)| legal.then(|| enrich_action(board_size, i))) + .filter(|&(_, legal)| *legal) + .map(|(i, _)| enrich_action(board_size, i)) .collect() }