Ship borealis to the browser, and add the classic three-round mode

The web client now plays borealis (data/models.json), trained on the three-round
match and taking the 501-dim match view rather than a bare round. Only the actor
trunk is exported -- the critic exists to grade moves in training and never plays,
so the graph physically cannot leak the opponent's hand or the deck, which beats
promising not to call it.

The TypeScript match layer and observation mirror match.py and match_obs.py. They
have to agree to the bit: a mismatch throws nowhere, the ONNX policy just consumes
a wrong vector and plays worse for reasons nobody can see. So the port is not
trusted -- generate_match_parity_fixture.py emits 282 positions from real JAX play
(mid-round, both seats, past a roll-over, with a live carry) and the TS output is
checked against them to float32 round-off.

Match mode is a menu toggle. A seed fixes all three deals and the coin flips, so a
match stays a pure function of it. One-deal mode is unchanged from the player's
side; borealis simply sees it as round one at a carry of zero, a position it has
seen a great many times.

Two bugs found by driving the built app in a browser, both silent:

- The result card totalled the round, not the match. It read "-11 : 3" while the
  match stood at -96 : 66 -- it would have named the wrong winner. It now headlines
  the match total and breaks the round out beneath it.
- Game records were being rejected. The client's schema went to v2 (it now records
  which model played; the old records stored the on-screen label, which stops
  identifying anything once there are two models) while serve_web_with_logs.py
  still only accepted v1, so every record would have 400'd into a console warning.
  v1 stays accepted -- the 111 existing games are altair.

npm test and tsc were green through both. Hence web/.claude/skills/verify, which
records the recipe and the selectors so the next session drives the app instead of
re-deriving how.

