10 Commits
Author SHA1 Message Date
coolguyandClaude Opus 4.8 7fc15ff46a Move the round strip out from under the opponent hand; let undo cross turns
Deploy web client / deploy (push) Blocked by required conditions
Deploy web client / build (push) Waiting to run
Two fixes to the last change:

- The round strip sat top-center, exactly where the opponent's hand is, so its
  cards covered it. Moved it under the rival plaque on the left, clear of the hand.

- Undo/redo was capped at the current turn. When the setting is on it now walks the
  whole timeline -- back through committed moves, the rival's replies, and earlier
  rounds. The rival stays suspended whenever there are moves ahead to redo, so
  reviewing never makes it replay the move being looked at. Menu label updated to
  "전체 되감기".

Verified in a browser: the strip no longer overlaps the opponent hand, and undo
from mid-game walks back eight steps across a rival turn to the opening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 18:18:47 +09:00
coolguyandClaude Opus 4.8 774faf2b51 Add the altair-vs-borealis measurement driver
The script behind runs/jax-ppo-match/altair_vs_borealis.json: duplicate,
seat-swapped play between the two models across single-deal and three-round
conditions, reporting win rate with a Wilson interval and mean margin. Kept so the
2x2 result is reproducible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 18:11:27 +09:00
coolguyandClaude Opus 4.8 c8fa89dab9 Pause between rounds, summarise the match, and localise to Korean
Three-round match is now the default. The bigger fix is that round transitions
were invisible: the engine advances rounds atomically, so a round-ending move
flashed straight to the next round's board and its scores were never shown.

The client now holds at each round end (matchStep sets roundComplete instead of
rolling over; matchAdvanceRound continues on a button). While held, the finished
board stays up and an overlay shows that round's per-expedition breakdown and the
running match total -- no legal move, the rival waits, and nothing advances until
you choose to. The last round ends the match rather than pausing.

The end panel now summarises the whole match in one table -- each round's total
and the sum -- built from a new roundHistory on the match state. A single deal
still shows its per-expedition breakdown as before.

All UI text is Korean now: 승리 / 패배 / 무승부, the prompts, the menu, the score
plaques, both score tables. Removed the explanatory noise that was cluttering the
menu (the "seed shuffles the deck" and "match is decided on the total" blurbs, the
policy/provider line). Verified end to end in a browser: default is three rounds,
each round end pauses on its colour scores, and the final panel shows the
three-round summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 18:11:19 +09:00
coolguyandClaude Opus 4.8 267db367d8 Remove hints; gate undo/redo behind a setting and limit it to your turn
Three changes to how much the client helps you:

- The hint feature is gone -- button, board highlights, the whole path that asked
  the policy for your best move. Playing against the model shouldn't come with the
  model telling you what to do.

- Undo/redo is off by default and enabled from a menu toggle. Taking moves back is
  a training aid, not how the game is played, so the honest game is the default.
  The switch is reactive: flipping it on mid-game shows the controls immediately,
  no restart.

- When on, undo/redo is limited to the current turn. The floor is the most recent
  committed move -- a rival move or your own draw -- so you can revise a card
  selection or placement before you draw, but you can't rewind into the rival's
  move or an earlier turn. Verified in a browser: undo is disabled at turn start,
  enabled after selecting a card, and disabled again once you've drawn and the
  rival has answered.

Board and Card lose their now-dead hint props and the hint CSS goes with them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 17:56:16 +09:00
coolguyandClaude Opus 4.8 5e177cc369 Stop drag-selecting the game board
The table is a control surface, but the browser treats it as a document, so
dragging across it painted card numbers and labels blue and popped a copy menu on
a mobile long-press. Selection is now off across .app-shell and turned back on
only for input/textarea -- in practice the seed field, the one place you would
actually want to select and copy text.

user-select touches neither click nor focus, so every button and card still
responds. Verified in a browser: dragging across the hand and the score plaque
selects nothing, while the seed field still selects and copies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 17:36:41 +09:00
coolguyandClaude Opus 4.8 3e7dda0d22 Make overlapped hand cards legible with a corner index
Both hands overlap, and the centered value is buried on every card but the
topmost one, so a tightly packed hand became unreadable. Cards overlap
left-over-right, so the one strip that stays visible is each card's left edge --
which is exactly where the top corner already sat. The markup had the corner index
all along; it just wasn't carrying its weight.

So the top-left corner gets a small ivory backing chip and a size bump, which lets
it read even when it falls over the card behind it -- the same reason a fanned
hand of playing cards stays readable. The rival's revealed cards had their corner
index hidden entirely (mini cards set it to display:none); it's re-enabled, scaled
to the 44px card, and their overlap eased from -17 to -14px to give it room.

Verified in a browser at 900/1180/1400px: an eight-card hand overlapping at 900px
now reads every card off its corner, and the rival's revealed cards read off
theirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 17:23:49 +09:00
coolguyandClaude Opus 4.8 138d7c858d Classic Lost Cities: three-round agent, and borealis in the browser
The trainer was learning a game nobody plays. Lost Cities is decided on three
rounds summed; the stack optimised a single deal. This branch builds the real
game, trains an agent on it, and ships it.

borealis beats altair -- the league policy that was on the web -- 0.6094 at the
real three-round game over 8192 duplicate matches (+22.0 points), at a matched
compute budget. It is also harder to farm: a from-scratch exploiter funded to the
same 131M reaches 0.6295 against altair and only 0.4657 against borealis.

What actually earned that, measured by ablation at a matched budget rather than
asserted:

  both-seat training   +17.1 points.  Self-play already played the opponent's
                       plies with the same network and the old trainer
                       stop_gradiented them away. Free doubling of the data.
  in-scan auto-reset   5.8x the learner actions per update at identical compute:
                       87% of every rollout was spent stepping already-done envs.
                       Exposed a latent zero-bootstrap in compute_gae.
  linear total reward  Beats tanh(total/scale) 0.5859. The two share an objective;
                       the linear one just pays it out every ply instead of once
                       per ~160, and risk attitude turns out to be worth under a
                       win-rate point in this game -- a policy told to gamble when
                       behind *loses* to a greedy clone.
  match observation    ~2.5 points. Mostly single-round defects: the observation
                       never said whose turn it was, and divided the score
                       difference by 780.
  privileged critic    +3.7 points, but only at scale -- ablated at 39.3M it
                       measured *negative*. An ablation run at a budget you do not
                       intend to ship can invert.

Also: rounds are independent (corr 0.004), so carry earns its place in the
observation through the start-player rule, not the objective.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 07:10:07 +09:00
coolguyandClaude Opus 4.8 1d3b29aadb 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
2026-07-15 07:06:29 +09:00
coolguyandClaude Opus 4.8 8dec6c3fe1 Keep the human game records, and name the models
The 111 games are the user's own strategy record, not ML data -- so they stay as
they are, and hint/undo usage deliberately goes unlogged. What they were missing
is which model they were played against: the v1 schema stores `policy: "WASM ·
FINAL PPO"`, which is the string shown in the corner of the screen, not an
identity. It stops meaning anything the moment there is a second final model,
which there now is.

data/models.json fixes that going forward. Codenames are astronomical and
alphabetical, so the first letter is the generation and you can always tell which
came later; a new letter means the observation space broke, not that the model got
better. A codename never encodes quality -- that is the trap "FINAL PPO" fell into.
The hash stays the truth, because a hash cannot lie about what actually played;
the codename is assigned here, not derived.

  altair    (e8241e305c01) -- single round, 454-dim, the league policy on the web.
                              All 111 v1 games were played against this.
  borealis  (4ae613b010ca) -- three-round match, 501-dim, 131M. Beats altair
                              0.6094 at the real game and holds up better under a
                              funded exploiter.

The records were also sitting in runs/tmp/, which is gitignored and documented as
disposable. They are in data/ now, and committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 06:25:29 +09:00
coolguyandClaude Opus 4.8 0fb6e3c1a6 Fund the exploiter properly: ours holds, league breaks
The earlier exploiters ran a quarter of their targets' budget. Rerun at a matched
~131M learner actions (measured: 130.4M and 131.1M):

  ours    exploiter reaches 0.4657 [0.455, 0.477], mean lead -5.5
  league  exploiter reaches 0.6295 [0.619, 0.640], mean lead +28.8

The CIs are nowhere near each other and the gap widened under funding, 0.094 ->
0.164. A qualitative line gets crossed too: a properly funded attacker trained
from scratch *beats* league outright, and still loses to ours.

Two things a reader has to carry with that, both of which cut against us:

- Neither exploiter plateaued -- both climb through the final update, so these
  remain lower bounds. And ours' attacker has the steeper tail (+0.031 vs +0.016
  per 100 updates) while league's curve is already bending, so a much larger
  attacker could narrow the gap. Cannot rule out that it closes.
- League is a single-round policy blind to carry and match score, and both frozen
  policies are played greedily, which is maximally exploitable by construction.
  So this is not a clean "training method A vs B".

