diff --git a/.gitignore b/.gitignore index ce83ce4..16def7b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ src/**/*.c # Local session / lock files .compute.lock .claude/ +# ...except checked-in project skills, which are documentation for the next session. +!web/.claude/ +!web/.claude/** # Rust build output target/ @@ -29,10 +32,13 @@ tools/julia/ .ruff_cache # Web dependencies and locally exported models. The verified public browser -# policy below is the one exception: it is deliberately served as a static asset. +# policies below are the exception: they are deliberately served as static assets, +# and the deploy builds straight from the repo, so an ignored model ships as a 404. web/node_modules/ web/*.tsbuildinfo web/public/models/*.onnx web/public/models/*.json !web/public/models/jax-ppo.onnx !web/public/models/jax-ppo.json +!web/public/models/borealis.onnx +!web/public/models/borealis.json diff --git a/scripts/export_match_onnx.py b/scripts/export_match_onnx.py new file mode 100644 index 0000000..99fd0d6 --- /dev/null +++ b/scripts/export_match_onnx.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Export a match policy's actor trunk to ONNX for the browser. + +Only the actor ships. The critic exists to grade moves during training and never +plays, so its trunk -- and the privileged view of the opponent's hand and the deck +that feeds it -- is dropped here rather than shipped and then not used. That also +means the exported graph physically cannot leak hidden state, which is a stronger +guarantee than promising not to call it. + +MatchActorCritic lays the actor out as Dense_0..Dense_{num_layers} exactly as the +single-round model does, so the graph construction is the same; only the input +width and the checkpoint loader differ. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +import jax +import jax.numpy as jnp +import numpy as np + +from lost_cities_jax.match_obs import MATCH_CRITIC_OBS_DIM, MATCH_OBS_DIM +from lost_cities_jax.match_ppo import Ablation, MatchActorCritic, create_match_train_state +from lost_cities_jax.ppo import load_config, restore_checkpoint +from lost_cities_jax.types import N_ACTIONS + + +def build_argparser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoint", type=Path, required=True) + parser.add_argument("--config", type=Path, default=Path("configs/jax_ppo/match-selfplay.yaml")) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--codename", required=True, help="see data/models.json") + return parser + + +def export_model(checkpoint: Path, config: Path, output: Path, codename: str) -> None: + try: + import onnx + from onnx import TensorProto, helper, numpy_helper + from onnx.reference import ReferenceEvaluator + except ImportError as exc: + raise SystemExit("onnx is required; run with `uv run --with onnx ...`") from exc + + cfg = load_config(config) + state = restore_checkpoint( + checkpoint, create_match_train_state(cfg, jax.random.PRNGKey(0), Ablation()) + ) + params = state.params + dense = params["params"] + + nodes = [] + initializers = [] + previous = "obs" + for index in range(cfg.network.num_layers): + layer = dense[f"Dense_{index}"] + weight, bias = f"dense_{index}.weight", f"dense_{index}.bias" + initializers.extend( + [ + numpy_helper.from_array(np.asarray(layer["kernel"], dtype=np.float32), weight), + numpy_helper.from_array(np.asarray(layer["bias"], dtype=np.float32), bias), + ] + ) + nodes.append(helper.make_node("Gemm", [previous, weight, bias], [f"dense_{index}.linear"])) + nodes.append(helper.make_node("Relu", [f"dense_{index}.linear"], [f"dense_{index}.relu"])) + previous = f"dense_{index}.relu" + + actor = dense[f"Dense_{cfg.network.num_layers}"] + initializers.extend( + [ + numpy_helper.from_array(np.asarray(actor["kernel"], dtype=np.float32), "actor.weight"), + numpy_helper.from_array(np.asarray(actor["bias"], dtype=np.float32), "actor.bias"), + ] + ) + nodes.append(helper.make_node("Gemm", [previous, "actor.weight", "actor.bias"], ["logits"])) + + graph = helper.make_graph( + nodes, + f"coolrl-lost-cities-match-actor-{codename}", + [helper.make_tensor_value_info("obs", TensorProto.FLOAT, [None, MATCH_OBS_DIM])], + [helper.make_tensor_value_info("logits", TensorProto.FLOAT, [None, N_ACTIONS])], + initializer=initializers, + ) + model = helper.make_model( + graph, producer_name="coolrl-lost-cities", opset_imports=[helper.make_opsetid("", 17)] + ) + model.ir_version = 8 + onnx.checker.check_model(model) + + # The exported graph has to agree with the trained one, not merely load. + rng = np.random.default_rng(20260715) + sample = rng.normal(size=(8, MATCH_OBS_DIM)).astype(np.float32) + critic_stub = jnp.zeros((8, MATCH_CRITIC_OBS_DIM), dtype=jnp.float32) + flax_model = MatchActorCritic(cfg.network.hidden_size, cfg.network.num_layers) + expected, _ = flax_model.apply(params, jnp.asarray(sample), critic_stub) + actual = ReferenceEvaluator(model).run(None, {"obs": sample})[0] + np.testing.assert_allclose(actual, np.asarray(expected), rtol=2e-5, atol=2e-5) + np.testing.assert_array_equal( + np.argmax(actual, axis=1), np.argmax(np.asarray(expected), axis=1) + ) + + output.parent.mkdir(parents=True, exist_ok=True) + onnx.save(model, output) + model_bytes = output.read_bytes() + manifest = { + "format": "coolrl-lost-cities-match-onnx-v1", + "codename": codename, + "model_file": output.name, + "model_size_bytes": len(model_bytes), + "model_sha256": hashlib.sha256(model_bytes).hexdigest(), + "source_checkpoint": str(checkpoint), + "source_config": config.name, + "observation_size": MATCH_OBS_DIM, + "action_size": N_ACTIONS, + "hidden_size": cfg.network.hidden_size, + "num_layers": cfg.network.num_layers, + "dtype": "float32", + "validation_max_abs_error": float(np.max(np.abs(actual - np.asarray(expected)))), + } + output.with_suffix(".json").write_text(json.dumps(manifest, indent=2) + "\n") + print(f"exported {codename} -> {output} ({output.stat().st_size:,} bytes)") + print( + f" sha256 {manifest['model_sha256'][:12]} obs {MATCH_OBS_DIM} max err " + f"{manifest['validation_max_abs_error']:.2e}" + ) + + +def main() -> None: + args = build_argparser().parse_args() + export_model(args.checkpoint, args.config, args.output, args.codename) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_match_parity_fixture.py b/scripts/generate_match_parity_fixture.py new file mode 100644 index 0000000..fd62510 --- /dev/null +++ b/scripts/generate_match_parity_fixture.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Generate match states and their observations so TypeScript can be checked against JAX. + +The two observation builders must agree to the bit. A mismatch does not throw -- +the ONNX policy consumes a wrong vector quite happily and plays worse for reasons +nobody can see. So the port is not trusted; it is checked. + +States are drawn from real random play so the fixture covers the awkward parts: +mid-round, both seats to move, past a round roll-over, with a non-zero carry. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jax +import numpy as np + +from lost_cities_jax.match import MatchState, match_reset_from, match_step +from lost_cities_jax.match_obs import MATCH_OBS_DIM, match_observation +from lost_cities_jax.opponents import random_legal_action +from lost_cities_jax.types import N_CARDS + +OUTPUT = Path(__file__).resolve().parents[1] / "web" / "src" / "game" / "match-parity-fixture.json" +N_ROUNDS = 3 + + +def match_json(match: MatchState) -> dict: + round_state = match.round + return { + "round": { + "deckOrder": np.asarray(round_state.deck_order).astype(int).tolist(), + "drawPtr": int(round_state.draw_ptr), + "cardLoc": np.asarray(round_state.card_loc).astype(int).tolist(), + "handPublic": np.asarray(round_state.hand_public).astype(bool).tolist(), + "colTop": np.asarray(round_state.col_top).astype(int).tolist(), + "colHandshakes": np.asarray(round_state.col_hs).astype(int).tolist(), + "colLength": np.asarray(round_state.col_len).astype(int).tolist(), + "piles": [ + np.asarray(round_state.pile[color, : int(round_state.pile_len[color])]) + .astype(int) + .tolist() + for color in range(5) + ], + "toMove": int(round_state.to_move), + "stepCount": int(round_state.step_count), + "done": bool(round_state.done), + }, + "deckOrders": np.asarray(match.deck_orders).astype(int).tolist(), + "coinFlips": np.asarray(match.coin_flips).astype(int).tolist(), + "roundIdx": int(match.round_idx), + "carry": np.asarray(match.carry).astype(int).tolist(), + "done": bool(match.done), + } + + +def main() -> None: + rng = np.random.default_rng(20260715) + key = jax.random.PRNGKey(7) + rows = [] + + for match_index in range(6): + decks = np.stack([rng.permutation(N_CARDS) for _ in range(N_ROUNDS)]) + coins = rng.integers(0, 2, size=(N_ROUNDS,)) + match = match_reset_from(decks.astype(np.int8), coins.astype(np.int8)) + + # Sample the opening position and then every 17th ply, which lands in all + # three rounds and on both seats without hand-picking anything. + ply = 0 + while not bool(match.done) and ply < 400: + if ply % 17 == 0 or ply == 0: + for player in (0, 1): + obs = np.asarray(match_observation(match, player), dtype=np.float64) + assert obs.shape == (MATCH_OBS_DIM,) + rows.append( + { + "match": match_json(match), + "player": player, + "observation": [round(float(v), 7) for v in obs], + } + ) + key, step_key = jax.random.split(key) + action = int(random_legal_action(match.round, match.round.to_move, step_key)) + match, _, _ = match_step(match, action) + ply += 1 + del match_index + + OUTPUT.write_text( + json.dumps({"format": "jax-web-match-parity-v1", "obsDim": MATCH_OBS_DIM, "rows": rows}) + + "\n" + ) + print(f"wrote {len(rows)} rows -> {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/serve_web_with_logs.py b/scripts/serve_web_with_logs.py new file mode 100644 index 0000000..94781a4 --- /dev/null +++ b/scripts/serve_web_with_logs.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Serve the production web client and append completed games to JSONL.""" + +from __future__ import annotations + +import argparse +import json +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +MAX_RECORD_BYTES = 6_000_000 + +# v2 adds the opponent's identity (codename + hash) and the match layer -- three +# deals, the coin flips, and which round each move belongs to. v1 is still +# accepted because 111 games were recorded under it; they are all altair, which +# data/models.json records since v1 has nowhere to say so. +SUPPORTED_FORMATS = frozenset({"lost-cities-web-game-v1", "lost-cities-web-game-v2"}) + + +def parse_record(body: bytes) -> dict[str, Any]: + if len(body) > MAX_RECORD_BYTES: + raise ValueError("record is too large") + value = json.loads(body) + if not isinstance(value, dict) or value.get("format") not in SUPPORTED_FORMATS: + raise ValueError("unsupported game record") + if not isinstance(value.get("gameId"), str) or not isinstance(value.get("moves"), list): + raise ValueError("invalid game record") + return value + + +def make_handler(dist: Path, output: Path): + seen_ids: set[str] = set() + if output.exists(): + for line in output.read_text(encoding="utf-8").splitlines(): + try: + game_id = json.loads(line).get("gameId") + if isinstance(game_id, str): + seen_ids.add(game_id) + except (json.JSONDecodeError, AttributeError): + continue + + class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, directory=str(dist), **kwargs) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/api/game-records": + self.send_error(HTTPStatus.NOT_FOUND) + return + try: + length = int(self.headers.get("content-length", "0")) + record = parse_record(self.rfile.read(length)) + except (ValueError, json.JSONDecodeError) as error: + self.send_error(HTTPStatus.BAD_REQUEST, str(error)) + return + game_id = record["gameId"] + if game_id not in seen_ids: + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("a", encoding="utf-8") as stream: + stream.write( + json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + seen_ids.add(game_id) + self.send_response(HTTPStatus.NO_CONTENT) + self.end_headers() + + return Handler + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=5173) + parser.add_argument("--dist", type=Path, default=Path("web/dist")) + parser.add_argument("--output", type=Path, default=Path("data/human-play/game-records.jsonl")) + args = parser.parse_args() + server = ThreadingHTTPServer((args.host, args.port), make_handler(args.dist, args.output)) + print(f"Serving {args.dist} on http://{args.host}:{args.port}", flush=True) + print(f"Writing game records to {args.output}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/lost_cities_jax/test_web_log_server.py b/tests/lost_cities_jax/test_web_log_server.py new file mode 100644 index 0000000..80037fe --- /dev/null +++ b/tests/lost_cities_jax/test_web_log_server.py @@ -0,0 +1,34 @@ +import json + +import pytest + +from scripts.serve_web_with_logs import parse_record + + +def test_parse_record_accepts_analysis_record(): + record = {"format": "lost-cities-web-game-v1", "gameId": "game-1", "moves": []} + assert parse_record(json.dumps(record).encode()) == record + + +@pytest.mark.parametrize("record", [{}, {"format": "other"}, {"format": "lost-cities-web-game-v1"}]) +def test_parse_record_rejects_invalid_input(record): + with pytest.raises(ValueError): + parse_record(json.dumps(record).encode()) + + +def test_parse_record_accepts_the_match_schema(): + # v2 carries which model played and the three-round match; v1 had neither. + record = { + "format": "lost-cities-web-game-v2", + "gameId": "game-2", + "opponent": {"codename": "borealis", "hash": "13a25243de1c"}, + "mode": 3, + "moves": [], + } + assert parse_record(json.dumps(record).encode()) == record + + +def test_parse_record_still_accepts_v1(): + # 111 games were recorded under v1; refusing them now would orphan them. + record = {"format": "lost-cities-web-game-v1", "gameId": "game-1", "moves": []} + assert parse_record(json.dumps(record).encode()) == record diff --git a/web/.claude/skills/verify/SKILL.md b/web/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..7e1eea3 --- /dev/null +++ b/web/.claude/skills/verify/SKILL.md @@ -0,0 +1,68 @@ +--- +name: verify +description: Drive the Lost Cities web client in a real browser to observe a change working — model loading, a full match, the result card, records being written. +--- + +# Verifying the web client + +The client is where a silent bug hides best. `npm test` and `tsc` were both green +while the app was announcing the wrong winner and dropping every game record on +the floor. Neither throws. Run it. + +## Build and serve + +```bash +cd web && npm run build +cd .. && tmux new-session -d -s websrv \ + "uv run python scripts/serve_web_with_logs.py --host 127.0.0.1 --port 5199 \ + --dist web/dist --output /tmp/verify-records.jsonl" +``` + +Serve the **built dist**, not the vite dev server — the deploy builds from dist, +and the model is a static asset whose path only resolves there. Point `--output` +at a scratch file so verification runs never touch `data/human-play/`. + +## Drive it + +Playwright is deliberately **not** a dependency: the `playwright` package downloads +~114MB of Chromium on install, and `npm ci` runs in the deploy workflow. Install it +for the run and uninstall after. + +```bash +cd web +npm i -D playwright --no-fund --no-audit && npx playwright install chromium +# ... drive ... +npm uninstall playwright +``` + +## Selectors that actually work + +Found by dumping the DOM; guessing at them wasted two runs. + +| What | Selector | +|---|---| +| a hand card | `button.card:not([disabled])` | +| play onto an expedition | `button.lane__zone--mine.is-target` | +| discard | `button.lane__discard.is-target` | +| draw from deck | `button.deck-stack:not([disabled])` | +| which model loaded | `.score-plaque--rival small` | +| match progress | `.round-strip` | +| final scores | `.result-card` | + +A turn is three clicks: pick a card, choose where it goes, then draw. The place and +draw targets only appear **after** the card is selected, and only the legal ones are +enabled — so click the card first, then query. + +The rival answers on a 620ms timer plus inference; ~200ms of slack between plies is +enough. A full three-round match runs ~140 plies, so budget a few minutes. + +## Worth driving + +- **A whole match, not one round.** The round roll-over is where carry banks, and it + is where the result card got it wrong. +- **Check the record actually saved.** A schema bump on the client silently 400s + against `scripts/serve_web_with_logs.py` until its allowlist is updated too. +- **A stale save in localStorage.** Bump the key on a schema change; a half-migrated + save is worse than a fresh deal. +- **The same seed twice.** In match mode the seed fixes all three deals and the coin + flips, so a match is a pure function of it. diff --git a/web/BUGS.md b/web/BUGS.md new file mode 100644 index 0000000..66f5035 --- /dev/null +++ b/web/BUGS.md @@ -0,0 +1,21 @@ +# Web bug checklist + +- [ ] Prevent cards from launching more than once during initial loading. + - Implementation and automated checks complete; user visual acceptance pending. +- [ ] Stack Hint, Undo, and Redo vertically at the bottom-right, aligned with the hand row. + - Implementation and automated checks complete; user visual acceptance pending. +- [ ] Restore the latest game timeline and review position from local storage. + - Implementation and automated checks complete; user reload acceptance pending. +- [ ] Keep Hint, Undo, Redo, and Play From Here in fixed vertical positions. + - Play From Here remains visible but disabled when unavailable; user visual acceptance pending. +- [ ] Automatically record completed human-versus-AI games for later analysis. + - JSONL includes the full deal, both hands before every move, actions, draws, scores, and final state. + - Local production output: `data/human-play/game-records.jsonl` (was `runs/tmp/`, which is gitignored and documented as disposable). +- [ ] Support game-record IDs when LAN HTTP does not expose `crypto.randomUUID`. + - Uses `crypto.getRandomValues` with a compatibility fallback; user acceptance pending. + +Verification: + +- `cd web && npm test` +- `cd web && npm run build` +- Visual acceptance is performed by the user; do not run browser automation. diff --git a/web/public/models/borealis.json b/web/public/models/borealis.json new file mode 100644 index 0000000..164eb06 --- /dev/null +++ b/web/public/models/borealis.json @@ -0,0 +1,15 @@ +{ + "format": "coolrl-lost-cities-match-onnx-v1", + "codename": "borealis", + "model_file": "borealis.onnx", + "model_size_bytes": 3327043, + "model_sha256": "13a25243de1c0c4f27dcac348b072cc6ea8ffd283dc033b877a37099fd809415", + "source_checkpoint": "runs/jax-ppo-match/2026-07-15_031529_match-scaled/latest", + "source_config": "match-selfplay.yaml", + "observation_size": 501, + "action_size": 96, + "hidden_size": 512, + "num_layers": 3, + "dtype": "float32", + "validation_max_abs_error": 9.1552734375e-05 +} diff --git a/web/public/models/borealis.onnx b/web/public/models/borealis.onnx new file mode 100644 index 0000000..d92077f Binary files /dev/null and b/web/public/models/borealis.onnx differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 5eeb31c..385b701 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,16 +10,35 @@ import { currentHandSorted, decodeAction, encodeAction, - legalActionMask, previewPlacement, - resetFromOrder, - step, } from "./game/engine"; -import { deckOrderFromSeed, normalizeSeed, randomSeed } from "./game/random"; -import { DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types"; +import { + N_ROUNDS, + matchFromOrders, + matchLegalActionMask, + matchScore, + matchStep, + type MatchState, +} from "./game/match"; +import { MODEL_CODENAME, MODEL_HASH } from "./model/policy"; +import { matchFromSeed, normalizeSeed, randomSeed } from "./game/random"; +import { parseSavedGame, SAVED_GAME_KEY, type SavedGame } from "./game/persistence"; +import { DRAW_DECK, N_CARDS, PLAY, type PlaceType } from "./game/types"; import { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy"; import { useCardMotion } from "./ui/useCardMotion"; +/** One deal, or the classic three-round match decided on the summed total. */ +export type Mode = 1 | 3; + +const MODE_KEY = "lost-cities.mode"; + +function loadMode(): Mode { + const requested = new URLSearchParams(window.location.search).get("rounds"); + if (requested === "3") return 3; + if (requested === "1") return 1; + return window.localStorage.getItem(MODE_KEY) === "3" ? 3 : 1; +} + interface Hint { action: number; text: string; @@ -36,8 +55,9 @@ const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null }; * makes a finished game reviewable ply by ply. */ interface Frame { - state: GameState; + state: MatchState; selection: Selection; + move?: { player: 0 | 1; action: number }; } // The hand row is centered, so the binding constraint is the score plaque @@ -66,15 +86,12 @@ function handCardStep(viewportWidth: number, count: number): number { return Math.max(minVisible, Math.min(relaxed, fitted)); } -/** A `?seed=` in the URL loads that exact deal, so a game can be shared or replayed. */ -function initialSeed(): string { - const fromUrl = new URLSearchParams(window.location.search).get("seed"); - const seed = fromUrl === null ? "" : normalizeSeed(fromUrl); - return seed === "" ? randomSeed() : seed; -} - -function openingFrame(seed: string): Frame { - return { state: resetFromOrder(deckOrderFromSeed(seed)), selection: EMPTY_SELECTION }; +/** A `?seed=` in the URL loads that exact deal, so a game can be shared or replayed. + * In match mode the seed fixes all three deals and the coin flips too, so the + * whole match is a pure function of it. */ +function openingFrame(seed: string, mode: Mode): Frame { + const { deckOrders, coinFlips } = matchFromSeed(seed); + return { state: matchFromOrders(deckOrders, coinFlips, mode), selection: EMPTY_SELECTION }; } /** Keep the address bar in sync so the current deal stays shareable. */ @@ -84,29 +101,66 @@ function publishSeed(seed: string): void { window.history.replaceState(null, "", url); } +function newGameId(): string { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + if (typeof crypto.getRandomValues === "function") { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + return `game-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function initialGame(): Pick { + const requested = new URLSearchParams(window.location.search).get("seed"); + const mode = loadMode(); + const saved = parseSavedGame(window.localStorage.getItem(SAVED_GAME_KEY)); + const requestedSeed = requested === null ? null : normalizeSeed(requested); + // A saved game only resumes into the mode it was played in. + if (saved && saved.mode === mode && (requestedSeed === null || requestedSeed === saved.seed)) { + return saved; + } + const seed = requestedSeed || randomSeed(); + return { seed, mode, frames: [openingFrame(seed, mode)], cursor: 0, resultOpen: true }; +} + function App() { - const [seed, setSeed] = useState(initialSeed); - const [frames, setFrames] = useState(() => [openingFrame(seed)]); - const [cursor, setCursor] = useState(0); + const [initial] = useState(initialGame); + const [seed, setSeed] = useState(initial.seed); + const [mode, setMode] = useState(initial.mode as Mode); + const [frames, setFrames] = useState(initial.frames); + const [cursor, setCursor] = useState(initial.cursor); const [seedDraft, setSeedDraft] = useState(""); - const [resultOpen, setResultOpen] = useState(true); + const [resultOpen, setResultOpen] = useState(initial.resultOpen); const [policy, setPolicy] = useState(null); - const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO"); + const [modelMessage, setModelMessage] = useState("LOADING BOREALIS"); const [thinking, setThinking] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const [hint, setHint] = useState(null); const [hintPending, setHintPending] = useState(false); const generation = useRef(0); + const gameId = useRef(newGameId()); + const startedAt = useRef(new Date().toISOString()); + const loggedGame = useRef(null); const { cardRef, deckRef, overlayRef, resetMotion } = useCardMotion(); - const { state, selection } = frames[cursor]; + const { state: match, selection } = frames[cursor]; + // The board on screen is the round in play; the match is what decides the game. + const state = match.round; const canUndo = cursor > 0; const canRedo = cursor < frames.length - 1; - const latestState = useRef(state); + const latestMatch = useRef(match); useEffect(() => { publishSeed(seed); }, [seed]); - useEffect(() => { latestState.current = state; }, [state]); + useEffect(() => { latestMatch.current = match; }, [match]); + useEffect(() => { window.localStorage.setItem(MODE_KEY, String(mode)); }, [mode]); + useEffect(() => { + const saved: SavedGame = { version: 2, seed, mode, frames, cursor, resultOpen }; + window.localStorage.setItem(SAVED_GAME_KEY, JSON.stringify(saved)); + }, [cursor, frames, mode, resultOpen, seed]); useEffect(() => { function onResize() { setViewportWidth(window.innerWidth); } @@ -116,7 +170,7 @@ function App() { const humanHand = useMemo(() => currentHandSorted(state, 0), [state]); const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]); - const legal = useMemo(() => legalActionMask(state), [state]); + const legal = useMemo(() => matchLegalActionMask(match), [match]); const selectedCard = selection.handSlot === null ? null : humanHand[selection.handSlot]; const selectedColor = selectedCard === null ? null : cardColor(selectedCard); const displayState = useMemo( @@ -125,14 +179,23 @@ function App() { : previewPlacement(state, selection.handSlot, selection.placeType), [selection, state], ); - const scores = useMemo(() => boardScore(displayState), [displayState]); + /** This round's board. */ + const roundScores = useMemo(() => boardScore(displayState), [displayState]); + /** Rounds already banked plus the board in play — the number that decides a match. */ + const scores = useMemo( + (): [number, number] => [ + match.carry[0] + roundScores[0], + match.carry[1] + roundScores[1], + ], + [match.carry, roundScores], + ); useEffect(() => { let cancelled = false; loadPolicy().then(({ policy: loaded, warning }) => { if (cancelled) return; setPolicy(loaded); - setModelMessage(warning ?? `${loaded.provider.toUpperCase()} · FINAL PPO`); + setModelMessage(warning ?? `${loaded.provider.toUpperCase()} · BOREALIS`); }); return () => { cancelled = true; }; }, []); @@ -149,24 +212,28 @@ function App() { const rivalSuspended = canRedo; useEffect(() => { - if (!policy || rivalSuspended || state.done || state.toMove !== 1) return; + if (!policy || rivalSuspended || match.done || state.toMove !== 1) return; const currentGeneration = generation.current; const timer = window.setTimeout(async () => { setThinking(true); try { let action: number; try { - action = await policy.action(state); + action = await policy.action(match); } catch (error) { console.error("AI action failed; falling back to heuristic policy", error); const fallback = fallbackHeuristicPolicy(); - action = await fallback.action(state); + action = await fallback.action(match); if (generation.current !== currentGeneration) return; setPolicy(fallback); setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)"); } if (generation.current !== currentGeneration) return; - pushFrame({ state: step(state, action), selection: EMPTY_SELECTION }); + pushFrame({ + state: matchStep(match, action), + selection: EMPTY_SELECTION, + move: { player: 1, action }, + }); } catch (error) { console.error("AI action failed even with heuristic fallback", error); setModelMessage("MODEL INFERENCE ERROR"); @@ -175,15 +242,19 @@ function App() { } }, 620); return () => window.clearTimeout(timer); - }, [policy, rivalSuspended, state]); + }, [policy, rivalSuspended, match, state.toMove]); /** Deal a game. Without a seed this rolls a fresh one; the same seed always * reproduces the same deal, so `restart(seed)` also replays the current one. */ - function restart(nextSeed: string = randomSeed()) { + function restart(nextSeed: string = randomSeed(), nextMode: Mode = mode) { generation.current += 1; resetMotion(); + gameId.current = newGameId(); + startedAt.current = new Date().toISOString(); + loggedGame.current = null; setSeed(nextSeed); - setFrames([openingFrame(nextSeed)]); + setMode(nextMode); + setFrames([openingFrame(nextSeed, nextMode)]); setCursor(0); setSeedDraft(""); setResultOpen(true); @@ -217,8 +288,8 @@ function App() { /** Ask the policy driving the rival what it would do in your seat. */ async function requestHint() { - if (!policy || state.done || state.toMove !== 0 || hintPending) return; - const position = state; + if (!policy || match.done || state.toMove !== 0 || hintPending) return; + const position = match; setHintPending(true); setMenuOpen(false); try { @@ -229,10 +300,10 @@ function App() { const [best] = ranked; // The position can move on while inference runs — a hint for a stale // position would point at the wrong hand slot. - if (best === undefined || latestState.current !== position) return; + if (best === undefined || latestMatch.current !== position) return; setHint({ action: best.action, - text: describeAction(position, best.action), + text: describeAction(position.round, best.action), probability: best.probability, }); } catch (error) { @@ -246,21 +317,21 @@ function App() { useEffect(() => { setHint(null); }, [cursor, frames]); function chooseCard(handSlot: number) { - if (state.toMove !== 0 || state.done) return; + if (state.toMove !== 0 || match.done) return; const next = selection.handSlot !== handSlot || selection.placeType !== null ? { handSlot, placeType: null } : EMPTY_SELECTION; - pushFrame({ state, selection: next }); + pushFrame({ state: match, selection: next }); } function choosePlace(placeType: PlaceType) { if (selection.handSlot === null) return; - pushFrame({ state, selection: { ...selection, placeType } }); + pushFrame({ state: match, selection: { ...selection, placeType } }); } function cancelPlace() { if (selection.handSlot === null || selection.placeType === null) return; - pushFrame({ state, selection: { ...selection, placeType: null } }); + pushFrame({ state: match, selection: { ...selection, placeType: null } }); } useEffect(() => { @@ -282,7 +353,11 @@ function App() { function commit(drawSource: number) { if (!legalDraw(drawSource) || selection.handSlot === null || selection.placeType === null) return; const action = encodeAction(selection.handSlot, selection.placeType, drawSource); - pushFrame({ state: step(state, action), selection: EMPTY_SELECTION }); + pushFrame({ + state: matchStep(match, action), + selection: EMPTY_SELECTION, + move: { player: 0, action }, + }); } // Cards actually drawn in the hand row (the selected card moves into the @@ -309,18 +384,83 @@ function App() { const hintConfidence = hint !== null && hint.probability !== null ? `${Math.round(hint.probability * 100)}%` : null; - const canHint = policy !== null && !state.done && state.toMove === 0; + const canHint = policy !== null && !match.done && state.toMove === 0; const outcome = scores[0] === scores[1] ? "DRAW" : scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS"; + + useEffect(() => { + if (!match.done || loggedGame.current === gameId.current) return; + loggedGame.current = gameId.current; + const moves = frames.flatMap((frame, index) => { + if (!frame.move || index === 0) return []; + const beforeMatch = frames[index - 1].state; + const afterMatch = frame.state; + const before = beforeMatch.round; + const after = afterMatch.round; + const decoded = decodeAction(frame.move.action); + const beforeHand = currentHandSorted(before, frame.move.player); + // A move that ends a round is followed by a fresh deal, so the drawn card + // has to be read from the hand it was drawn into, not from the next round's. + const rolledOver = afterMatch.roundIdx !== beforeMatch.roundIdx; + const afterHand = rolledOver ? beforeHand : currentHandSorted(after, frame.move.player); + return [{ + ply: before.stepCount + 1, + round: beforeMatch.roundIdx + 1, + player: frame.move.player === 0 ? "human" : "ai", + action: frame.move.action, + playedCard: beforeHand[decoded.handSlot], + placeType: decoded.placeType === PLAY ? "play" : "discard", + drawSource: decoded.drawSource === DRAW_DECK ? "deck" : `discard_${decoded.drawSource - 1}`, + drawnCard: rolledOver + ? null + : afterHand.find((card) => !beforeHand.includes(card)) ?? null, + handsBefore: { + human: currentHandSorted(before, 0), + ai: currentHandSorted(before, 1), + }, + scoresAfter: matchScore(afterMatch), + }]; + }); + const record = { + format: "lost-cities-web-game-v2", + gameId: gameId.current, + seed, + // Which model actually played. The old records stored the on-screen label, + // which stops identifying anything the moment there is a second model. + opponent: { codename: MODEL_CODENAME, hash: MODEL_HASH }, + mode, + startedAt: startedAt.current, + finishedAt: new Date().toISOString(), + deckOrders: match.deckOrders.slice(0, mode), + coinFlips: match.coinFlips.slice(0, mode), + moves, + finalScores: scores, + roundScores: match.carry, + outcome, + finalState: match, + policy: modelMessage, + }; + void fetch("./api/game-records", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(record), + keepalive: true, + }).then((response) => { + if (!response.ok) throw new Error(`game log HTTP ${response.status}`); + }).catch((error) => { + loggedGame.current = null; + console.warn("Game record was not saved", error); + }); + }, [frames, match, mode, modelMessage, outcome, scores, seed, state.done]); // While the rival is paused on its own turn (or on the final position) nothing // will happen until the move is redone or play is resumed — say so. On your own // turn the normal prompt still applies: you can simply play on from here. - const reviewingRival = rivalSuspended && (state.done || state.toMove === 1); + const reviewingRival = rivalSuspended && (match.done || state.toMove === 1); const status = reviewingRival ? `Reviewing ply ${state.stepCount} — redo, or play on from here` - : state.done + : match.done ? outcome : state.toMove === 1 ? thinking ? "The rival is thinking ···" : "The rival's turn" @@ -350,6 +490,24 @@ function App() {
+
+ + +
+ + A MATCH IS DECIDED ON THE SUMMED TOTAL · WHOEVER LEADS ON POINTS + OPENS THE NEXT ROUND +