.gitignore excluded the new model, which would have shipped a 404: the deploy
builds straight from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
This commit is contained in:
2026-07-15 07:06:29 +09:00
co-authored by Claude Opus 4.8
parent 8dec6c3fe1
commit 1d3b29aadb
22 changed files with 1438 additions and 101 deletions
+7 -1
View File
@@ -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
+138
View File
@@ -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()
+97
View File
@@ -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()
+86
View File
@@ -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()
@@ -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
+68
View File
@@ -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.
+21
View File
@@ -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.
+15
View File
@@ -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
}
Binary file not shown.
+252 -64
View File
@@ -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<SavedGame, "seed" | "frames" | "cursor" | "resultOpen" | "mode"> {
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<string>(initialSeed);
const [frames, setFrames] = useState<Frame[]>(() => [openingFrame(seed)]);
const [cursor, setCursor] = useState(0);
const [initial] = useState(initialGame);
const [seed, setSeed] = useState(initial.seed);
const [mode, setMode] = useState<Mode>(initial.mode as Mode);
const [frames, setFrames] = useState<Frame[]>(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<Policy | null>(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<Hint | null>(null);
const [hintPending, setHintPending] = useState(false);
const generation = useRef(0);
const gameId = useRef(newGameId());
const startedAt = useRef(new Date().toISOString());
const loggedGame = useRef<string | null>(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() {
<div className="menu-popover">
<button onClick={() => restart()}>NEW GAME</button>
<button onClick={() => restart(seed)}>REPLAY THIS SEED</button>
<div className="menu-modes" role="group" aria-label="Game length">
<button
className={mode === 1 ? "is-active" : ""}
onClick={() => restart(randomSeed(), 1)}
>
ONE DEAL
</button>
<button
className={mode === 3 ? "is-active" : ""}
onClick={() => restart(randomSeed(), 3)}
>
MATCH · 3 ROUNDS
</button>
</div>
<span>
A MATCH IS DECIDED ON THE SUMMED TOTAL · WHOEVER LEADS ON POINTS
OPENS THE NEXT ROUND
</span>
<label className="menu-seed">
<span>DEAL SEED · {seed}</span>
<input
@@ -367,6 +525,24 @@ function App() {
)}
</div>
{mode === N_ROUNDS && (
<div className="round-strip" aria-label={`Round ${match.roundIdx + 1} of ${N_ROUNDS}`}>
{Array.from({ length: N_ROUNDS }, (_, round) => (
<span
key={round}
className={
round < match.roundIdx ? "is-done" : round === match.roundIdx ? "is-live" : ""
}
>
R{round + 1}
</span>
))}
<em>
{match.carry[0]} : {match.carry[1]} BANKED
</em>
</div>
)}
<div className="table-center">
<Board
state={displayState}
@@ -398,14 +574,6 @@ function App() {
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""} ${reviewingRival ? "turn-prompt--review" : ""}`}>
{hint ? `Hint — ${hint.text}${hintConfidence ? ` (${hintConfidence})` : ""}` : status}
</p>
<button
type="button"
className={`hint-button ${hint ? "is-active" : ""}`}
onClick={requestHint}
disabled={!canHint || hintPending}
>
{hintPending ? "THINKING…" : "HINT"}
</button>
</div>
<section className="human-hand" aria-label="Your hand">
@@ -415,7 +583,7 @@ function App() {
key={card}
selected={selection.handSlot === slot}
hinted={hintedCard === card}
disabled={state.toMove !== 0 || state.done}
disabled={state.toMove !== 0 || match.done}
onClick={() => chooseCard(slot)}
style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }}
innerRef={cardRef(card)}
@@ -424,24 +592,41 @@ function App() {
</section>
<section className="score-plaque score-plaque--human" aria-label={`Your score ${scores[0]}`}>
<div><strong>YOU <i /></strong><small>{state.toMove === 0 && !state.done ? "YOUR TURN" : "EXPEDITION LEAD"}</small></div><b>{scores[0]}</b>
<div><strong>YOU <i /></strong><small>{state.toMove === 0 && !match.done ? "YOUR TURN" : "EXPEDITION LEAD"}</small></div><b>{scores[0]}</b>
</section>
<div className="history-bar">
<div className="control-stack">
<button
type="button"
className={`hint-button ${hint ? "is-active" : ""}`}
onClick={requestHint}
disabled={!canHint || hintPending}
>
{hintPending ? "THINKING…" : "HINT"}
</button>
<button type="button" onClick={undo} disabled={!canUndo} aria-label="Undo one action" title="Undo (←)">
<span>UNDO</span>
</button>
<button type="button" onClick={redo} disabled={!canRedo} aria-label="Redo one action" title="Redo (→)">
<span>REDO</span>
</button>
{rivalSuspended && (
<button type="button" className="history-bar__resume" onClick={resumeFromHere}>
PLAY FROM HERE
</button>
)}
{state.done && !resultOpen && (
<button type="button" onClick={() => setResultOpen(true)}>SCORE</button>
)}
<button
type="button"
className="control-stack__resume"
onClick={resumeFromHere}
disabled={!rivalSuspended}
>
PLAY FROM HERE
</button>
<button
type="button"
className={`control-stack__score ${state.done && !resultOpen ? "is-available" : ""}`}
onClick={() => setResultOpen(true)}
disabled={!state.done || resultOpen}
aria-hidden={!state.done || resultOpen}
>
SCORE
</button>
</div>
{/* Cards in flight back to the deck (undo of a draw) are re-parented here
@@ -453,6 +638,9 @@ function App() {
state={state}
outcome={outcome}
seed={seed}
carry={match.carry}
roundIdx={match.roundIdx}
totalRounds={mode}
onReview={() => setResultOpen(false)}
onPlayAgain={() => restart()}
/>
+40 -4
View File
@@ -6,6 +6,15 @@ interface ResultCardProps {
state: GameState;
outcome: string;
seed: string;
/**
* Rounds already banked, when this ends a match. The table below breaks down the
* board in front of you -- the final round -- but a match is decided on the sum,
* so the headline and the total have to carry the earlier rounds or they name
* the wrong winner.
*/
carry?: [number, number];
roundIdx?: number;
totalRounds?: number;
onReview: () => void;
onPlayAgain: () => void;
}
@@ -14,17 +23,35 @@ function signed(value: number): string {
return value > 0 ? `+${value}` : String(value);
}
export function ResultCard({ state, outcome, seed, onReview, onPlayAgain }: ResultCardProps) {
export function ResultCard({
state,
outcome,
seed,
carry = [0, 0],
roundIdx = 0,
totalRounds = 1,
onReview,
onPlayAgain,
}: ResultCardProps) {
const you = scoreBreakdown(state, 0);
const rival = scoreBreakdown(state, 1);
const isMatch = totalRounds > 1;
const matchTotals: [number, number] = [carry[0] + you.total, carry[1] + rival.total];
return (
<div className="result-overlay">
<section className="result-card" aria-label="Final score">
<header className="result-card__head">
<p>ROUND COMPLETE</p>
<p>{isMatch ? `MATCH COMPLETE · ${totalRounds} ROUNDS` : "ROUND COMPLETE"}</p>
<h1>{outcome}</h1>
<strong>{you.total} <i>:</i> {rival.total}</strong>
<strong>{matchTotals[0]} <i>:</i> {matchTotals[1]}</strong>
{isMatch && (
<small className="result-card__banked">
ROUND {roundIdx + 1} · {signed(you.total)} : {signed(rival.total)}
{" · BANKED "}
{signed(carry[0])} : {signed(carry[1])}
</small>
)}
</header>
<table className="result-table">
@@ -69,12 +96,21 @@ export function ResultCard({ state, outcome, seed, onReview, onPlayAgain }: Resu
</tbody>
<tfoot>
<tr>
<th scope="row">TOTAL</th>
<th scope="row">{isMatch ? `ROUND ${roundIdx + 1}` : "TOTAL"}</th>
<td className="result-table__detail" />
<td>{signed(you.total)}</td>
<td className="result-table__detail" />
<td>{signed(rival.total)}</td>
</tr>
{isMatch && (
<tr className="result-table__match">
<th scope="row">MATCH TOTAL</th>
<td className="result-table__detail" />
<td>{signed(matchTotals[0])}</td>
<td className="result-table__detail" />
<td>{signed(matchTotals[1])}</td>
</tr>
)}
</tfoot>
</table>
+6 -2
View File
@@ -32,7 +32,11 @@ export function shuffledDeck(random: () => number = Math.random): number[] {
return deck;
}
export function resetFromOrder(deckOrder: number[]): GameState {
/**
* `firstPlayer` moves first. Rounds two and three of a classic match are led by
* whoever is ahead on points, so the match layer sets this per round.
*/
export function resetFromOrder(deckOrder: number[], firstPlayer: 0 | 1 | number = 0): GameState {
if (deckOrder.length !== N_CARDS || new Set(deckOrder).size !== N_CARDS) {
throw new Error("deckOrder must be a permutation of 0..59");
}
@@ -48,7 +52,7 @@ export function resetFromOrder(deckOrder: number[]): GameState {
colHandshakes: emptyMatrix(2, N_COLORS),
colLength: emptyMatrix(2, N_COLORS),
piles: Array.from({ length: N_COLORS }, () => []),
toMove: 0,
toMove: (firstPlayer === 1 ? 1 : 0) as 0 | 1,
stepCount: 0,
done: false,
};
File diff suppressed because one or more lines are too long
+116
View File
@@ -0,0 +1,116 @@
import { boardScore, legalActionMask, resetFromOrder, step } from "./engine";
import type { GameState } from "./types";
export const N_ROUNDS = 3;
/**
* Classic Lost Cities: three rounds, scores summed, highest total wins.
*
* The single-round engine is left alone -- it is the rules oracle the Python
* engine is differential-tested against. Only two rules live up here, both from
* the Kosmos rulebook:
*
* "If after three games you have the highest overall score, you win."
* "The player who has more points begins" the next game -- not alternating.
*
* The rulebook says nothing about an exact tie, so the starter falls back to a
* coin flip, drawn up front with the deals. This mirrors src/lost_cities_jax/match.py;
* the two are checked against each other in match.test.ts.
*/
export interface MatchState {
round: GameState;
/** Every deal of the match, shuffled up front. */
deckOrders: number[][];
/** Tie-breaking starters, used only when the scores are level. */
coinFlips: number[];
roundIdx: number;
/** Points banked by each player in the rounds already finished. */
carry: [number, number];
/**
* 1 for a one-off deal, 3 for the classic match.
*
* The observation always reports the round index out of three regardless --
* that is the space the policy was trained on, and a one-off deal is simply its
* round one, played at a carry of zero.
*/
totalRounds: number;
done: boolean;
}
/**
* Whoever has banked more points leads; level scores fall back to the coin.
*
* Round one needs no special case: carry is (0, 0) there, so the tie branch
* already picks the coin flip, which is exactly the rulebook's arbitrary
* "oldest player begins".
*/
export function startingPlayer(
carry: readonly [number, number],
roundIdx: number,
coinFlips: readonly number[],
): number {
const lead = carry[0] - carry[1];
if (lead > 0) return 0;
if (lead < 0) return 1;
return coinFlips[roundIdx];
}
export function matchFromOrders(
deckOrders: number[][],
coinFlips: number[],
totalRounds: number = N_ROUNDS,
): MatchState {
const carry: [number, number] = [0, 0];
return {
round: resetFromOrder(deckOrders[0], startingPlayer(carry, 0, coinFlips)),
deckOrders,
coinFlips,
roundIdx: 0,
carry,
totalRounds,
done: false,
};
}
/** Running totals: rounds already banked plus the board in play. */
export function matchScore(state: MatchState): [number, number] {
const board = boardScore(state.round);
return [state.carry[0] + board[0], state.carry[1] + board[1]];
}
export function matchLegalActionMask(state: MatchState): boolean[] {
const mask = legalActionMask(state.round);
return state.done ? mask.map(() => false) : mask;
}
/**
* Play one ply. Rolls into the next round when the deck runs out.
*
* Rounds one and two pay nothing -- they only bank into `carry`. Only the sum
* decides the match.
*/
export function matchStep(state: MatchState, action: number): MatchState {
const played = step(state.round, action);
if (!played.done || state.done) {
return { ...state, round: played };
}
const board = boardScore(played);
const banked: [number, number] = [state.carry[0] + board[0], state.carry[1] + board[1]];
if (state.roundIdx >= state.totalRounds - 1) {
return { ...state, round: played, done: true };
}
const roundIdx = state.roundIdx + 1;
return {
...state,
round: resetFromOrder(
state.deckOrders[roundIdx],
startingPlayer(banked, roundIdx, state.coinFlips),
),
roundIdx,
carry: banked,
done: false,
};
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import fixture from "./match-parity-fixture.json";
import {
N_ROUNDS,
matchFromOrders,
matchLegalActionMask,
matchScore,
matchStep,
startingPlayer,
type MatchState,
} from "./match";
import { MATCH_OBS_DIM, matchObservation } from "./matchObservation";
import type { GameState } from "./types";
interface FixtureRow {
match: {
round: GameState;
deckOrders: number[][];
coinFlips: number[];
roundIdx: number;
carry: number[];
done: boolean;
};
player: number;
observation: number[];
}
const rows = fixture.rows as unknown as FixtureRow[];
function toMatch(row: FixtureRow): MatchState {
return {
round: row.match.round,
deckOrders: row.match.deckOrders,
coinFlips: row.match.coinFlips,
roundIdx: row.match.roundIdx,
carry: [row.match.carry[0], row.match.carry[1]],
// The fixture is generated from the JAX match, which is always three rounds.
totalRounds: N_ROUNDS,
done: row.match.done,
};
}
function firstLegal(match: MatchState): number {
const index = matchLegalActionMask(match).findIndex(Boolean);
if (index < 0) throw new Error("no legal action");
return index;
}
describe("match observation parity with JAX", () => {
it("has the dimension the exported model expects", () => {
expect(MATCH_OBS_DIM).toBe(fixture.obsDim);
expect(MATCH_OBS_DIM).toBe(501);
});
it("reproduces every fixture observation", () => {
expect(rows.length).toBeGreaterThan(100);
let worst = 0;
let worstAt = "";
for (const [index, row] of rows.entries()) {
const actual = matchObservation(toMatch(row), row.player);
expect(actual.length).toBe(row.observation.length);
for (let i = 0; i < actual.length; i += 1) {
const delta = Math.abs(actual[i] - row.observation[i]);
if (delta > worst) {
worst = delta;
worstAt = `row ${index}, feature ${i}`;
}
}
}
// A mismatch here throws nowhere: the ONNX policy consumes the wrong vector
// and plays worse for reasons nobody can see. So the bar is float32 round-off,
// not "close enough".
expect(worst, `largest disagreement at ${worstAt}`).toBeLessThan(1e-5);
});
it("covers positions past a round roll-over, with a real carry", () => {
expect(rows.some((row) => row.match.roundIdx > 0)).toBe(true);
expect(rows.some((row) => row.match.carry[0] !== row.match.carry[1])).toBe(true);
});
});
describe("match rules", () => {
it("plays exactly three rounds and then ends", () => {
let match = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips);
const seen = new Set<number>();
for (let ply = 0; ply < 1400 && !match.done; ply += 1) {
seen.add(match.roundIdx);
match = matchStep(match, firstLegal(match));
}
expect(match.done).toBe(true);
expect(seen).toEqual(new Set([0, 1, 2]));
expect(match.roundIdx).toBe(N_ROUNDS - 1);
});
it("lets whoever has more points begin, and flips a coin when level", () => {
expect(startingPlayer([60, 10], 1, [1, 1, 1])).toBe(0); // ahead leads, coin ignored
expect(startingPlayer([10, 60], 1, [0, 0, 0])).toBe(1);
expect(startingPlayer([30, 30], 1, [0, 0, 0])).toBe(0); // level falls back to the coin
expect(startingPlayer([30, 30], 1, [1, 1, 1])).toBe(1);
});
it("keeps the running total continuous across a round boundary", () => {
let match = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips);
for (let ply = 0; ply < 1400 && !match.done; ply += 1) {
const before = match.roundIdx;
const next = matchStep(match, firstLegal(match));
if (next.roundIdx !== before) {
// The finished round folds into carry and the fresh board is empty, so
// the roll-over itself moves nothing.
expect(matchScore(next)).toEqual([next.carry[0], next.carry[1]]);
}
match = next;
}
expect(match.done).toBe(true);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { cardColor } from "./cards";
import { boardScore } from "./engine";
import { N_ROUNDS, type MatchState } from "./match";
import { observation } from "./observation";
import {
CARDS_PER_COLOR,
LOC_DECK,
LOC_DISCARD,
LOC_P0_HAND,
N_CARDS,
N_COLORS,
OBS_DIM,
} from "./types";
/**
* Player-view observation for a three-round match: the mirror of
* src/lost_cities_jax/match_obs.py.
*
* These two must agree to the bit. A mismatch does not throw -- the model happily
* consumes a wrong vector and plays worse for reasons nobody can see. That is why
* matchObservation.test.ts checks this against fixtures generated from the Python
* side rather than trusting the port.
*
* On top of the single-round observation this adds the four things a match policy
* cannot play without: carry (scalar *and* binned, because round three is a
* threshold problem); which round it is; whose turn it is; and the deck clock,
* since a round ends on the last deck draw and players bend that parity by drawing
* from discard piles. Live points per colour are split by hand / discard pile /
* unseen, because a discard pile is public and recoverable.
*/
/** Packed tightly around zero: that is where the round-three decision flips. */
const CARRY_BIN_EDGES = [-60, -30, -12, -1, 1, 12, 30, 60];
export const N_CARRY_BINS = CARRY_BIN_EDGES.length + 1;
/** A typical round margin, not the theoretical 780 maximum the old obs divided by. */
const CARRY_SCALE = 75;
/** 2+3+...+10, the most one expedition can be worth before multipliers. */
const MAX_COLOR_POINTS = 54;
const N_MATCH_SCALARS =
1 + N_CARRY_BINS + N_ROUNDS + 1 + 1 + 1 + 1 + 2 * N_COLORS * 3;
export const MATCH_OBS_DIM = OBS_DIM + N_MATCH_SCALARS;
function cardRank(card: number): number {
const slot = card % CARDS_PER_COLOR;
return slot >= 3 ? slot - 1 : 0;
}
/** numpy.digitize: the count of edges strictly below `value`. */
function digitize(value: number, edges: readonly number[]): number {
let index = 0;
while (index < edges.length && value >= edges[index]) index += 1;
return index;
}
/**
* Points still reachable for `subject`, split by where the card sits:
* [in hand, in a discard pile, unseen] per colour.
*
* Seen through `viewer`'s eyes -- a card in the opponent's hand only counts as "in
* hand" if it is public, otherwise it is unseen.
*/
function livePoints(state: MatchState, viewer: number, subject: number): number[] {
const round = state.round;
const isMine = subject === viewer;
const subjectHandLoc = LOC_P0_HAND + subject;
const out = Array.from({ length: N_COLORS }, () => [0, 0, 0]);
for (let card = 0; card < N_CARDS; card += 1) {
const color = cardColor(card);
const rank = cardRank(card);
// An ascending column can only take cards above its current top.
if (rank <= round.colTop[subject][color]) continue;
const loc = round.cardLoc[card];
const inSubjectHand = loc === subjectHandLoc;
if (inSubjectHand && (isMine || round.handPublic[card])) {
out[color][0] += rank;
} else if (loc === LOC_DISCARD) {
out[color][1] += rank;
} else if (loc === LOC_DECK || (inSubjectHand && !isMine && !round.handPublic[card])) {
// Cards in the *other* player's hidden hand are unseen to the viewer too,
// but the subject cannot reach them, so they are deliberately excluded.
out[color][2] += rank;
}
}
return out.flat().map((points) => points / MAX_COLOR_POINTS);
}
export function matchObservation(state: MatchState, player: number): Float32Array {
const opponent = 1 - player;
const round = state.round;
const base = observation(round, player);
const board = boardScore(round);
const lead =
state.carry[player] + board[player] - (state.carry[opponent] + board[opponent]);
const carryScaled = Math.min(2, Math.max(-2, lead / CARRY_SCALE));
const carryBins = Array.from({ length: N_CARRY_BINS }, (_, i) =>
Number(i === digitize(lead, CARRY_BIN_EDGES)),
);
const roundOneHot = Array.from({ length: N_ROUNDS }, (_, i) => Number(i === state.roundIdx));
const roundsLeft = (N_ROUNDS - 1 - state.roundIdx) / (N_ROUNDS - 1);
const toMove = round.toMove;
const myTurn = Number(toMove === player);
// toMove flips every ply, so the round's opener is recoverable from parity.
const roundOpener = toMove ^ (round.stepCount & 1);
const iOpened = Number(roundOpener === player);
// If both players drew from the deck from here, the last deck card falls to
// whoever is on move after `remaining - 1` more plies.
const remaining = Math.max(N_CARDS - round.drawPtr, 1);
const lastDrawer = toMove ^ ((remaining - 1) & 1);
const iTakeLast = Number(lastDrawer === player);
const extra = [
carryScaled,
...carryBins,
...roundOneHot,
roundsLeft,
myTurn,
iOpened,
iTakeLast,
...livePoints(state, player, player),
...livePoints(state, player, opponent),
];
if (extra.length !== N_MATCH_SCALARS) {
throw new Error(`match scalars ${extra.length} != ${N_MATCH_SCALARS}`);
}
const out = new Float32Array(MATCH_OBS_DIM);
out.set(base, 0);
out.set(Float32Array.from(extra), OBS_DIM);
return out;
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { matchFromOrders } from "./match";
import { matchFromSeed } from "./random";
import { parseSavedGame } from "./persistence";
const { deckOrders, coinFlips } = matchFromSeed("abc");
const match = matchFromOrders(deckOrders, coinFlips, 3);
function savedGame(overrides: Record<string, unknown> = {}) {
return {
version: 2,
seed: "abc",
mode: 3,
frames: [{ state: match, selection: { handSlot: null, placeType: null } }],
cursor: 0,
resultOpen: true,
...overrides,
};
}
describe("saved game parsing", () => {
it("accepts a complete saved timeline", () => {
const saved = savedGame();
expect(parseSavedGame(JSON.stringify(saved))).toEqual(saved);
});
it("accepts a one-deal game", () => {
const saved = savedGame({
mode: 1,
frames: [
{ state: matchFromOrders(deckOrders, coinFlips, 1), selection: { handSlot: null, placeType: null } },
],
});
expect(parseSavedGame(JSON.stringify(saved))).toEqual(saved);
});
it("rejects corrupt and incompatible data", () => {
expect(parseSavedGame("not json")).toBeNull();
expect(parseSavedGame(JSON.stringify({ version: 2 }))).toBeNull();
expect(parseSavedGame(JSON.stringify(savedGame({ frames: [] })))).toBeNull();
expect(parseSavedGame(JSON.stringify(savedGame({ mode: 2 })))).toBeNull();
});
it("refuses a v1 save rather than guessing what it meant", () => {
// A v1 save is one round: no carry, no deals for rounds two and three, no
// coin flips. There is nothing honest to migrate it into, so it is dropped
// and a fresh game is dealt.
const v1 = { version: 1, seed: "abc", frames: [{ state: match.round, selection: {} }], cursor: 0, resultOpen: true };
expect(parseSavedGame(JSON.stringify(v1))).toBeNull();
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { MatchState } from "./match";
import { N_CARDS, type GameState, type PlaceType } from "./types";
/**
* v2 because a saved game now holds a match, not a round. The key is bumped
* rather than migrated: a v1 save has no carry, no deals for rounds two and
* three, and no coin flips, so there is nothing honest to migrate it into. An
* unreadable save just deals a fresh game, which is the right failure.
*/
export const SAVED_GAME_KEY = "lost-cities-jax-ppo.game.v2";
export interface SavedFrame {
state: MatchState;
selection: { handSlot: number | null; placeType: PlaceType | null };
}
export interface SavedGame {
version: 2;
seed: string;
/** 1 for a one-off deal, 3 for the classic match. */
mode: number;
frames: SavedFrame[];
cursor: number;
resultOpen: boolean;
}
function isNumberArray(value: unknown, length?: number): value is number[] {
return Array.isArray(value) && (length === undefined || value.length === length) &&
value.every((item) => typeof item === "number" && Number.isFinite(item));
}
function isBooleanArray(value: unknown, length: number): value is boolean[] {
return Array.isArray(value) && value.length === length &&
value.every((item) => typeof item === "boolean");
}
function isMatrix(value: unknown, rows: number, columns: number): value is number[][] {
return Array.isArray(value) && value.length === rows &&
value.every((row) => isNumberArray(row, columns));
}
function isDeckOrder(value: unknown): value is number[] {
return isNumberArray(value, N_CARDS) && new Set(value).size === N_CARDS;
}
function isGameState(value: unknown): value is GameState {
if (typeof value !== "object" || value === null) return false;
const state = value as Partial<GameState>;
return isDeckOrder(state.deckOrder) &&
isNumberArray(state.cardLoc, N_CARDS) && isBooleanArray(state.handPublic, N_CARDS) &&
isMatrix(state.colTop, 2, 5) && isMatrix(state.colHandshakes, 2, 5) &&
isMatrix(state.colLength, 2, 5) && Array.isArray(state.piles) && state.piles.length === 5 &&
state.piles.every((pile) => isNumberArray(pile)) &&
typeof state.drawPtr === "number" && typeof state.stepCount === "number" &&
(state.toMove === 0 || state.toMove === 1) && typeof state.done === "boolean";
}
function isMatchState(value: unknown): value is MatchState {
if (typeof value !== "object" || value === null) return false;
const match = value as Partial<MatchState>;
return isGameState(match.round) &&
Array.isArray(match.deckOrders) && match.deckOrders.length === 3 &&
match.deckOrders.every(isDeckOrder) &&
isNumberArray(match.coinFlips, 3) &&
Number.isInteger(match.roundIdx) && match.roundIdx! >= 0 && match.roundIdx! < 3 &&
isNumberArray(match.carry, 2) &&
(match.totalRounds === 1 || match.totalRounds === 3) &&
typeof match.done === "boolean";
}
function isSavedGame(value: unknown): value is SavedGame {
if (typeof value !== "object" || value === null) return false;
const saved = value as Partial<SavedGame>;
if (saved.version !== 2 || typeof saved.seed !== "string" ||
(saved.mode !== 1 && saved.mode !== 3) ||
!Array.isArray(saved.frames) || saved.frames.length === 0 ||
!Number.isInteger(saved.cursor) || saved.cursor! < 0 || saved.cursor! >= saved.frames.length ||
typeof saved.resultOpen !== "boolean") return false;
return saved.frames.every((frame) => {
if (typeof frame !== "object" || frame === null || !isMatchState(frame.state)) return false;
const selection = frame.selection;
return typeof selection === "object" && selection !== null &&
(selection.handSlot === null || Number.isInteger(selection.handSlot)) &&
(selection.placeType === null || selection.placeType === 0 || selection.placeType === 1);
});
}
export function parseSavedGame(raw: string | null): SavedGame | null {
if (raw === null) return null;
try {
const value: unknown = JSON.parse(raw);
return isSavedGame(value) ? value : null;
} catch {
return null;
}
}
+15
View File
@@ -25,6 +25,21 @@ export function deckOrderFromSeed(seed: string): number[] {
return shuffledDeck(mulberry32(hashSeed(seed)));
}
/**
* Every deal and coin flip of a match, drawn up front from one seed.
*
* Drawing them all now — rather than shuffling again when a round rolls over —
* is what keeps a seed reproducible: the whole match is a pure function of it.
* The first deal is `deckOrderFromSeed`, so a match and a one-off deal on the
* same seed open on the same position.
*/
export function matchFromSeed(seed: string): { deckOrders: number[][]; coinFlips: number[] } {
const random = mulberry32(hashSeed(seed));
const deckOrders = [shuffledDeck(random), shuffledDeck(random), shuffledDeck(random)];
const coinFlips = [0, 1, 2].map(() => (random() < 0.5 ? 0 : 1));
return { deckOrders, coinFlips };
}
/** A fresh shareable seed, e.g. "k3f9qa". */
export function randomSeed(): string {
return Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, "0");
+27 -14
View File
@@ -1,13 +1,25 @@
import * as ort from "onnxruntime-web/all";
import { currentHandSorted, legalActionMask } from "../game/engine";
import { observation } from "../game/observation";
import { DISCARD, DRAW_DECK, PLAY, type GameState } from "../game/types";
import { currentHandSorted } from "../game/engine";
import { matchLegalActionMask, type MatchState } from "../game/match";
import { matchObservation } from "../game/matchObservation";
import { DISCARD, DRAW_DECK, PLAY } from "../game/types";
import { cardColor, cardRank, isHandshake } from "../game/cards";
export type ExecutionProvider = "webgpu" | "wasm" | "heuristic";
const MODEL_URL = `${import.meta.env.BASE_URL}models/jax-ppo.onnx`;
/**
* borealis -- see data/models.json. Trained on the three-round match, so it takes
* the match view (carry, round, deck clock) rather than a bare round. A one-off
* deal is simply its round one at a carry of zero, which is a position it has seen
* a great many times.
*/
const MODEL_URL = `${import.meta.env.BASE_URL}models/borealis.onnx`;
/** Identity of what actually plays. Records carry the hash; the codename is for
* humans and is assigned in data/models.json, not derived. */
export const MODEL_CODENAME = "borealis";
export const MODEL_HASH = "13a25243de1c";
/** A legal action with the policy's confidence in it, if the policy has one. */
export interface RankedAction {
@@ -18,16 +30,16 @@ export interface RankedAction {
export interface Policy {
readonly provider: ExecutionProvider;
/** Legal actions, best first. Drives both the rival's move and the hint. */
rank(state: GameState): Promise<RankedAction[]>;
action(state: GameState): Promise<number>;
rank(match: MatchState): Promise<RankedAction[]>;
action(match: MatchState): Promise<number>;
}
abstract class RankingPolicy implements Policy {
abstract readonly provider: ExecutionProvider;
abstract rank(state: GameState): Promise<RankedAction[]>;
abstract rank(match: MatchState): Promise<RankedAction[]>;
async action(state: GameState): Promise<number> {
const [best] = await this.rank(state);
async action(match: MatchState): Promise<number> {
const [best] = await this.rank(match);
if (best === undefined) throw new Error("state has no legal actions");
return best.action;
}
@@ -54,12 +66,12 @@ class OnnxPolicy extends RankingPolicy {
super();
}
async rank(state: GameState): Promise<RankedAction[]> {
const obs = observation(state, state.toMove);
async rank(match: MatchState): Promise<RankedAction[]> {
const obs = matchObservation(match, match.round.toMove);
const result = await this.session.run({ obs: new ort.Tensor("float32", obs, [1, obs.length]) });
const logits = result.logits?.data;
if (!logits) throw new Error("ONNX model did not return a logits output");
const legal = legalActionMask(state);
const legal = matchLegalActionMask(match);
return softmaxOverLegal(legal.map((_, action) => Number(logits[action])), legal);
}
}
@@ -67,8 +79,9 @@ class OnnxPolicy extends RankingPolicy {
export class HeuristicPolicy extends RankingPolicy {
readonly provider = "heuristic" as const;
async rank(state: GameState): Promise<RankedAction[]> {
const legal = legalActionMask(state);
async rank(match: MatchState): Promise<RankedAction[]> {
const state = match.round;
const legal = matchLegalActionMask(match);
const hand = currentHandSorted(state);
const ranked = legal.flatMap((isLegal, action) => {
if (!isLegal) return [];
+93 -15
View File
@@ -472,7 +472,7 @@ button.card:disabled { cursor: default; }
.deck-stack > span { margin-top: 5px; color: #7e817c; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; }
.deck-stack.is-draw-target .card { box-shadow: 0 0 0 3px var(--gold), 7px 8px 0 #171b1e, 0 0 32px rgba(201, 163, 75, 0.3); }
/* Insets keep the row clear of the history bar parked at the right. */
/* Insets keep the prompt clear of the controls parked at the right. */
.prompt-row {
position: absolute;
right: 330px;
@@ -498,7 +498,6 @@ button.card:disabled { cursor: default; }
.turn-prompt--review { color: #9fb4c8; }
.hint-button {
flex: 0 0 auto;
height: 32px;
padding: 0 14px;
border: 1px solid var(--gold-dim);
@@ -610,23 +609,25 @@ button.card:disabled { cursor: default; }
.result-card button:hover { border-color: var(--gold); }
.result-card button.is-primary { border-color: var(--gold); color: var(--gold); }
/* Sits in the band above the hand: the hand row is centered and its width is
computed against the score plaques, so a wide bar down at the hand's level
would overlap the leftmost/rightmost cards. */
.history-bar {
/* Player controls share the hand row's bottom edge and stay in the mirrored
right-side keep-out reserved by the centered hand layout. */
.control-stack {
position: absolute;
right: 24px;
bottom: 196px;
bottom: 30px;
z-index: 12;
display: flex;
align-items: center;
width: 142px;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.history-bar button {
.control-stack button {
height: 32px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--gold-dim);
border-radius: 9px;
@@ -634,15 +635,17 @@ button.card:disabled { cursor: default; }
font: 700 13px/1 Georgia, serif;
cursor: pointer;
}
.history-bar button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.history-bar button:hover:not(:disabled) { border-color: var(--gold); }
.history-bar button:disabled { opacity: 0.3; cursor: default; }
.history-bar__resume {
.control-stack button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.control-stack button:hover:not(:disabled) { border-color: var(--gold); }
.control-stack button:disabled { opacity: 0.3; cursor: default; }
.control-stack__resume {
border-color: var(--gold) !important;
color: var(--gold);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.14em;
}
.control-stack__score { visibility: hidden; }
.control-stack__score.is-available { visibility: visible; }
@media (max-width: 1250px) {
.table-center { width: 600px; }
@@ -651,7 +654,6 @@ button.card:disabled { cursor: default; }
.human-hand .card { width: 98px; height: 142px; margin-left: 7px; }
.human-hand { height: 146px; }
.prompt-row { bottom: 177px; }
.history-bar { bottom: 172px; }
.table-center { bottom: 210px; }
/* Narrow viewports: the plaques are the keep-out that squeezes the hand row,
so they become compact chips (model-name line dropped) to give the cards
@@ -675,7 +677,83 @@ button.card:disabled { cursor: default; }
.human-hand { bottom: 18px; height: 138px; }
.human-hand .card { width: 94px; height: 134px; }
.prompt-row { bottom: 165px; }
.history-bar { bottom: 160px; }
.control-stack { bottom: 18px; }
.score-plaque--human { bottom: 9px; }
.deck-stack { transform: scale(0.86); }
}
/* Match mode: which round is live, and what is already banked. The board only
ever shows the round in play, so without this the score plaques would be the
only hint that two more rounds are coming. */
.round-strip {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(18, 14, 10, 0.72);
border: 1px solid rgba(214, 188, 140, 0.22);
font-size: 11px;
letter-spacing: 0.12em;
color: rgba(214, 188, 140, 0.55);
z-index: 4;
pointer-events: none;
}
.round-strip span {
font-weight: 700;
}
.round-strip span.is-done {
color: rgba(214, 188, 140, 0.85);
}
.round-strip span.is-live {
color: #f0d9a6;
text-shadow: 0 0 10px rgba(240, 217, 166, 0.45);
}
.round-strip em {
font-style: normal;
margin-left: 4px;
padding-left: 10px;
border-left: 1px solid rgba(214, 188, 140, 0.22);
color: rgba(214, 188, 140, 0.75);
}
.menu-modes {
display: flex;
gap: 6px;
}
.menu-modes button {
flex: 1;
font-size: 10px;
}
.menu-modes button.is-active {
background: rgba(240, 217, 166, 0.16);
color: #f0d9a6;
border-color: rgba(240, 217, 166, 0.45);
}
/* A match's headline is the summed total; the table still breaks down the round
in front of you, so the two have to be told apart. */
.result-card__banked {
display: block;
margin-top: 6px;
font-size: 11px;
letter-spacing: 0.1em;
color: rgba(214, 188, 140, 0.55);
}
.result-table__match td,
.result-table__match th {
border-top: 1px solid rgba(214, 188, 140, 0.22);
color: #f0d9a6;
font-weight: 700;
}
+12 -1
View File
@@ -153,11 +153,22 @@ export function useCardMotion(): CardMotion {
continue;
}
// getBoundingClientRect includes a transform applied by WAAPI. A render
// while a deal/move is still in flight (for example when the policy
// finishes loading) must retain the intended destination instead of
// treating the animated visual position as a new layout and launching a
// second flight.
const previous = rects.current.get(card);
if (element.getAnimations().some((animation) => animation.playState === "running")) {
if (previous) next.set(card, previous);
continue;
}
const to = element.getBoundingClientRect();
next.set(card, to);
if (reduced) continue;
const from = rects.current.get(card);
const from = previous;
if (from) {
animateMove(element, from, to);
} else if (deckRect) {