The claim that survives is the matched-budget one, and only that one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 06:21:46 +09:00
30 changed files with 2143 additions and 346 deletions
+7 -1
View File
@@ -15,6 +15,9 @@ src/**/*.c
# Local session / lock files # Local session / lock files
.compute.lock .compute.lock
.claude/ .claude/
# ...except checked-in project skills, which are documentation for the next session.
!web/.claude/
!web/.claude/**
# Rust build output # Rust build output
target/ target/
@@ -29,10 +32,13 @@ tools/julia/
.ruff_cache .ruff_cache
# Web dependencies and locally exported models. The verified public browser # 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/node_modules/
web/*.tsbuildinfo web/*.tsbuildinfo
web/public/models/*.onnx web/public/models/*.onnx
web/public/models/*.json web/public/models/*.json
!web/public/models/jax-ppo.onnx !web/public/models/jax-ppo.onnx
!web/public/models/jax-ppo.json !web/public/models/jax-ppo.json
!web/public/models/borealis.onnx
!web/public/models/borealis.json
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
{
"_scheme": {
"naming": "Astronomical names, alphabetically ordered. The first letter is the generation; a new letter means the observation space broke, not that the model got better.",
"rule": "A codename never encodes quality. The record this replaces stored 'FINAL PPO', which stops meaning anything the moment there is a second final model.",
"identity": "The hash is the truth -- it is what actually played. The codename is for humans, and it is assigned here, not derived. Records should carry the hash; look the codename up.",
"next": "cygnus, deneb, ..."
},
"models": {
"altair": {
"hash": "e8241e305c01",
"hash_kind": "sha256 of web/public/models/jax-ppo.onnx",
"generation": "a",
"game": "single round",
"observation_size": 454,
"hidden_size": 512,
"num_layers": 3,
"trained": "self-play league, 122.6M learner actions",
"source": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/latest",
"deployed": "web/public/models/jax-ppo.onnx",
"displayed_as": "WASM · FINAL PPO",
"note": "Every game in data/human-play/game-records.jsonl (format v1, 111 games, 2026-07-14) was played against this model. The v1 schema has no model field -- it stores the on-screen label -- so this line is the record of what they played."
},
"borealis": {
"hash": "4ae613b010ca",
"hash_kind": "sha256 over the orbax checkpoint files (no ONNX export yet)",
"generation": "b",
"game": "three-round match (classic rules)",
"observation_size": 501,
"critic_observation_size": 681,
"hidden_size": 512,
"num_layers": 3,
"trained": "self-play, 131.1M learner actions, linear total-score reward, both seats, privileged critic",
"source": "runs/jax-ppo-match/2026-07-15_031529_match-scaled/latest",
"deployed": null,
"results": {
"vs_altair_3round": "0.6094 win rate [0.599, 0.620], +22.0 points, 8192 duplicate matches",
"exploitability": "a from-scratch exploiter funded to 131M reaches 0.4657 against it, and 0.6295 against altair -- lower bound, neither exploiter had plateaued"
}
}
}
}
+34
View File
@@ -581,3 +581,37 @@ Fable은 이것을 "가장 큰 누락 아이디어"로 꼽았다. 실측은 **
**흥미로운 점:** 우리는 **순수 셀프플레이**이고 league는 **착취자 구조를 학습에 넣은** 런인데, **흥미로운 점:** 우리는 **순수 셀프플레이**이고 league는 **착취자 구조를 학습에 넣은** 런인데,
그런데도 우리가 덜 착취당한다. 착취자 구조가 exploitability를 낮춰줄 것이라는 기대가 그런데도 우리가 덜 착취당한다. 착취자 구조가 exploitability를 낮춰줄 것이라는 기대가
이 게임에서는 확인되지 않았다. 이 게임에서는 확인되지 않았다.
### 착취자에 제대로 자금을 대고 재측정 (131M, 목표와 동일 예산)
앞선 32.5M 측정은 **공격자가 목표보다 4배 부족**했다. 예산을 목표와 맞춰 다시 쟀다.
실측 확인: 착취자 learner 액션 **130.4M** (우리 상대) / **131.1M** (league 상대).
| 얼려놓은 정책 | 착취자 승률 | 95% CI | 착취자 평균 마진 | 이전(32.5M) |
|---|---|---|---|---|
| **우리 매치 스택** (131.1M 학습) | **0.4657** | [0.455, 0.477] | **5.5점** | 0.2278 |
| league (122.6M 학습) | **0.6295** | [0.619, 0.640] | **+28.8점** | 0.3213 |
**CI가 전혀 겹치지 않고, 격차가 오히려 벌어졌다 (0.094 → 0.164).**
**질적으로 선을 넘었다:** 제대로 자금 댄 전담 공격자는 **league를 아예 이긴다**(0.63, +28.8점).
반면 **우리는 여전히 못 뚫는다**(0.47, 5.5점).
#### 우리에게 불리한 단서 (반드시 함께 읽을 것)
- **둘 다 1000 업데이트 끝까지 정체 없이 오르고 있었다. 이 수치도 여전히 하한선이다.**
- **우리 쪽 착취자의 말단 기울기가 더 가파르다** (+0.031 vs +0.016 / 100 업데이트).
league 착취자 곡선은 꺾이기 시작했는데 우리 쪽은 아직 갈 길이 남았다.
**공격자 예산을 크게 더 키우면 격차가 좁혀질 수 있고, 뒤집힐 가능성도 배제 못 한다.**
#### 구조적 교란 (순수한 "학습법 A vs B"가 아니다)
1. **league는 단판 정책이다.** carry도 매치 점수도 모른다. 더 털리는 것의 일부는
**학습 방법이 아니라 구조적 맹점** 때문일 수 있다.
2. **둘 다 greedy(argmax)로 뒀다.** 결정론적 정책은 **정의상 최대로 착취당한다.**
양쪽을 똑같이 대우했으니 비교는 공정하나, **절대값은 부풀려져 있다.**
#### 살아남는 주장 / 못 하는 주장
-**"동일 예산의 전담 공격자 앞에서 league는 뚫리고 우리는 안 뚫린다."**
- ❌ "우리 정책은 착취 불가능하다." — 하한선일 뿐이고, 우리 쪽 곡선은 아직 오르는 중이다.
+201
View File
@@ -0,0 +1,201 @@
"""altair (single-round, gen a) vs borealis (3-round match, gen b).
Full 2x2: {single deal, 3-round match} x {win rate + Wilson, mean margin + CI}.
Both policies act on the SAME MatchState but from their own view:
- borealis reads the full MatchState (match_observation + privileged critic).
- altair reads only the round in play as a single-round State (observation).
We play whole matches (duplicated: every deal-triple from both seats). From the
same runs we harvest two scoring conventions:
- single deal = the round-0 board score, snapshotted the ply round 0 rolls over
(carry=0, round_idx=0 there, so it is an honest standalone deal).
- 3-round match = match_score(final), the summed total.
Margins are borealis-minus-altair. Self-play (borealis vs borealis) is run as a
harness check: mirrored identical policies must give win rate 0.5 / margin 0.0.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import jax
import jax.numpy as jnp
import numpy as np
from lost_cities_jax.match import (
MatchState,
match_legal_action_mask,
match_reset_from,
match_score,
match_step,
)
from lost_cities_jax.match_eval import MATCH_SCAN_STEPS, _wilson, match_bank
from lost_cities_jax.match_obs import match_critic_observation, match_observation
from lost_cities_jax.match_ppo import Ablation, MatchActorCritic, create_match_train_state
from lost_cities_jax.obs import observation
from lost_cities_jax.ppo import (
ActorCritic,
create_train_state,
load_config,
mask_logits,
restore_checkpoint,
)
ALTAIR_CKPT = Path(
"/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/latest"
)
BOREALIS_CKPT = Path("runs/jax-ppo-match/2026-07-15_031529_match-scaled/latest")
MATCHES = 4096 # -> 8192 duplicate games per cell
SEED = 20260715
OUT = Path("runs/jax-ppo-match/altair_vs_borealis.json")
borealis_cfg = load_config("configs/jax_ppo/match-selfplay.yaml")
altair_cfg = load_config("configs/jax_ppo/balanced.yaml")
ABL = Ablation() # privileged_critic=True, as borealis was trained
borealis_st = restore_checkpoint(
BOREALIS_CKPT, create_match_train_state(borealis_cfg, jax.random.PRNGKey(0), ABL)
)
altair_st = restore_checkpoint(ALTAIR_CKPT, create_train_state(altair_cfg, jax.random.PRNGKey(0)))
borealis_model = MatchActorCritic(borealis_cfg.network.hidden_size, borealis_cfg.network.num_layers)
altair_model = ActorCritic(altair_cfg.network.hidden_size, altair_cfg.network.num_layers)
def _borealis_action(env: MatchState, to_move, mask):
obs = jax.vmap(match_observation)(env, to_move)
crit = jax.vmap(match_critic_observation)(env, to_move)
logits, _ = borealis_model.apply(borealis_st.params, obs, crit)
return jnp.argmax(mask_logits(logits, mask), axis=-1).astype(jnp.int32)
def _altair_action(env: MatchState, to_move, mask):
obs = jax.vmap(observation)(env.round, to_move)
logits, _ = altair_model.apply(altair_st.params, obs)
return jnp.argmax(mask_logits(logits, mask), axis=-1).astype(jnp.int32)
def _make_run(action_seat0, action_seat1):
"""Build a jitted full-match runner.
``action_seat0`` is the policy that plays when ``to_move == borealis_seat``
(i.e. borealis); ``action_seat1`` is the other policy (altair). Selection is
by ``borealis_seat`` so mirrored orientation is a pure seat relabel.
"""
@jax.jit
def run(env: MatchState, borealis_seat):
def body(carry, _):
env, r0_snap = carry
to_move = env.round.to_move.astype(jnp.int32)
mask = jax.vmap(match_legal_action_mask)(env)
a0 = action_seat0(env, to_move, mask)
a1 = action_seat1(env, to_move, mask)
action = jnp.where(to_move == borealis_seat, a0, a1)
was_r0 = env.round_idx == 0
nxt, _, _ = jax.vmap(match_step, in_axes=(0, 0))(env, action)
just_finished_r0 = was_r0 & (nxt.round_idx == 1)
# nxt.carry == round-0 board score exactly on the roll-over ply.
r0_snap = jnp.where(just_finished_r0[:, None], nxt.carry, r0_snap)
return (nxt, r0_snap), None
n = env.done.shape[0]
r0_snap = jnp.zeros((n, 2), dtype=jnp.int32)
(env, r0_snap), _ = jax.lax.scan(body, (env, r0_snap), xs=None, length=MATCH_SCAN_STEPS)
return env, r0_snap
return run
def _summ(margin: np.ndarray) -> dict:
"""margin = borealis - altair, per duplicate game. Positive = borealis wins."""
n = int(margin.size)
b_wins = int((margin > 0).sum())
a_wins = int((margin < 0).sum())
ties = int((margin == 0).sum())
lo, hi = _wilson(float(b_wins), float(n))
std = float(margin.std(ddof=1))
sem = std / math.sqrt(n)
return {
"n_duplicate_games": n,
"borealis_wins": b_wins,
"altair_wins": a_wins,
"ties": ties,
"borealis_win_rate": b_wins / n,
"wilson_95": [lo, hi],
"mean_margin_borealis_minus_altair": float(margin.mean()),
"margin_std": std,
"margin_sem": sem,
"margin_95ci": [float(margin.mean() - 1.96 * sem), float(margin.mean() + 1.96 * sem)],
}
def _play(run, decks, coins, borealis_first: bool):
"""Duplicate play; returns (single_deal_margins, match_margins).
``borealis_first`` picks which policy is action_seat0 in the runner. When the
two policies are identical (self-play) this must yield perfectly antisymmetric
margins -> win rate 0.5, margin 0.
"""
single, match = [], []
for seat in (0, 1):
env = jax.vmap(match_reset_from)(decks, coins)
b_seat = jnp.full((MATCHES,), seat, dtype=jnp.int32)
final, r0 = run(env, b_seat)
r0 = np.asarray(r0)
totals = np.asarray(jax.vmap(match_score)(final))
# borealis is at index ``seat``.
single.append(r0[:, seat] - r0[:, 1 - seat])
match.append(totals[:, seat] - totals[:, 1 - seat])
return np.concatenate(single), np.concatenate(match)
def main():
decks, coins = match_bank(SEED, MATCHES)
# --- Harness check: borealis vs borealis (both seats borealis) ---
run_self = _make_run(_borealis_action, _borealis_action)
self_single, self_match = _play(run_self, decks, coins, True)
self_check = {
"single_deal": _summ(self_single),
"three_round_match": _summ(self_match),
}
# --- Real comparison: altair vs borealis ---
# seat0-slot = borealis (selected when to_move == borealis_seat), seat1 = altair
run_av = _make_run(_borealis_action, _altair_action)
av_single, av_match = _play(run_av, decks, coins, True)
comparison = {
"single_deal": _summ(av_single),
"three_round_match": _summ(av_match),
}
result = {
"meta": {
"altair_ckpt": str(ALTAIR_CKPT),
"borealis_ckpt": str(BOREALIS_CKPT),
"matches_per_orientation": MATCHES,
"duplicate_games_per_cell": 2 * MATCHES,
"match_scan_steps": MATCH_SCAN_STEPS,
"seed": SEED,
"privileged_critic": ABL.privileged_critic,
"policies": "greedy argmax (deterministic given the deal)",
"margin_sign": "borealis total minus altair total",
},
"harness_check_borealis_vs_borealis": self_check,
"altair_vs_borealis": comparison,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(result, indent=2))
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
+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.
+318 -161
View File
@@ -1,29 +1,53 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Board, type BoardHint } from "./components/Board"; import { Board } from "./components/Board";
import { Card, CardBack } from "./components/Card"; import { Card, CardBack } from "./components/Card";
import { ResultCard } from "./components/ResultCard"; import { ResultCard, RoundOverlay } from "./components/Scoreboard";
import { describeAction } from "./game/actions";
import { cardColor, cardName } from "./game/cards"; import { cardColor, cardName } from "./game/cards";
import { import {
boardScore, boardScore,
currentHandSorted, currentHandSorted,
decodeAction, decodeAction,
encodeAction, encodeAction,
legalActionMask,
previewPlacement, previewPlacement,
resetFromOrder,
step,
} from "./game/engine"; } from "./game/engine";
import { deckOrderFromSeed, normalizeSeed, randomSeed } from "./game/random"; import {
import { DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types"; N_ROUNDS,
matchAdvanceRound,
matchFromOrders,
matchLegalActionMask,
matchScore,
matchStep,
roundScore,
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 { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy";
import { useCardMotion } from "./ui/useCardMotion"; import { useCardMotion } from "./ui/useCardMotion";
interface Hint { /** One deal, or the classic three-round match decided on the summed total. */
action: number; export type Mode = 1 | 3;
text: string;
probability: number | null; 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;
// The classic three-round match is the default; a stored "1" opts down to a deal.
return window.localStorage.getItem(MODE_KEY) === "1" ? 1 : 3;
}
// Off by default: taking moves back is a training aid, not how the game is played,
// and the default should be the honest game. When on, undo/redo walk the whole
// timeline -- across the rival's moves and earlier rounds, not just your turn.
const UNDO_KEY = "lost-cities.undo";
function loadUndoEnabled(): boolean {
return window.localStorage.getItem(UNDO_KEY) === "on";
} }
type Selection = { handSlot: number | null; placeType: PlaceType | null }; type Selection = { handSlot: number | null; placeType: PlaceType | null };
@@ -36,8 +60,9 @@ const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null };
* makes a finished game reviewable ply by ply. * makes a finished game reviewable ply by ply.
*/ */
interface Frame { interface Frame {
state: GameState; state: MatchState;
selection: Selection; selection: Selection;
move?: { player: 0 | 1; action: number };
} }
// The hand row is centered, so the binding constraint is the score plaque // The hand row is centered, so the binding constraint is the score plaque
@@ -66,15 +91,12 @@ function handCardStep(viewportWidth: number, count: number): number {
return Math.max(minVisible, Math.min(relaxed, fitted)); 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. */ /** A `?seed=` in the URL loads that exact deal, so a game can be shared or replayed.
function initialSeed(): string { * In match mode the seed fixes all three deals and the coin flips too, so the
const fromUrl = new URLSearchParams(window.location.search).get("seed"); * whole match is a pure function of it. */
const seed = fromUrl === null ? "" : normalizeSeed(fromUrl); function openingFrame(seed: string, mode: Mode): Frame {
return seed === "" ? randomSeed() : seed; const { deckOrders, coinFlips } = matchFromSeed(seed);
} return { state: matchFromOrders(deckOrders, coinFlips, mode), selection: EMPTY_SELECTION };
function openingFrame(seed: string): Frame {
return { state: resetFromOrder(deckOrderFromSeed(seed)), selection: EMPTY_SELECTION };
} }
/** Keep the address bar in sync so the current deal stays shareable. */ /** Keep the address bar in sync so the current deal stays shareable. */
@@ -84,29 +106,72 @@ function publishSeed(seed: string): void {
window.history.replaceState(null, "", url); 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() { function App() {
const [seed, setSeed] = useState<string>(initialSeed); const [initial] = useState(initialGame);
const [frames, setFrames] = useState<Frame[]>(() => [openingFrame(seed)]); const [seed, setSeed] = useState(initial.seed);
const [cursor, setCursor] = useState(0); 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 [seedDraft, setSeedDraft] = useState("");
const [resultOpen, setResultOpen] = useState(true); const [resultOpen, setResultOpen] = useState(initial.resultOpen);
const [policy, setPolicy] = useState<Policy | null>(null); const [policy, setPolicy] = useState<Policy | null>(null);
const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO"); const [modelMessage, setModelMessage] = useState("BOREALIS 불러오는 중");
const [thinking, setThinking] = useState(false); const [thinking, setThinking] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
const [hint, setHint] = useState<Hint | null>(null); const [undoEnabled, setUndoEnabled] = useState(loadUndoEnabled);
const [hintPending, setHintPending] = useState(false);
const generation = useRef(0); 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 { cardRef, deckRef, overlayRef, resetMotion } = useCardMotion();
const { state, selection } = frames[cursor]; const { state: match, selection } = frames[cursor];
const canUndo = cursor > 0; // The board on screen is the round in play; the match is what decides the game.
const canRedo = cursor < frames.length - 1; const state = match.round;
const latestState = useRef(state); // When enabled, undo/redo walk the whole timeline -- back through your committed
// moves, the rival's replies, and earlier rounds, not just the current turn. The
// rival is held whenever there are moves ahead to redo, so stepping back never
// makes it replay the move you are reviewing.
const canUndo = undoEnabled && cursor > 0;
const canRedo = undoEnabled && cursor < frames.length - 1;
const latestMatch = useRef(match);
useEffect(() => { publishSeed(seed); }, [seed]); useEffect(() => { publishSeed(seed); }, [seed]);
useEffect(() => { latestState.current = state; }, [state]); useEffect(() => { latestMatch.current = match; }, [match]);
useEffect(() => { window.localStorage.setItem(MODE_KEY, String(mode)); }, [mode]);
useEffect(() => {
window.localStorage.setItem(UNDO_KEY, undoEnabled ? "on" : "off");
}, [undoEnabled]);
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(() => { useEffect(() => {
function onResize() { setViewportWidth(window.innerWidth); } function onResize() { setViewportWidth(window.innerWidth); }
@@ -116,7 +181,7 @@ function App() {
const humanHand = useMemo(() => currentHandSorted(state, 0), [state]); const humanHand = useMemo(() => currentHandSorted(state, 0), [state]);
const opponentHand = useMemo(() => currentHandSorted(state, 1), [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 selectedCard = selection.handSlot === null ? null : humanHand[selection.handSlot];
const selectedColor = selectedCard === null ? null : cardColor(selectedCard); const selectedColor = selectedCard === null ? null : cardColor(selectedCard);
const displayState = useMemo( const displayState = useMemo(
@@ -125,14 +190,23 @@ function App() {
: previewPlacement(state, selection.handSlot, selection.placeType), : previewPlacement(state, selection.handSlot, selection.placeType),
[selection, state], [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
loadPolicy().then(({ policy: loaded, warning }) => { loadPolicy().then(({ policy: loaded, warning }) => {
if (cancelled) return; if (cancelled) return;
setPolicy(loaded); setPolicy(loaded);
setModelMessage(warning ?? `${loaded.provider.toUpperCase()} · FINAL PPO`); setModelMessage(warning ?? "BOREALIS");
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
@@ -149,45 +223,64 @@ function App() {
const rivalSuspended = canRedo; const rivalSuspended = canRedo;
useEffect(() => { useEffect(() => {
if (!policy || rivalSuspended || state.done || state.toMove !== 1) return; // No move while a finished round is being shown -- the player advances it.
if (!policy || rivalSuspended || match.done || match.roundComplete || state.toMove !== 1) return;
const currentGeneration = generation.current; const currentGeneration = generation.current;
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
setThinking(true); setThinking(true);
try { try {
let action: number; let action: number;
try { try {
action = await policy.action(state); action = await policy.action(match);
} catch (error) { } catch (error) {
console.error("AI action failed; falling back to heuristic policy", error); console.error("AI action failed; falling back to heuristic policy", error);
const fallback = fallbackHeuristicPolicy(); const fallback = fallbackHeuristicPolicy();
action = await fallback.action(state); action = await fallback.action(match);
if (generation.current !== currentGeneration) return; if (generation.current !== currentGeneration) return;
setPolicy(fallback); setPolicy(fallback);
setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)"); setModelMessage("휴리스틱 대체 (모델 오류)");
} }
if (generation.current !== currentGeneration) return; 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) { } catch (error) {
console.error("AI action failed even with heuristic fallback", error); console.error("AI action failed even with heuristic fallback", error);
setModelMessage("MODEL INFERENCE ERROR"); setModelMessage("모델 추론 오류");
} finally { } finally {
if (generation.current === currentGeneration) setThinking(false); if (generation.current === currentGeneration) setThinking(false);
} }
}, 620); }, 620);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [policy, rivalSuspended, state]); }, [policy, rivalSuspended, match, state.toMove]);
/** Deal the next round from the round-complete pause. */
function advanceRound() {
generation.current += 1;
resetMotion();
setFrames((items) => [...items.slice(0, cursor + 1), {
state: matchAdvanceRound(match),
selection: EMPTY_SELECTION,
}]);
setCursor(cursor + 1);
}
/** Deal a game. Without a seed this rolls a fresh one; the same seed always /** 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. */ * 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; generation.current += 1;
resetMotion(); resetMotion();
gameId.current = newGameId();
startedAt.current = new Date().toISOString();
loggedGame.current = null;
setSeed(nextSeed); setSeed(nextSeed);
setFrames([openingFrame(nextSeed)]); setMode(nextMode);
setFrames([openingFrame(nextSeed, nextMode)]);
setCursor(0); setCursor(0);
setSeedDraft(""); setSeedDraft("");
setResultOpen(true); setResultOpen(true);
setHint(null);
setThinking(false); setThinking(false);
setMenuOpen(false); setMenuOpen(false);
} }
@@ -201,66 +294,29 @@ function App() {
if (next < 0 || next >= frames.length) return; if (next < 0 || next >= frames.length) return;
generation.current += 1; generation.current += 1;
setCursor(next); setCursor(next);
setHint(null);
setThinking(false); setThinking(false);
setMenuOpen(false); setMenuOpen(false);
} }
const undo = () => moveCursor(cursor - 1); const undo = () => { if (canUndo) moveCursor(cursor - 1); };
const redo = () => moveCursor(cursor + 1); const redo = () => { if (canRedo) moveCursor(cursor + 1); };
/** Leave the reviewed position as the live one and let the rival play on. */
function resumeFromHere() {
setFrames((items) => items.slice(0, cursor + 1));
setMenuOpen(false);
}
/** 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;
setHintPending(true);
setMenuOpen(false);
try {
const ranked = await policy.rank(position).catch(async (error) => {
console.error("hint inference failed; falling back to heuristic", error);
return fallbackHeuristicPolicy().rank(position);
});
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;
setHint({
action: best.action,
text: describeAction(position, best.action),
probability: best.probability,
});
} catch (error) {
console.error("hint failed", error);
} finally {
setHintPending(false);
}
}
/** A hint answers one position; drop it as soon as anything is done or undone. */
useEffect(() => { setHint(null); }, [cursor, frames]);
function chooseCard(handSlot: number) { 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 const next = selection.handSlot !== handSlot || selection.placeType !== null
? { handSlot, placeType: null } ? { handSlot, placeType: null }
: EMPTY_SELECTION; : EMPTY_SELECTION;
pushFrame({ state, selection: next }); pushFrame({ state: match, selection: next });
} }
function choosePlace(placeType: PlaceType) { function choosePlace(placeType: PlaceType) {
if (selection.handSlot === null) return; if (selection.handSlot === null) return;
pushFrame({ state, selection: { ...selection, placeType } }); pushFrame({ state: match, selection: { ...selection, placeType } });
} }
function cancelPlace() { function cancelPlace() {
if (selection.handSlot === null || selection.placeType === null) return; if (selection.handSlot === null || selection.placeType === null) return;
pushFrame({ state, selection: { ...selection, placeType: null } }); pushFrame({ state: match, selection: { ...selection, placeType: null } });
} }
useEffect(() => { useEffect(() => {
@@ -282,7 +338,11 @@ function App() {
function commit(drawSource: number) { function commit(drawSource: number) {
if (!legalDraw(drawSource) || selection.handSlot === null || selection.placeType === null) return; if (!legalDraw(drawSource) || selection.handSlot === null || selection.placeType === null) return;
const action = encodeAction(selection.handSlot, selection.placeType, drawSource); 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 // Cards actually drawn in the hand row (the selected card moves into the
@@ -299,44 +359,100 @@ function App() {
(_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)], (_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)],
).some(Boolean); ).some(Boolean);
const hintMove = hint === null ? null : decodeAction(hint.action);
const hintedCard = hintMove === null ? undefined : humanHand[hintMove.handSlot];
const boardHint: BoardHint | null = hintMove === null || hintedCard === undefined ? null : {
color: cardColor(hintedCard),
place: hintMove.placeType,
drawSource: hintMove.drawSource,
};
const hintConfidence = hint !== null && hint.probability !== null
? `${Math.round(hint.probability * 100)}%`
: null;
const canHint = policy !== null && !state.done && state.toMove === 0;
const outcome = scores[0] === scores[1] const outcome = scores[0] === scores[1]
? "DRAW" ? "무승부"
: scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS"; : scores[0] > scores[1] ? "승리" : "패배";
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.roundHistory,
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 // 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 // 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. // 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 const status = reviewingRival
? `Reviewing ply ${state.stepCount} — redo, or play on from here` ? "되돌리는 중 — 다시 진행하려면 앞으로"
: state.done : match.done
? outcome ? outcome
: state.toMove === 1 : match.roundComplete
? thinking ? "The rival is thinking ···" : "The rival's turn" ? `${match.roundIdx + 1}라운드 종료`
: selection.handSlot === null : state.toMove === 1
? "Play or discard a card" ? thinking ? "상대가 생각 중이에요" : "상대 차례"
: selection.placeType === null : selection.handSlot === null
? `Choose a destination for ${cardName(selectedCard!)}` ? "낼 카드나 버릴 카드를 고르세요"
: "Draw — deck or a discard pile"; : selection.placeType === null
? `${cardName(selectedCard!)} 놓을 곳을 고르세요`
: "카드를 뽑으세요 — 덱 또는 버림패";
return ( return (
<main className="app-shell"> <main className="app-shell">
<section className="score-plaque score-plaque--rival" aria-label={`Rival score ${scores[1]}`}> <section className="score-plaque score-plaque--rival" aria-label={`상대 점수 ${scores[1]}`}>
<div><strong>THE RIVAL</strong><small>{modelMessage}</small></div><b>{scores[1]}</b> <div><strong></strong><small>{modelMessage}</small></div><b>{scores[1]}</b>
</section> </section>
<div className="opponent-hand" aria-label="Rival hand"> <div className="opponent-hand" aria-label="상대 손패">
{opponentHand.map((card) => ( {opponentHand.map((card) => (
state.handPublic[card] state.handPublic[card]
? <Card mini card={card} key={card} innerRef={cardRef(card)} /> ? <Card mini card={card} key={card} innerRef={cardRef(card)} />
@@ -345,28 +461,64 @@ function App() {
</div> </div>
<div className="menu-wrap"> <div className="menu-wrap">
<button className="menu-button" onClick={() => setMenuOpen((open) => !open)}>MENU</button> <button className="menu-button" onClick={() => setMenuOpen((open) => !open)}></button>
{menuOpen && ( {menuOpen && (
<div className="menu-popover"> <div className="menu-popover">
<button onClick={() => restart()}>NEW GAME</button> <button onClick={() => restart()}> </button>
<button onClick={() => restart(seed)}>REPLAY THIS SEED</button> <button onClick={() => restart(seed)}> </button>
<div className="menu-modes" role="group" aria-label="게임 길이">
<button
className={mode === 3 ? "is-active" : ""}
onClick={() => restart(randomSeed(), 3)}
>
3
</button>
<button
className={mode === 1 ? "is-active" : ""}
onClick={() => restart(randomSeed(), 1)}
>
</button>
</div>
<label className="menu-seed"> <label className="menu-seed">
<span>DEAL SEED · {seed}</span> <span> · {seed}</span>
<input <input
value={seedDraft} value={seedDraft}
placeholder="load a deal seed…" placeholder="시드 입력…"
onChange={(event) => setSeedDraft(event.target.value)} onChange={(event) => setSeedDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") loadSeed(); }} onKeyDown={(event) => { if (event.key === "Enter") loadSeed(); }}
aria-label="Load a deal by seed" aria-label="시드로 딜 불러오기"
/> />
</label> </label>
<button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}>DEAL THIS SEED</button> <button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}> </button>
<span>THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC</span> <label className="menu-toggle">
<span>{policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}</span> <input
type="checkbox"
checked={undoEnabled}
onChange={(event) => setUndoEnabled(event.target.checked)}
/>
<span> / · </span>
</label>
</div> </div>
)} )}
</div> </div>
{mode === N_ROUNDS && (
<div className="round-strip" aria-label={`${N_ROUNDS}라운드 중 ${match.roundIdx + 1}라운드`}>
{Array.from({ length: N_ROUNDS }, (_, round) => (
<span
key={round}
className={
round < match.roundIdx ? "is-done" : round === match.roundIdx ? "is-live" : ""
}
>
{round + 1}
</span>
))}
<em> {match.carry[0]} : {match.carry[1]}</em>
</div>
)}
<div className="table-center"> <div className="table-center">
<Board <Board
state={displayState} state={displayState}
@@ -374,7 +526,6 @@ function App() {
selectedPlace={selection.placeType} selectedPlace={selection.placeType}
canPlay={canPlaySelected} canPlay={canPlaySelected}
canDraw={legalDraw} canDraw={legalDraw}
hint={boardHint}
cardRef={cardRef} cardRef={cardRef}
onChoosePlace={choosePlace} onChoosePlace={choosePlace}
onCancelPlace={cancelPlace} onCancelPlace={cancelPlace}
@@ -384,38 +535,29 @@ function App() {
<button <button
type="button" type="button"
className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""} ${hintMove?.drawSource === DRAW_DECK ? "is-hinted" : ""}`} className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""}`}
disabled={!legalDraw(DRAW_DECK)} disabled={!legalDraw(DRAW_DECK)}
onClick={() => commit(DRAW_DECK)} onClick={() => commit(DRAW_DECK)}
aria-label={`Draw from deck, ${N_CARDS - state.drawPtr} cards left`} aria-label={`덱에서 뽑기, ${N_CARDS - state.drawPtr}장 남음`}
> >
<CardBack innerRef={deckRef} /> <CardBack innerRef={deckRef} />
<strong>{N_CARDS - state.drawPtr}</strong> <strong>{N_CARDS - state.drawPtr}</strong>
<span>CARDS LEFT</span> <span> </span>
</button> </button>
<div className="prompt-row"> <div className="prompt-row">
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""} ${reviewingRival ? "turn-prompt--review" : ""}`}> <p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""} ${reviewingRival ? "turn-prompt--review" : ""}`}>
{hint ? `Hint — ${hint.text}${hintConfidence ? ` (${hintConfidence})` : ""}` : status} {status}
</p> </p>
<button
type="button"
className={`hint-button ${hint ? "is-active" : ""}`}
onClick={requestHint}
disabled={!canHint || hintPending}
>
{hintPending ? "THINKING…" : "HINT"}
</button>
</div> </div>
<section className="human-hand" aria-label="Your hand"> <section className="human-hand" aria-label="내 손패">
{renderedHand.map(({ card, slot }, position) => ( {renderedHand.map(({ card, slot }, position) => (
<Card <Card
card={card} card={card}
key={card} key={card}
selected={selection.handSlot === slot} selected={selection.handSlot === slot}
hinted={hintedCard === card} disabled={state.toMove !== 0 || match.done}
disabled={state.toMove !== 0 || state.done}
onClick={() => chooseCard(slot)} onClick={() => chooseCard(slot)}
style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }} style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }}
innerRef={cardRef(card)} innerRef={cardRef(card)}
@@ -423,37 +565,52 @@ function App() {
))} ))}
</section> </section>
<section className="score-plaque score-plaque--human" aria-label={`Your score ${scores[0]}`}> <section className="score-plaque score-plaque--human" aria-label={`내 점수 ${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> <i /></strong><small>{state.toMove === 0 && !match.done ? "내 차례" : "현재 점수"}</small></div><b>{scores[0]}</b>
</section> </section>
<div className="history-bar"> <div className="control-stack">
<button type="button" onClick={undo} disabled={!canUndo} aria-label="Undo one action" title="Undo (←)"> {undoEnabled && (
<span>UNDO</span> <>
</button> <button type="button" onClick={undo} disabled={!canUndo} aria-label="한 단계 되돌리기" title="되돌리기 (←)">
<button type="button" onClick={redo} disabled={!canRedo} aria-label="Redo one action" title="Redo (→)"> <span></span>
<span>REDO</span> </button>
</button> <button type="button" onClick={redo} disabled={!canRedo} aria-label="한 단계 다시" title="다시 (→)">
{rivalSuspended && ( <span></span>
<button type="button" className="history-bar__resume" onClick={resumeFromHere}> </button>
PLAY FROM HERE </>
</button>
)}
{state.done && !resultOpen && (
<button type="button" onClick={() => setResultOpen(true)}>SCORE</button>
)} )}
<button
type="button"
className={`control-stack__score ${match.done && !resultOpen ? "is-available" : ""}`}
onClick={() => setResultOpen(true)}
disabled={!match.done || resultOpen}
aria-hidden={!match.done || resultOpen}
>
</button>
</div> </div>
{/* Cards in flight back to the deck (undo of a draw) are re-parented here {/* Cards in flight back to the deck (undo of a draw) are re-parented here
after React has already removed them from the table. */} after React has already removed them from the table. */}
<div className="motion-overlay" ref={overlayRef} aria-hidden="true" /> <div className="motion-overlay" ref={overlayRef} aria-hidden="true" />
{state.done && resultOpen && ( {match.roundComplete && (
<RoundOverlay
state={state}
roundIdx={match.roundIdx}
totalRounds={mode}
matchTotals={scores}
onContinue={advanceRound}
/>
)}
{match.done && resultOpen && (
<ResultCard <ResultCard
state={state} state={state}
outcome={outcome} outcome={outcome}
seed={seed} roundHistory={mode > 1 ? match.roundHistory : [roundScore(match)]}
onReview={() => setResultOpen(false)} totalRounds={mode}
onPlayAgain={() => restart()} onPlayAgain={() => restart()}
/> />
)} )}
+2 -14
View File
@@ -9,20 +9,12 @@ import {
import { cardsOnBoard } from "../game/engine"; import { cardsOnBoard } from "../game/engine";
import { DISCARD, PLAY, type GameState, type PlaceType } from "../game/types"; import { DISCARD, PLAY, type GameState, type PlaceType } from "../game/types";
/** Where the hint says to put the card and where to draw from, if hinting. */
export interface BoardHint {
color: number;
place: PlaceType;
drawSource: number;
}
interface BoardProps { interface BoardProps {
state: GameState; state: GameState;
selectedColor: number | null; selectedColor: number | null;
selectedPlace: PlaceType | null; selectedPlace: PlaceType | null;
canPlay: boolean; canPlay: boolean;
canDraw: (source: number) => boolean; canDraw: (source: number) => boolean;
hint: BoardHint | null;
cardRef: (card: number) => (element: HTMLElement | null) => void; cardRef: (card: number) => (element: HTMLElement | null) => void;
onChoosePlace: (place: PlaceType) => void; onChoosePlace: (place: PlaceType) => void;
onCancelPlace: () => void; onCancelPlace: () => void;
@@ -58,7 +50,6 @@ export function Board({
selectedPlace, selectedPlace,
canPlay, canPlay,
canDraw, canDraw,
hint,
cardRef, cardRef,
onChoosePlace, onChoosePlace,
onCancelPlace, onCancelPlace,
@@ -75,9 +66,6 @@ export function Board({
const drawTarget = selectedPlace !== null && canDraw(color + 1); const drawTarget = selectedPlace !== null && canDraw(color + 1);
const discardChosen = selectedPlace === DISCARD && selectedColor === color; const discardChosen = selectedPlace === DISCARD && selectedColor === color;
const playChosen = selectedPlace === PLAY && selectedColor === color; const playChosen = selectedPlace === PLAY && selectedColor === color;
const hintPlay = hint?.place === PLAY && hint.color === color;
const hintDiscard = hint?.place === DISCARD && hint.color === color;
const hintDraw = hint?.drawSource === color + 1;
return ( return (
<article <article
className="lane" className="lane"
@@ -97,7 +85,7 @@ export function Board({
<button <button
type="button" type="button"
className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${discardChosen ? "is-chosen" : ""} ${hintDiscard || hintDraw ? "is-hinted" : ""}`} className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${discardChosen ? "is-chosen" : ""}`}
onClick={() => drawTarget ? onDraw(color + 1) : discardChosen ? onCancelPlace() : discardTarget ? onChoosePlace(DISCARD) : undefined} onClick={() => drawTarget ? onDraw(color + 1) : discardChosen ? onCancelPlace() : discardTarget ? onChoosePlace(DISCARD) : undefined}
disabled={!discardTarget && !drawTarget && !discardChosen} disabled={!discardTarget && !drawTarget && !discardChosen}
aria-label={discardChosen ? `Cancel discarding to ${name} pile` : `${name} discard pile`} aria-label={discardChosen ? `Cancel discarding to ${name} pile` : `${name} discard pile`}
@@ -113,7 +101,7 @@ export function Board({
<button <button
type="button" type="button"
className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${playChosen ? "is-chosen" : ""} ${hintPlay ? "is-hinted" : ""}`} className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${playChosen ? "is-chosen" : ""}`}
onClick={() => playChosen ? onCancelPlace() : playTarget ? onChoosePlace(PLAY) : undefined} onClick={() => playChosen ? onCancelPlace() : playTarget ? onChoosePlace(PLAY) : undefined}
disabled={!playTarget && !playChosen} disabled={!playTarget && !playChosen}
aria-label={playChosen ? `Cancel playing to your ${name} expedition` : `Your ${name} expedition`} aria-label={playChosen ? `Cancel playing to your ${name} expedition` : `Your ${name} expedition`}
+1 -3
View File
@@ -14,7 +14,6 @@ interface CardProps {
* hand row is only as tall as a card back, so a full card would burst it. */ * hand row is only as tall as a card back, so a full card would burst it. */
mini?: boolean; mini?: boolean;
selected?: boolean; selected?: boolean;
hinted?: boolean;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
style?: React.CSSProperties; style?: React.CSSProperties;
@@ -26,7 +25,6 @@ export function Card({
compact = false, compact = false,
mini = false, mini = false,
selected = false, selected = false,
hinted = false,
disabled = false, disabled = false,
onClick, onClick,
style: styleOverride, style: styleOverride,
@@ -35,7 +33,7 @@ export function Card({
const color = cardColor(card); const color = cardColor(card);
const value = cardValue(card); const value = cardValue(card);
const handshake = isHandshake(card); const handshake = isHandshake(card);
const className = `card ${compact ? "card--compact" : ""} ${mini ? "card--mini" : ""} ${selected ? "card--selected" : ""} ${hinted ? "card--hinted" : ""}`; const className = `card ${compact ? "card--compact" : ""} ${mini ? "card--mini" : ""} ${selected ? "card--selected" : ""}`;
const style = { "--card-color": COLOR_HEX[color], ...styleOverride } as React.CSSProperties; const style = { "--card-color": COLOR_HEX[color], ...styleOverride } as React.CSSProperties;
const content = ( const content = (
<> <>
-91
View File
@@ -1,91 +0,0 @@
import { COLOR_GLYPHS, COLOR_HEX, EXPEDITION_NAMES } from "../game/cards";
import { scoreBreakdown } from "../game/scoring";
import type { GameState } from "../game/types";
interface ResultCardProps {
state: GameState;
outcome: string;
seed: string;
onReview: () => void;
onPlayAgain: () => void;
}
function signed(value: number): string {
return value > 0 ? `+${value}` : String(value);
}
export function ResultCard({ state, outcome, seed, onReview, onPlayAgain }: ResultCardProps) {
const you = scoreBreakdown(state, 0);
const rival = scoreBreakdown(state, 1);
return (
<div className="result-overlay">
<section className="result-card" aria-label="Final score">
<header className="result-card__head">
<p>ROUND COMPLETE</p>
<h1>{outcome}</h1>
<strong>{you.total} <i>:</i> {rival.total}</strong>
</header>
<table className="result-table">
<thead>
<tr>
<th scope="col">EXPEDITION</th>
<th scope="col" colSpan={2}>YOU</th>
<th scope="col" colSpan={2}>THE RIVAL</th>
</tr>
<tr className="result-table__subhead">
<th scope="col" />
<th scope="col">CARDS</th>
<th scope="col">SCORE</th>
<th scope="col">CARDS</th>
<th scope="col">SCORE</th>
</tr>
</thead>
<tbody>
{EXPEDITION_NAMES.map((name, color) => {
const mine = you.colors[color];
const theirs = rival.colors[color];
return (
<tr key={name}>
<th scope="row" style={{ "--lane-color": COLOR_HEX[color] } as React.CSSProperties}>
<i>{COLOR_GLYPHS[color]}</i>{name}
</th>
<td className="result-table__detail">
{mine.cards ? `${mine.cards}${mine.handshakes ? ` ·×${mine.handshakes + 1}` : ""}` : "—"}
</td>
<td className={mine.score < 0 ? "is-negative" : mine.score > 0 ? "is-positive" : ""}>
{mine.cards ? signed(mine.score) : "—"}
</td>
<td className="result-table__detail">
{theirs.cards ? `${theirs.cards}${theirs.handshakes ? ` ·×${theirs.handshakes + 1}` : ""}` : "—"}
</td>
<td className={theirs.score < 0 ? "is-negative" : theirs.score > 0 ? "is-positive" : ""}>
{theirs.cards ? signed(theirs.score) : "—"}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<th scope="row">TOTAL</th>
<td className="result-table__detail" />
<td>{signed(you.total)}</td>
<td className="result-table__detail" />
<td>{signed(rival.total)}</td>
</tr>
</tfoot>
</table>
<footer className="result-card__foot">
<small>DEAL SEED {seed}</small>
<div>
<button type="button" onClick={onReview}>REVIEW GAME</button>
<button type="button" className="is-primary" onClick={onPlayAgain}>PLAY AGAIN</button>
</div>
</footer>
</section>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { COLOR_GLYPHS, COLOR_HEX, EXPEDITION_NAMES_KO } from "../game/cards";
import { scoreBreakdown } from "../game/scoring";
import type { GameState } from "../game/types";
function signed(value: number): string {
return value > 0 ? `+${value}` : String(value);
}
function scoreClass(value: number): string {
return value < 0 ? "is-negative" : value > 0 ? "is-positive" : "";
}
/** Per-expedition breakdown of one round's board: cards (with the wager multiplier)
* and score, for both players. */
function ColorTable({ state }: { state: GameState }) {
const you = scoreBreakdown(state, 0);
const rival = scoreBreakdown(state, 1);
return (
<table className="result-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col" colSpan={2}></th>
<th scope="col" colSpan={2}></th>
</tr>
<tr className="result-table__subhead">
<th scope="col" />
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{EXPEDITION_NAMES_KO.map((name, color) => {
const mine = you.colors[color];
const theirs = rival.colors[color];
const cards = (c: { cards: number; handshakes: number }) =>
c.cards ? `${c.cards}${c.handshakes ? ` ·×${c.handshakes + 1}` : ""}` : "—";
return (
<tr key={name}>
<th scope="row" style={{ "--lane-color": COLOR_HEX[color] } as React.CSSProperties}>
<i>{COLOR_GLYPHS[color]}</i>{name}
</th>
<td className="result-table__detail">{cards(mine)}</td>
<td className={scoreClass(mine.score)}>{mine.cards ? signed(mine.score) : "—"}</td>
<td className="result-table__detail">{cards(theirs)}</td>
<td className={scoreClass(theirs.score)}>{theirs.cards ? signed(theirs.score) : "—"}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<th scope="row"></th>
<td className="result-table__detail" />
<td>{signed(you.total)}</td>
<td className="result-table__detail" />
<td>{signed(rival.total)}</td>
</tr>
</tfoot>
</table>
);
}
/** The pause between rounds: the finished round's per-colour scores and where the
* match stands, held until the player chooses to deal the next round. */
export function RoundOverlay({
state,
roundIdx,
totalRounds,
matchTotals,
onContinue,
}: {
state: GameState;
roundIdx: number;
totalRounds: number;
matchTotals: [number, number];
onContinue: () => void;
}) {
return (
<div className="result-overlay">
<section className="result-card" aria-label={`${roundIdx + 1}라운드 결과`}>
<header className="result-card__head">
<p>{roundIdx + 1} </p>
<strong>{matchTotals[0]} <i>:</i> {matchTotals[1]}</strong>
<small className="result-card__banked"> · {matchTotals[0]} · {matchTotals[1]}</small>
</header>
<ColorTable state={state} />
<footer className="result-card__foot">
<button type="button" className="is-primary" onClick={onContinue}>
{roundIdx + 1 >= totalRounds - 1 ? "마지막 라운드로" : `${roundIdx + 2}라운드 시작`}
</button>
</footer>
</section>
</div>
);
}
/** The end of the game: the outcome, the final round's per-colour breakdown, and --
* for a match -- a single table summarising all three rounds. */
export function ResultCard({
state,
outcome,
roundHistory,
totalRounds,
onPlayAgain,
}: {
state: GameState;
outcome: string;
/** Each finished round's [you, rival] total, including this last one. */
roundHistory: [number, number][];
totalRounds: number;
onPlayAgain: () => void;
}) {
const isMatch = totalRounds > 1;
const totals = roundHistory.reduce(
(acc, [you, rival]) => [acc[0] + you, acc[1] + rival] as [number, number],
[0, 0] as [number, number],
);
return (
<div className="result-overlay">
<section className="result-card" aria-label="최종 결과">
<header className="result-card__head">
<p>{isMatch ? `${totalRounds}라운드 매치 종료` : "게임 종료"}</p>
<h1>{outcome}</h1>
<strong>{totals[0]} <i>:</i> {totals[1]}</strong>
</header>
{isMatch ? (
<table className="result-table result-table--rounds">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{roundHistory.map(([you, rival], round) => (
<tr key={round}>
<th scope="row">{round + 1}</th>
<td className={scoreClass(you)}>{signed(you)}</td>
<td className={scoreClass(rival)}>{signed(rival)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="result-table__match">
<th scope="row"></th>
<td>{signed(totals[0])}</td>
<td>{signed(totals[1])}</td>
</tr>
</tfoot>
</table>
) : (
<ColorTable state={state} />
)}
<footer className="result-card__foot">
<button type="button" className="is-primary" onClick={onPlayAgain}> </button>
</footer>
</section>
</div>
);
}
+1
View File
@@ -2,6 +2,7 @@ import { CARDS_PER_COLOR } from "./types";
export const COLOR_NAMES = ["Red", "Blue", "Green", "Gold", "Violet"] as const; export const COLOR_NAMES = ["Red", "Blue", "Green", "Gold", "Violet"] as const;
export const EXPEDITION_NAMES = ["Volcano", "Ocean", "Jungle", "Desert", "Cavern"] as const; export const EXPEDITION_NAMES = ["Volcano", "Ocean", "Jungle", "Desert", "Cavern"] as const;
export const EXPEDITION_NAMES_KO = ["화산", "바다", "정글", "사막", "동굴"] as const;
export const COLOR_HEX = ["#b94737", "#3569a7", "#397b4e", "#b37d20", "#704487"] as const; export const COLOR_HEX = ["#b94737", "#3569a7", "#397b4e", "#b37d20", "#704487"] as const;
export const COLOR_GLYPHS = ["▲", "≋", "♧", "◆", "⬟"] as const; export const COLOR_GLYPHS = ["▲", "≋", "♧", "◆", "⬟"] as const;
+6 -2
View File
@@ -32,7 +32,11 @@ export function shuffledDeck(random: () => number = Math.random): number[] {
return deck; 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) { if (deckOrder.length !== N_CARDS || new Set(deckOrder).size !== N_CARDS) {
throw new Error("deckOrder must be a permutation of 0..59"); 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), colHandshakes: emptyMatrix(2, N_COLORS),
colLength: emptyMatrix(2, N_COLORS), colLength: emptyMatrix(2, N_COLORS),
piles: Array.from({ length: N_COLORS }, () => []), piles: Array.from({ length: N_COLORS }, () => []),
toMove: 0, toMove: (firstPlayer === 1 ? 1 : 0) as 0 | 1,
stepCount: 0, stepCount: 0,
done: false, done: false,
}; };
File diff suppressed because one or more lines are too long
+146
View File
@@ -0,0 +1,146 @@
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;
/**
* The round's deck is exhausted and its board is being shown, but the match has
* not rolled into the next round yet. The engine advances rounds atomically; the
* client holds here so the finished board and its per-colour scores are visible
* before `matchAdvanceRound` deals the next one. Never set on the last round --
* that sets `done` instead.
*/
roundComplete: boolean;
/** Each finished round's [you, rival] total, in order, for the final summary. */
roundHistory: [number, 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,
roundComplete: false,
roundHistory: [],
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);
// No moves while the match is over or a finished round is being shown.
return state.done || state.roundComplete ? mask.map(() => false) : mask;
}
/** Score of the round currently on the board, by player. */
export function roundScore(state: MatchState): [number, number] {
return boardScore(state.round);
}
/**
* Play one ply. When the deck runs out this holds on the finished board
* (`roundComplete`) rather than rolling straight into the next round, so the
* client can show the round's scores; `matchAdvanceRound` continues. The last
* round sets `done` instead. Rounds one and two only bank into `carry` -- the
* summed total decides the match.
*/
export function matchStep(state: MatchState, action: number): MatchState {
const played = step(state.round, action);
if (!played.done || state.done || state.roundComplete) {
return { ...state, round: played };
}
const board = boardScore(played);
if (state.roundIdx >= state.totalRounds - 1) {
return {
...state,
round: played,
roundHistory: [...state.roundHistory, board],
done: true,
};
}
return { ...state, round: played, roundComplete: true };
}
/** Bank the finished round and deal the next one. No-op unless roundComplete. */
export function matchAdvanceRound(state: MatchState): MatchState {
if (!state.roundComplete) return state;
const board = boardScore(state.round);
const banked: [number, number] = [state.carry[0] + board[0], state.carry[1] + board[1]];
const roundIdx = state.roundIdx + 1;
return {
...state,
round: resetFromOrder(
state.deckOrders[roundIdx],
startingPlayer(banked, roundIdx, state.coinFlips),
),
roundIdx,
carry: banked,
roundHistory: [...state.roundHistory, board],
roundComplete: false,
};
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import fixture from "./match-parity-fixture.json";
import {
N_ROUNDS,
matchAdvanceRound,
matchFromOrders,
matchLegalActionMask,
matchScore,
matchStep,
roundScore,
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,
// Not part of the observation; only present so the type is satisfied.
roundComplete: false,
roundHistory: [],
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);
});
});
/** Play a full match, advancing past each round-complete pause. */
function playMatch(start: MatchState): { final: MatchState; pauses: number } {
let match = start;
let pauses = 0;
for (let ply = 0; ply < 1400 && !match.done; ply += 1) {
if (match.roundComplete) {
pauses += 1;
match = matchAdvanceRound(match);
continue;
}
match = matchStep(match, firstLegal(match));
}
return { final: match, pauses };
}
describe("match rules", () => {
it("pauses at each round end, then plays exactly three rounds", () => {
const start = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips);
const { final, pauses } = playMatch(start);
// Two roll-overs for three rounds; the third ends the match, not a pause.
expect(pauses).toBe(N_ROUNDS - 1);
expect(final.done).toBe(true);
expect(final.roundIdx).toBe(N_ROUNDS - 1);
expect(final.roundHistory.length).toBe(N_ROUNDS);
});
it("holds the finished board while round-complete, offering no legal move", () => {
let match = matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips);
while (!match.roundComplete && !match.done) match = matchStep(match, firstLegal(match));
expect(match.roundComplete).toBe(true);
expect(match.roundIdx).toBe(0); // not advanced yet
expect(matchLegalActionMask(match).some(Boolean)).toBe(false);
// The running total already includes the round being shown.
expect(matchScore(match)).toEqual(roundScore(match));
});
it("banks each round exactly once into the history and carry", () => {
const { final } = playMatch(matchFromOrders(rows[0].match.deckOrders, rows[0].match.coinFlips));
const summed = final.roundHistory.reduce(
(acc, [you, rival]) => [acc[0] + you, acc[1] + rival] as [number, number],
[0, 0] as [number, number],
);
// carry banks rounds 1..2; the third is still on the board at match end.
const board = roundScore(final);
expect(matchScore(final)).toEqual([summed[0], summed[1]]);
expect([final.carry[0] + board[0], final.carry[1] + board[1]]).toEqual(matchScore(final));
});
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);
});
});
+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();
});
});
+99
View File
@@ -0,0 +1,99 @@
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.roundComplete === "boolean" &&
Array.isArray(match.roundHistory) &&
match.roundHistory.every((entry) => isNumberArray(entry, 2)) &&
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))); 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". */ /** A fresh shareable seed, e.g. "k3f9qa". */
export function randomSeed(): string { export function randomSeed(): string {
return Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, "0"); 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 * as ort from "onnxruntime-web/all";
import { currentHandSorted, legalActionMask } from "../game/engine"; import { currentHandSorted } from "../game/engine";
import { observation } from "../game/observation"; import { matchLegalActionMask, type MatchState } from "../game/match";
import { DISCARD, DRAW_DECK, PLAY, type GameState } from "../game/types"; import { matchObservation } from "../game/matchObservation";
import { DISCARD, DRAW_DECK, PLAY } from "../game/types";
import { cardColor, cardRank, isHandshake } from "../game/cards"; import { cardColor, cardRank, isHandshake } from "../game/cards";
export type ExecutionProvider = "webgpu" | "wasm" | "heuristic"; 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. */ /** A legal action with the policy's confidence in it, if the policy has one. */
export interface RankedAction { export interface RankedAction {
@@ -18,16 +30,16 @@ export interface RankedAction {
export interface Policy { export interface Policy {
readonly provider: ExecutionProvider; readonly provider: ExecutionProvider;
/** Legal actions, best first. Drives both the rival's move and the hint. */ /** Legal actions, best first. Drives both the rival's move and the hint. */
rank(state: GameState): Promise<RankedAction[]>; rank(match: MatchState): Promise<RankedAction[]>;
action(state: GameState): Promise<number>; action(match: MatchState): Promise<number>;
} }
abstract class RankingPolicy implements Policy { abstract class RankingPolicy implements Policy {
abstract readonly provider: ExecutionProvider; abstract readonly provider: ExecutionProvider;
abstract rank(state: GameState): Promise<RankedAction[]>; abstract rank(match: MatchState): Promise<RankedAction[]>;
async action(state: GameState): Promise<number> { async action(match: MatchState): Promise<number> {
const [best] = await this.rank(state); const [best] = await this.rank(match);
if (best === undefined) throw new Error("state has no legal actions"); if (best === undefined) throw new Error("state has no legal actions");
return best.action; return best.action;
} }
@@ -54,12 +66,12 @@ class OnnxPolicy extends RankingPolicy {
super(); super();
} }
async rank(state: GameState): Promise<RankedAction[]> { async rank(match: MatchState): Promise<RankedAction[]> {
const obs = observation(state, state.toMove); const obs = matchObservation(match, match.round.toMove);
const result = await this.session.run({ obs: new ort.Tensor("float32", obs, [1, obs.length]) }); const result = await this.session.run({ obs: new ort.Tensor("float32", obs, [1, obs.length]) });
const logits = result.logits?.data; const logits = result.logits?.data;
if (!logits) throw new Error("ONNX model did not return a logits output"); 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); return softmaxOverLegal(legal.map((_, action) => Number(logits[action])), legal);
} }
} }
@@ -67,8 +79,9 @@ class OnnxPolicy extends RankingPolicy {
export class HeuristicPolicy extends RankingPolicy { export class HeuristicPolicy extends RankingPolicy {
readonly provider = "heuristic" as const; readonly provider = "heuristic" as const;
async rank(state: GameState): Promise<RankedAction[]> { async rank(match: MatchState): Promise<RankedAction[]> {
const legal = legalActionMask(state); const state = match.round;
const legal = matchLegalActionMask(match);
const hand = currentHandSorted(state); const hand = currentHandSorted(state);
const ranked = legal.flatMap((isLegal, action) => { const ranked = legal.flatMap((isLegal, action) => {
if (!isLegal) return []; if (!isLegal) return [];
+157 -59
View File
@@ -18,6 +18,23 @@
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* The table is a control surface, not a document. Dragging across it should never
paint a card's number blue or pop a long-press copy menu on mobile. Selection is
off everywhere and turned back on only for text you would actually want to copy
or edit -- in practice the seed field. user-select does not touch click or focus,
so every button still works. */
.app-shell {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.app-shell input,
.app-shell textarea {
user-select: text;
-webkit-user-select: text;
}
html, body, #root { min-width: 1024px; min-height: 100%; margin: 0; } html, body, #root { min-width: 1024px; min-height: 100%; margin: 0; }
/* Below the supported minimum width/height the board no longer shrinks /* Below the supported minimum width/height the board no longer shrinks
(app-shell keeps a 1024x720 floor), so allow scrolling instead of clipping (app-shell keeps a 1024x720 floor), so allow scrolling instead of clipping
@@ -108,7 +125,7 @@ button:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; }
display: flex; display: flex;
transform: translateX(-50%); transform: translateX(-50%);
} }
.opponent-hand .card { margin-left: -17px; } .opponent-hand .card { margin-left: -14px; }
.opponent-hand .card:first-child { margin-left: 0; } .opponent-hand .card:first-child { margin-left: 0; }
.menu-wrap { position: absolute; top: 16px; right: 24px; z-index: 20; } .menu-wrap { position: absolute; top: 16px; right: 24px; z-index: 20; }
@@ -316,28 +333,10 @@ button.lane__discard:disabled { opacity: 1; }
pointer-events: none; pointer-events: none;
} }
/* The hinted card, its destination, and its draw source. */
.card--hinted { box-shadow: 0 0 0 3px var(--gold), 0 0 26px rgba(201, 163, 75, 0.35); }
.lane__zone.is-hinted,
.lane__discard.is-hinted,
.deck-stack.is-hinted { animation: target-pulse 1.6s ease-in-out infinite; }
.lane__zone.is-hinted .lane__ghost,
.lane__discard.is-hinted .lane__ghost {
border-color: var(--gold);
border-style: dashed;
opacity: 0.9;
}
.lane__zone.is-hinted .card,
.lane__discard.is-hinted .card,
.deck-stack.is-hinted .card { box-shadow: 0 0 0 2px var(--gold), 0 0 26px rgba(201, 163, 75, 0.3); }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.lane__zone.is-target, .lane__zone.is-target,
.lane__discard.is-target, .lane__discard.is-target,
.lane__discard.is-draw-target, .lane__discard.is-draw-target,
.lane__zone.is-hinted,
.lane__discard.is-hinted,
.deck-stack.is-hinted,
.deck-stack.is-draw-target { animation: none; } .deck-stack.is-draw-target { animation: none; }
} }
@@ -367,15 +366,28 @@ button.card:disabled { cursor: default; }
} }
.card__corner { .card__corner {
position: absolute; position: absolute;
left: 8px; left: 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
color: var(--card-color); color: var(--card-color);
font-family: Georgia, serif; font-family: Georgia, serif;
} }
.card__corner--top { top: 7px; } /* Cards overlap left-over-right, so the only strip that stays visible is each
card's left edge -- exactly where the top corner sits. It carries a small ivory
backing so the index reads even when it falls over the card behind it, which is
what lets the hand overlap tightly and still be legible. */
.card__corner--top {
top: 6px;
z-index: 2;
padding: 2px 5px 3px;
border-radius: 7px;
background: color-mix(in srgb, var(--ivory) 82%, transparent);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}
.card__corner--bottom { right: 8px; bottom: 7px; left: auto; } .card__corner--bottom { right: 8px; bottom: 7px; left: auto; }
.card__corner--top b { font-size: 20px; font-weight: 700; }
.card__corner--top i { margin-top: 0; font-size: 13px; }
.card__corner b { font-size: 18px; line-height: 1; } .card__corner b { font-size: 18px; line-height: 1; }
.card__corner i { margin-top: 1px; font-size: 12px; font-style: normal; line-height: 1; } .card__corner i { margin-top: 1px; font-size: 12px; font-style: normal; line-height: 1; }
.card__center { .card__center {
@@ -418,11 +430,25 @@ button.card:disabled { cursor: default; }
border-width: 2px; border-width: 2px;
border-radius: 5px; border-radius: 5px;
} }
.card--mini .card__corner { display: none; } /* The rival's revealed cards overlap -14px, leaving a ~30px strip. The centered
.card--mini .card__center { inset: 4px; } value is buried on every card but the top one, so a small top-left index rides
.card--mini .card__center b { font-size: 20px; } in the visible strip -- the same trick as the player's hand, scaled down. */
.card--mini .card__center i { margin-top: 2px; font-size: 11px; } .card--mini .card__corner--bottom { display: none; }
.card--mini .card__center--handshake i { font-size: 19px; } .card--mini .card__corner--top {
top: 3px;
left: 3px;
padding: 1px 3px 2px;
border-radius: 5px;
flex-direction: row;
gap: 2px;
align-items: baseline;
}
.card--mini .card__corner--top b { font-size: 13px; }
.card--mini .card__corner--top i { font-size: 8px; }
.card--mini .card__center { inset: 4px; top: 14px; }
.card--mini .card__center b { font-size: 17px; }
.card--mini .card__center i { margin-top: 2px; font-size: 10px; }
.card--mini .card__center--handshake i { font-size: 16px; }
.card--mini .card__center small { display: none; } .card--mini .card__center small { display: none; }
.card--mini::after { inset: 3px; border-radius: 3px; } .card--mini::after { inset: 3px; border-radius: 3px; }
.card--mini .card__flip-cover { border-radius: 5px; } .card--mini .card__flip-cover { border-radius: 5px; }
@@ -472,7 +498,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 > 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); } .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 { .prompt-row {
position: absolute; position: absolute;
right: 330px; right: 330px;
@@ -497,20 +523,6 @@ button.card:disabled { cursor: default; }
.turn-prompt--thinking { color: #c6b887; } .turn-prompt--thinking { color: #c6b887; }
.turn-prompt--review { color: #9fb4c8; } .turn-prompt--review { color: #9fb4c8; }
.hint-button {
flex: 0 0 auto;
height: 32px;
padding: 0 14px;
border: 1px solid var(--gold-dim);
border-radius: 9px;
background: rgba(7, 13, 12, 0.9);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.16em;
cursor: pointer;
}
.hint-button:hover:not(:disabled) { border-color: var(--gold); }
.hint-button:disabled { opacity: 0.35; cursor: default; }
.hint-button.is-active { border-color: var(--gold); color: var(--gold); }
.human-hand { .human-hand {
position: absolute; position: absolute;
@@ -610,23 +622,25 @@ button.card:disabled { cursor: default; }
.result-card button:hover { border-color: var(--gold); } .result-card button:hover { border-color: var(--gold); }
.result-card button.is-primary { border-color: var(--gold); 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 /* Player controls share the hand row's bottom edge and stay in the mirrored
computed against the score plaques, so a wide bar down at the hand's level right-side keep-out reserved by the centered hand layout. */
would overlap the leftmost/rightmost cards. */ .control-stack {
.history-bar {
position: absolute; position: absolute;
right: 24px; right: 24px;
bottom: 196px; bottom: 30px;
z-index: 12; z-index: 12;
display: flex; display: flex;
align-items: center; width: 142px;
flex-direction: column;
align-items: stretch;
gap: 8px; gap: 8px;
} }
.history-bar button { .control-stack button {
height: 32px; height: 32px;
padding: 0 11px; padding: 0 11px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 6px; gap: 6px;
border: 1px solid var(--gold-dim); border: 1px solid var(--gold-dim);
border-radius: 9px; border-radius: 9px;
@@ -634,15 +648,11 @@ button.card:disabled { cursor: default; }
font: 700 13px/1 Georgia, serif; font: 700 13px/1 Georgia, serif;
cursor: pointer; cursor: pointer;
} }
.history-bar button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; } .control-stack button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.history-bar button:hover:not(:disabled) { border-color: var(--gold); } .control-stack button:hover:not(:disabled) { border-color: var(--gold); }
.history-bar button:disabled { opacity: 0.3; cursor: default; } .control-stack button:disabled { opacity: 0.3; cursor: default; }
.history-bar__resume { .control-stack__score { visibility: hidden; }
border-color: var(--gold) !important; .control-stack__score.is-available { visibility: visible; }
color: var(--gold);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.14em;
}
@media (max-width: 1250px) { @media (max-width: 1250px) {
.table-center { width: 600px; } .table-center { width: 600px; }
@@ -651,7 +661,6 @@ button.card:disabled { cursor: default; }
.human-hand .card { width: 98px; height: 142px; margin-left: 7px; } .human-hand .card { width: 98px; height: 142px; margin-left: 7px; }
.human-hand { height: 146px; } .human-hand { height: 146px; }
.prompt-row { bottom: 177px; } .prompt-row { bottom: 177px; }
.history-bar { bottom: 172px; }
.table-center { bottom: 210px; } .table-center { bottom: 210px; }
/* Narrow viewports: the plaques are the keep-out that squeezes the hand row, /* 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 so they become compact chips (model-name line dropped) to give the cards
@@ -675,7 +684,96 @@ button.card:disabled { cursor: default; }
.human-hand { bottom: 18px; height: 138px; } .human-hand { bottom: 18px; height: 138px; }
.human-hand .card { width: 94px; height: 134px; } .human-hand .card { width: 94px; height: 134px; }
.prompt-row { bottom: 165px; } .prompt-row { bottom: 165px; }
.history-bar { bottom: 160px; } .control-stack { bottom: 18px; }
.score-plaque--human { bottom: 9px; } .score-plaque--human { bottom: 9px; }
.deck-stack { transform: scale(0.86); } .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. */
/* Tucked under the rival plaque on the left, clear of the opponent hand which
sits centered along the top and would otherwise cover it. */
.round-strip {
position: absolute;
top: 88px;
left: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
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: 9;
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;
}
/* The undo/redo settings switch. Off by default: taking moves back is a training
aid, and the honest game is the sensible default. */
.menu-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
cursor: pointer;
}
.menu-toggle input { width: 15px; height: 15px; accent-color: var(--gold); cursor: pointer; }
.menu-toggle span { padding: 0; color: #9a9d97; }
+12 -1
View File
@@ -153,11 +153,22 @@ export function useCardMotion(): CardMotion {
continue; 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(); const to = element.getBoundingClientRect();
next.set(card, to); next.set(card, to);
if (reduced) continue; if (reduced) continue;
const from = rects.current.get(card); const from = previous;
if (from) { if (from) {
animateMove(element, from, to); animateMove(element, from, to);
} else if (deckRect) { } else if (deckRect) {