Compare commits
10
Commits
0c83824243
...
7fc15ff46a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc15ff46a | ||
|
|
774faf2b51 | ||
|
|
c8fa89dab9 | ||
|
|
267db367d8 | ||
|
|
5e177cc369 | ||
|
|
3e7dda0d22 | ||
|
|
138d7c858d | ||
|
|
1d3b29aadb | ||
|
|
8dec6c3fe1 | ||
|
|
0fb6e3c1a6 |
+7
-1
@@ -15,6 +15,9 @@ src/**/*.c
|
||||
# Local session / lock files
|
||||
.compute.lock
|
||||
.claude/
|
||||
# ...except checked-in project skills, which are documentation for the next session.
|
||||
!web/.claude/
|
||||
!web/.claude/**
|
||||
|
||||
# Rust build output
|
||||
target/
|
||||
@@ -29,10 +32,13 @@ tools/julia/
|
||||
.ruff_cache
|
||||
|
||||
# Web dependencies and locally exported models. The verified public browser
|
||||
# policy below is the one exception: it is deliberately served as a static asset.
|
||||
# policies below are the exception: they are deliberately served as static assets,
|
||||
# and the deploy builds straight from the repo, so an ignored model ships as a 404.
|
||||
web/node_modules/
|
||||
web/*.tsbuildinfo
|
||||
web/public/models/*.onnx
|
||||
web/public/models/*.json
|
||||
!web/public/models/jax-ppo.onnx
|
||||
!web/public/models/jax-ppo.json
|
||||
!web/public/models/borealis.onnx
|
||||
!web/public/models/borealis.json
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,3 +581,37 @@ Fable은 이것을 "가장 큰 누락 아이디어"로 꼽았다. 실측은 **
|
||||
**흥미로운 점:** 우리는 **순수 셀프플레이**이고 league는 **착취자 구조를 학습에 넣은** 런인데,
|
||||
그런데도 우리가 덜 착취당한다. 착취자 구조가 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는 뚫리고 우리는 안 뚫린다."**
|
||||
- ❌ "우리 정책은 착취 불가능하다." — 하한선일 뿐이고, 우리 쪽 곡선은 아직 오르는 중이다.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
+313
-156
@@ -1,29 +1,53 @@
|
||||
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 { ResultCard } from "./components/ResultCard";
|
||||
import { describeAction } from "./game/actions";
|
||||
import { ResultCard, RoundOverlay } from "./components/Scoreboard";
|
||||
import { cardColor, cardName } from "./game/cards";
|
||||
import {
|
||||
boardScore,
|
||||
currentHandSorted,
|
||||
decodeAction,
|
||||
encodeAction,
|
||||
legalActionMask,
|
||||
previewPlacement,
|
||||
resetFromOrder,
|
||||
step,
|
||||
} from "./game/engine";
|
||||
import { deckOrderFromSeed, normalizeSeed, randomSeed } from "./game/random";
|
||||
import { DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types";
|
||||
import {
|
||||
N_ROUNDS,
|
||||
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 { useCardMotion } from "./ui/useCardMotion";
|
||||
|
||||
interface Hint {
|
||||
action: number;
|
||||
text: string;
|
||||
probability: number | null;
|
||||
/** One deal, or the classic three-round match decided on the summed total. */
|
||||
export type Mode = 1 | 3;
|
||||
|
||||
const MODE_KEY = "lost-cities.mode";
|
||||
|
||||
function loadMode(): Mode {
|
||||
const requested = new URLSearchParams(window.location.search).get("rounds");
|
||||
if (requested === "3") return 3;
|
||||
if (requested === "1") return 1;
|
||||
// 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 };
|
||||
@@ -36,8 +60,9 @@ const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null };
|
||||
* makes a finished game reviewable ply by ply.
|
||||
*/
|
||||
interface Frame {
|
||||
state: GameState;
|
||||
state: MatchState;
|
||||
selection: Selection;
|
||||
move?: { player: 0 | 1; action: number };
|
||||
}
|
||||
|
||||
// The hand row is centered, so the binding constraint is the score plaque
|
||||
@@ -66,15 +91,12 @@ function handCardStep(viewportWidth: number, count: number): number {
|
||||
return Math.max(minVisible, Math.min(relaxed, fitted));
|
||||
}
|
||||
|
||||
/** A `?seed=` in the URL loads that exact deal, so a game can be shared or replayed. */
|
||||
function initialSeed(): string {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get("seed");
|
||||
const seed = fromUrl === null ? "" : normalizeSeed(fromUrl);
|
||||
return seed === "" ? randomSeed() : seed;
|
||||
}
|
||||
|
||||
function openingFrame(seed: string): Frame {
|
||||
return { state: resetFromOrder(deckOrderFromSeed(seed)), selection: EMPTY_SELECTION };
|
||||
/** A `?seed=` in the URL loads that exact deal, so a game can be shared or replayed.
|
||||
* In match mode the seed fixes all three deals and the coin flips too, so the
|
||||
* whole match is a pure function of it. */
|
||||
function openingFrame(seed: string, mode: Mode): Frame {
|
||||
const { deckOrders, coinFlips } = matchFromSeed(seed);
|
||||
return { state: matchFromOrders(deckOrders, coinFlips, mode), selection: EMPTY_SELECTION };
|
||||
}
|
||||
|
||||
/** Keep the address bar in sync so the current deal stays shareable. */
|
||||
@@ -84,29 +106,72 @@ function publishSeed(seed: string): void {
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
function newGameId(): string {
|
||||
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
||||
if (typeof crypto.getRandomValues === "function") {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
return `game-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function initialGame(): Pick<SavedGame, "seed" | "frames" | "cursor" | "resultOpen" | "mode"> {
|
||||
const requested = new URLSearchParams(window.location.search).get("seed");
|
||||
const mode = loadMode();
|
||||
const saved = parseSavedGame(window.localStorage.getItem(SAVED_GAME_KEY));
|
||||
const requestedSeed = requested === null ? null : normalizeSeed(requested);
|
||||
// A saved game only resumes into the mode it was played in.
|
||||
if (saved && saved.mode === mode && (requestedSeed === null || requestedSeed === saved.seed)) {
|
||||
return saved;
|
||||
}
|
||||
const seed = requestedSeed || randomSeed();
|
||||
return { seed, mode, frames: [openingFrame(seed, mode)], cursor: 0, resultOpen: true };
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [seed, setSeed] = useState<string>(initialSeed);
|
||||
const [frames, setFrames] = useState<Frame[]>(() => [openingFrame(seed)]);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [initial] = useState(initialGame);
|
||||
const [seed, setSeed] = useState(initial.seed);
|
||||
const [mode, setMode] = useState<Mode>(initial.mode as Mode);
|
||||
const [frames, setFrames] = useState<Frame[]>(initial.frames);
|
||||
const [cursor, setCursor] = useState(initial.cursor);
|
||||
const [seedDraft, setSeedDraft] = useState("");
|
||||
const [resultOpen, setResultOpen] = useState(true);
|
||||
const [resultOpen, setResultOpen] = useState(initial.resultOpen);
|
||||
const [policy, setPolicy] = useState<Policy | null>(null);
|
||||
const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO");
|
||||
const [modelMessage, setModelMessage] = useState("BOREALIS 불러오는 중");
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
|
||||
const [hint, setHint] = useState<Hint | null>(null);
|
||||
const [hintPending, setHintPending] = useState(false);
|
||||
const [undoEnabled, setUndoEnabled] = useState(loadUndoEnabled);
|
||||
const generation = useRef(0);
|
||||
const gameId = useRef(newGameId());
|
||||
const startedAt = useRef(new Date().toISOString());
|
||||
const loggedGame = useRef<string | null>(null);
|
||||
const { cardRef, deckRef, overlayRef, resetMotion } = useCardMotion();
|
||||
|
||||
const { state, selection } = frames[cursor];
|
||||
const canUndo = cursor > 0;
|
||||
const canRedo = cursor < frames.length - 1;
|
||||
const latestState = useRef(state);
|
||||
const { state: match, selection } = frames[cursor];
|
||||
// The board on screen is the round in play; the match is what decides the game.
|
||||
const state = match.round;
|
||||
// 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(() => { 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(() => {
|
||||
function onResize() { setViewportWidth(window.innerWidth); }
|
||||
@@ -116,7 +181,7 @@ function App() {
|
||||
|
||||
const humanHand = useMemo(() => currentHandSorted(state, 0), [state]);
|
||||
const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]);
|
||||
const legal = useMemo(() => legalActionMask(state), [state]);
|
||||
const legal = useMemo(() => matchLegalActionMask(match), [match]);
|
||||
const selectedCard = selection.handSlot === null ? null : humanHand[selection.handSlot];
|
||||
const selectedColor = selectedCard === null ? null : cardColor(selectedCard);
|
||||
const displayState = useMemo(
|
||||
@@ -125,14 +190,23 @@ function App() {
|
||||
: previewPlacement(state, selection.handSlot, selection.placeType),
|
||||
[selection, state],
|
||||
);
|
||||
const scores = useMemo(() => boardScore(displayState), [displayState]);
|
||||
/** This round's board. */
|
||||
const roundScores = useMemo(() => boardScore(displayState), [displayState]);
|
||||
/** Rounds already banked plus the board in play — the number that decides a match. */
|
||||
const scores = useMemo(
|
||||
(): [number, number] => [
|
||||
match.carry[0] + roundScores[0],
|
||||
match.carry[1] + roundScores[1],
|
||||
],
|
||||
[match.carry, roundScores],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadPolicy().then(({ policy: loaded, warning }) => {
|
||||
if (cancelled) return;
|
||||
setPolicy(loaded);
|
||||
setModelMessage(warning ?? `${loaded.provider.toUpperCase()} · FINAL PPO`);
|
||||
setModelMessage(warning ?? "BOREALIS");
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
@@ -149,45 +223,64 @@ function App() {
|
||||
const rivalSuspended = canRedo;
|
||||
|
||||
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 timer = window.setTimeout(async () => {
|
||||
setThinking(true);
|
||||
try {
|
||||
let action: number;
|
||||
try {
|
||||
action = await policy.action(state);
|
||||
action = await policy.action(match);
|
||||
} catch (error) {
|
||||
console.error("AI action failed; falling back to heuristic policy", error);
|
||||
const fallback = fallbackHeuristicPolicy();
|
||||
action = await fallback.action(state);
|
||||
action = await fallback.action(match);
|
||||
if (generation.current !== currentGeneration) return;
|
||||
setPolicy(fallback);
|
||||
setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)");
|
||||
setModelMessage("휴리스틱 대체 (모델 오류)");
|
||||
}
|
||||
if (generation.current !== currentGeneration) return;
|
||||
pushFrame({ state: step(state, action), selection: EMPTY_SELECTION });
|
||||
pushFrame({
|
||||
state: matchStep(match, action),
|
||||
selection: EMPTY_SELECTION,
|
||||
move: { player: 1, action },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("AI action failed even with heuristic fallback", error);
|
||||
setModelMessage("MODEL INFERENCE ERROR");
|
||||
setModelMessage("모델 추론 오류");
|
||||
} finally {
|
||||
if (generation.current === currentGeneration) setThinking(false);
|
||||
}
|
||||
}, 620);
|
||||
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
|
||||
* reproduces the same deal, so `restart(seed)` also replays the current one. */
|
||||
function restart(nextSeed: string = randomSeed()) {
|
||||
function restart(nextSeed: string = randomSeed(), nextMode: Mode = mode) {
|
||||
generation.current += 1;
|
||||
resetMotion();
|
||||
gameId.current = newGameId();
|
||||
startedAt.current = new Date().toISOString();
|
||||
loggedGame.current = null;
|
||||
setSeed(nextSeed);
|
||||
setFrames([openingFrame(nextSeed)]);
|
||||
setMode(nextMode);
|
||||
setFrames([openingFrame(nextSeed, nextMode)]);
|
||||
setCursor(0);
|
||||
setSeedDraft("");
|
||||
setResultOpen(true);
|
||||
setHint(null);
|
||||
setThinking(false);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
@@ -201,66 +294,29 @@ function App() {
|
||||
if (next < 0 || next >= frames.length) return;
|
||||
generation.current += 1;
|
||||
setCursor(next);
|
||||
setHint(null);
|
||||
setThinking(false);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
const undo = () => moveCursor(cursor - 1);
|
||||
const redo = () => 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]);
|
||||
const undo = () => { if (canUndo) moveCursor(cursor - 1); };
|
||||
const redo = () => { if (canRedo) moveCursor(cursor + 1); };
|
||||
|
||||
function chooseCard(handSlot: number) {
|
||||
if (state.toMove !== 0 || state.done) return;
|
||||
if (state.toMove !== 0 || match.done) return;
|
||||
const next = selection.handSlot !== handSlot || selection.placeType !== null
|
||||
? { handSlot, placeType: null }
|
||||
: EMPTY_SELECTION;
|
||||
pushFrame({ state, selection: next });
|
||||
pushFrame({ state: match, selection: next });
|
||||
}
|
||||
|
||||
function choosePlace(placeType: PlaceType) {
|
||||
if (selection.handSlot === null) return;
|
||||
pushFrame({ state, selection: { ...selection, placeType } });
|
||||
pushFrame({ state: match, selection: { ...selection, placeType } });
|
||||
}
|
||||
|
||||
function cancelPlace() {
|
||||
if (selection.handSlot === null || selection.placeType === null) return;
|
||||
pushFrame({ state, selection: { ...selection, placeType: null } });
|
||||
pushFrame({ state: match, selection: { ...selection, placeType: null } });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -282,7 +338,11 @@ function App() {
|
||||
function commit(drawSource: number) {
|
||||
if (!legalDraw(drawSource) || selection.handSlot === null || selection.placeType === null) return;
|
||||
const action = encodeAction(selection.handSlot, selection.placeType, drawSource);
|
||||
pushFrame({ state: step(state, action), selection: EMPTY_SELECTION });
|
||||
pushFrame({
|
||||
state: matchStep(match, action),
|
||||
selection: EMPTY_SELECTION,
|
||||
move: { player: 0, action },
|
||||
});
|
||||
}
|
||||
|
||||
// Cards actually drawn in the hand row (the selected card moves into the
|
||||
@@ -299,44 +359,100 @@ function App() {
|
||||
(_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)],
|
||||
).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]
|
||||
? "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
|
||||
// will happen until the move is redone or play is resumed — say so. On your own
|
||||
// turn the normal prompt still applies: you can simply play on from here.
|
||||
const reviewingRival = rivalSuspended && (state.done || state.toMove === 1);
|
||||
const reviewingRival = rivalSuspended && (match.done || state.toMove === 1);
|
||||
const status = reviewingRival
|
||||
? `Reviewing ply ${state.stepCount} — redo, or play on from here`
|
||||
: state.done
|
||||
? "되돌리는 중 — 다시 진행하려면 앞으로"
|
||||
: match.done
|
||||
? outcome
|
||||
: match.roundComplete
|
||||
? `${match.roundIdx + 1}라운드 종료`
|
||||
: state.toMove === 1
|
||||
? thinking ? "The rival is thinking ···" : "The rival's turn"
|
||||
? thinking ? "상대가 생각 중이에요" : "상대 차례"
|
||||
: selection.handSlot === null
|
||||
? "Play or discard a card"
|
||||
? "낼 카드나 버릴 카드를 고르세요"
|
||||
: selection.placeType === null
|
||||
? `Choose a destination for ${cardName(selectedCard!)}`
|
||||
: "Draw — deck or a discard pile";
|
||||
? `${cardName(selectedCard!)} 놓을 곳을 고르세요`
|
||||
: "카드를 뽑으세요 — 덱 또는 버림패";
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="score-plaque score-plaque--rival" aria-label={`Rival score ${scores[1]}`}>
|
||||
<div><strong>THE RIVAL</strong><small>{modelMessage}</small></div><b>{scores[1]}</b>
|
||||
<section className="score-plaque score-plaque--rival" aria-label={`상대 점수 ${scores[1]}`}>
|
||||
<div><strong>상대</strong><small>{modelMessage}</small></div><b>{scores[1]}</b>
|
||||
</section>
|
||||
|
||||
<div className="opponent-hand" aria-label="Rival hand">
|
||||
<div className="opponent-hand" aria-label="상대 손패">
|
||||
{opponentHand.map((card) => (
|
||||
state.handPublic[card]
|
||||
? <Card mini card={card} key={card} innerRef={cardRef(card)} />
|
||||
@@ -345,28 +461,64 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="menu-wrap">
|
||||
<button className="menu-button" onClick={() => setMenuOpen((open) => !open)}>MENU</button>
|
||||
<button className="menu-button" onClick={() => setMenuOpen((open) => !open)}>메뉴</button>
|
||||
{menuOpen && (
|
||||
<div className="menu-popover">
|
||||
<button onClick={() => restart()}>NEW GAME</button>
|
||||
<button onClick={() => restart(seed)}>REPLAY THIS SEED</button>
|
||||
<button onClick={() => restart()}>새 게임</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">
|
||||
<span>DEAL SEED · {seed}</span>
|
||||
<span>시드 · {seed}</span>
|
||||
<input
|
||||
value={seedDraft}
|
||||
placeholder="load a deal seed…"
|
||||
placeholder="시드 입력…"
|
||||
onChange={(event) => setSeedDraft(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") loadSeed(); }}
|
||||
aria-label="Load a deal by seed"
|
||||
aria-label="시드로 딜 불러오기"
|
||||
/>
|
||||
</label>
|
||||
<button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}>DEAL THIS SEED</button>
|
||||
<span>THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC</span>
|
||||
<span>{policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}</span>
|
||||
<button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}>이 시드로 시작</button>
|
||||
<label className="menu-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={undoEnabled}
|
||||
onChange={(event) => setUndoEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>되돌리기 / 다시 · 전체 되감기</span>
|
||||
</label>
|
||||
</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">
|
||||
<Board
|
||||
state={displayState}
|
||||
@@ -374,7 +526,6 @@ function App() {
|
||||
selectedPlace={selection.placeType}
|
||||
canPlay={canPlaySelected}
|
||||
canDraw={legalDraw}
|
||||
hint={boardHint}
|
||||
cardRef={cardRef}
|
||||
onChoosePlace={choosePlace}
|
||||
onCancelPlace={cancelPlace}
|
||||
@@ -384,38 +535,29 @@ function App() {
|
||||
|
||||
<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)}
|
||||
onClick={() => commit(DRAW_DECK)}
|
||||
aria-label={`Draw from deck, ${N_CARDS - state.drawPtr} cards left`}
|
||||
aria-label={`덱에서 뽑기, ${N_CARDS - state.drawPtr}장 남음`}
|
||||
>
|
||||
<CardBack innerRef={deckRef} />
|
||||
<strong>{N_CARDS - state.drawPtr}</strong>
|
||||
<span>CARDS LEFT</span>
|
||||
<span>남은 카드</span>
|
||||
</button>
|
||||
|
||||
<div className="prompt-row">
|
||||
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""} ${reviewingRival ? "turn-prompt--review" : ""}`}>
|
||||
{hint ? `Hint — ${hint.text}${hintConfidence ? ` (${hintConfidence})` : ""}` : status}
|
||||
{status}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`hint-button ${hint ? "is-active" : ""}`}
|
||||
onClick={requestHint}
|
||||
disabled={!canHint || hintPending}
|
||||
>
|
||||
{hintPending ? "THINKING…" : "HINT"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="human-hand" aria-label="Your hand">
|
||||
<section className="human-hand" aria-label="내 손패">
|
||||
{renderedHand.map(({ card, slot }, position) => (
|
||||
<Card
|
||||
card={card}
|
||||
key={card}
|
||||
selected={selection.handSlot === slot}
|
||||
hinted={hintedCard === card}
|
||||
disabled={state.toMove !== 0 || state.done}
|
||||
disabled={state.toMove !== 0 || match.done}
|
||||
onClick={() => chooseCard(slot)}
|
||||
style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }}
|
||||
innerRef={cardRef(card)}
|
||||
@@ -423,37 +565,52 @@ function App() {
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="score-plaque score-plaque--human" aria-label={`Your score ${scores[0]}`}>
|
||||
<div><strong>YOU <i /></strong><small>{state.toMove === 0 && !state.done ? "YOUR TURN" : "EXPEDITION LEAD"}</small></div><b>{scores[0]}</b>
|
||||
<section className="score-plaque score-plaque--human" aria-label={`내 점수 ${scores[0]}`}>
|
||||
<div><strong>나 <i /></strong><small>{state.toMove === 0 && !match.done ? "내 차례" : "현재 점수"}</small></div><b>{scores[0]}</b>
|
||||
</section>
|
||||
|
||||
<div className="history-bar">
|
||||
<button type="button" onClick={undo} disabled={!canUndo} aria-label="Undo one action" title="Undo (←)">
|
||||
↶ <span>UNDO</span>
|
||||
<div className="control-stack">
|
||||
{undoEnabled && (
|
||||
<>
|
||||
<button type="button" onClick={undo} disabled={!canUndo} aria-label="한 단계 되돌리기" title="되돌리기 (←)">
|
||||
↶ <span>되돌리기</span>
|
||||
</button>
|
||||
<button type="button" onClick={redo} disabled={!canRedo} aria-label="Redo one action" title="Redo (→)">
|
||||
↷ <span>REDO</span>
|
||||
</button>
|
||||
{rivalSuspended && (
|
||||
<button type="button" className="history-bar__resume" onClick={resumeFromHere}>
|
||||
PLAY FROM HERE
|
||||
<button type="button" onClick={redo} disabled={!canRedo} aria-label="한 단계 다시" title="다시 (→)">
|
||||
↷ <span>다시</span>
|
||||
</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>
|
||||
|
||||
{/* Cards in flight back to the deck (undo of a draw) are re-parented here
|
||||
after React has already removed them from the table. */}
|
||||
<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
|
||||
state={state}
|
||||
outcome={outcome}
|
||||
seed={seed}
|
||||
onReview={() => setResultOpen(false)}
|
||||
roundHistory={mode > 1 ? match.roundHistory : [roundScore(match)]}
|
||||
totalRounds={mode}
|
||||
onPlayAgain={() => restart()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,20 +9,12 @@ import {
|
||||
import { cardsOnBoard } from "../game/engine";
|
||||
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 {
|
||||
state: GameState;
|
||||
selectedColor: number | null;
|
||||
selectedPlace: PlaceType | null;
|
||||
canPlay: boolean;
|
||||
canDraw: (source: number) => boolean;
|
||||
hint: BoardHint | null;
|
||||
cardRef: (card: number) => (element: HTMLElement | null) => void;
|
||||
onChoosePlace: (place: PlaceType) => void;
|
||||
onCancelPlace: () => void;
|
||||
@@ -58,7 +50,6 @@ export function Board({
|
||||
selectedPlace,
|
||||
canPlay,
|
||||
canDraw,
|
||||
hint,
|
||||
cardRef,
|
||||
onChoosePlace,
|
||||
onCancelPlace,
|
||||
@@ -75,9 +66,6 @@ export function Board({
|
||||
const drawTarget = selectedPlace !== null && canDraw(color + 1);
|
||||
const discardChosen = selectedPlace === DISCARD && 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 (
|
||||
<article
|
||||
className="lane"
|
||||
@@ -97,7 +85,7 @@ export function Board({
|
||||
|
||||
<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}
|
||||
disabled={!discardTarget && !drawTarget && !discardChosen}
|
||||
aria-label={discardChosen ? `Cancel discarding to ${name} pile` : `${name} discard pile`}
|
||||
@@ -113,7 +101,7 @@ export function Board({
|
||||
|
||||
<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}
|
||||
disabled={!playTarget && !playChosen}
|
||||
aria-label={playChosen ? `Cancel playing to your ${name} expedition` : `Your ${name} expedition`}
|
||||
|
||||
@@ -14,7 +14,6 @@ interface CardProps {
|
||||
* hand row is only as tall as a card back, so a full card would burst it. */
|
||||
mini?: boolean;
|
||||
selected?: boolean;
|
||||
hinted?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
@@ -26,7 +25,6 @@ export function Card({
|
||||
compact = false,
|
||||
mini = false,
|
||||
selected = false,
|
||||
hinted = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
style: styleOverride,
|
||||
@@ -35,7 +33,7 @@ export function Card({
|
||||
const color = cardColor(card);
|
||||
const value = cardValue(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 content = (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { CARDS_PER_COLOR } from "./types";
|
||||
|
||||
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_KO = ["화산", "바다", "정글", "사막", "동굴"] as const;
|
||||
export const COLOR_HEX = ["#b94737", "#3569a7", "#397b4e", "#b37d20", "#704487"] as const;
|
||||
export const COLOR_GLYPHS = ["▲", "≋", "♧", "◆", "⬟"] as const;
|
||||
|
||||
|
||||
@@ -32,7 +32,11 @@ export function shuffledDeck(random: () => number = Math.random): number[] {
|
||||
return deck;
|
||||
}
|
||||
|
||||
export function resetFromOrder(deckOrder: number[]): GameState {
|
||||
/**
|
||||
* `firstPlayer` moves first. Rounds two and three of a classic match are led by
|
||||
* whoever is ahead on points, so the match layer sets this per round.
|
||||
*/
|
||||
export function resetFromOrder(deckOrder: number[], firstPlayer: 0 | 1 | number = 0): GameState {
|
||||
if (deckOrder.length !== N_CARDS || new Set(deckOrder).size !== N_CARDS) {
|
||||
throw new Error("deckOrder must be a permutation of 0..59");
|
||||
}
|
||||
@@ -48,7 +52,7 @@ export function resetFromOrder(deckOrder: number[]): GameState {
|
||||
colHandshakes: emptyMatrix(2, N_COLORS),
|
||||
colLength: emptyMatrix(2, N_COLORS),
|
||||
piles: Array.from({ length: N_COLORS }, () => []),
|
||||
toMove: 0,
|
||||
toMove: (firstPlayer === 1 ? 1 : 0) as 0 | 1,
|
||||
stepCount: 0,
|
||||
done: false,
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ export function deckOrderFromSeed(seed: string): number[] {
|
||||
return shuffledDeck(mulberry32(hashSeed(seed)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every deal and coin flip of a match, drawn up front from one seed.
|
||||
*
|
||||
* Drawing them all now — rather than shuffling again when a round rolls over —
|
||||
* is what keeps a seed reproducible: the whole match is a pure function of it.
|
||||
* The first deal is `deckOrderFromSeed`, so a match and a one-off deal on the
|
||||
* same seed open on the same position.
|
||||
*/
|
||||
export function matchFromSeed(seed: string): { deckOrders: number[][]; coinFlips: number[] } {
|
||||
const random = mulberry32(hashSeed(seed));
|
||||
const deckOrders = [shuffledDeck(random), shuffledDeck(random), shuffledDeck(random)];
|
||||
const coinFlips = [0, 1, 2].map(() => (random() < 0.5 ? 0 : 1));
|
||||
return { deckOrders, coinFlips };
|
||||
}
|
||||
|
||||
/** A fresh shareable seed, e.g. "k3f9qa". */
|
||||
export function randomSeed(): string {
|
||||
return Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, "0");
|
||||
|
||||
+27
-14
@@ -1,13 +1,25 @@
|
||||
import * as ort from "onnxruntime-web/all";
|
||||
|
||||
import { currentHandSorted, legalActionMask } from "../game/engine";
|
||||
import { observation } from "../game/observation";
|
||||
import { DISCARD, DRAW_DECK, PLAY, type GameState } from "../game/types";
|
||||
import { currentHandSorted } from "../game/engine";
|
||||
import { matchLegalActionMask, type MatchState } from "../game/match";
|
||||
import { matchObservation } from "../game/matchObservation";
|
||||
import { DISCARD, DRAW_DECK, PLAY } from "../game/types";
|
||||
import { cardColor, cardRank, isHandshake } from "../game/cards";
|
||||
|
||||
export type ExecutionProvider = "webgpu" | "wasm" | "heuristic";
|
||||
|
||||
const MODEL_URL = `${import.meta.env.BASE_URL}models/jax-ppo.onnx`;
|
||||
/**
|
||||
* borealis -- see data/models.json. Trained on the three-round match, so it takes
|
||||
* the match view (carry, round, deck clock) rather than a bare round. A one-off
|
||||
* deal is simply its round one at a carry of zero, which is a position it has seen
|
||||
* a great many times.
|
||||
*/
|
||||
const MODEL_URL = `${import.meta.env.BASE_URL}models/borealis.onnx`;
|
||||
|
||||
/** Identity of what actually plays. Records carry the hash; the codename is for
|
||||
* humans and is assigned in data/models.json, not derived. */
|
||||
export const MODEL_CODENAME = "borealis";
|
||||
export const MODEL_HASH = "13a25243de1c";
|
||||
|
||||
/** A legal action with the policy's confidence in it, if the policy has one. */
|
||||
export interface RankedAction {
|
||||
@@ -18,16 +30,16 @@ export interface RankedAction {
|
||||
export interface Policy {
|
||||
readonly provider: ExecutionProvider;
|
||||
/** Legal actions, best first. Drives both the rival's move and the hint. */
|
||||
rank(state: GameState): Promise<RankedAction[]>;
|
||||
action(state: GameState): Promise<number>;
|
||||
rank(match: MatchState): Promise<RankedAction[]>;
|
||||
action(match: MatchState): Promise<number>;
|
||||
}
|
||||
|
||||
abstract class RankingPolicy implements Policy {
|
||||
abstract readonly provider: ExecutionProvider;
|
||||
abstract rank(state: GameState): Promise<RankedAction[]>;
|
||||
abstract rank(match: MatchState): Promise<RankedAction[]>;
|
||||
|
||||
async action(state: GameState): Promise<number> {
|
||||
const [best] = await this.rank(state);
|
||||
async action(match: MatchState): Promise<number> {
|
||||
const [best] = await this.rank(match);
|
||||
if (best === undefined) throw new Error("state has no legal actions");
|
||||
return best.action;
|
||||
}
|
||||
@@ -54,12 +66,12 @@ class OnnxPolicy extends RankingPolicy {
|
||||
super();
|
||||
}
|
||||
|
||||
async rank(state: GameState): Promise<RankedAction[]> {
|
||||
const obs = observation(state, state.toMove);
|
||||
async rank(match: MatchState): Promise<RankedAction[]> {
|
||||
const obs = matchObservation(match, match.round.toMove);
|
||||
const result = await this.session.run({ obs: new ort.Tensor("float32", obs, [1, obs.length]) });
|
||||
const logits = result.logits?.data;
|
||||
if (!logits) throw new Error("ONNX model did not return a logits output");
|
||||
const legal = legalActionMask(state);
|
||||
const legal = matchLegalActionMask(match);
|
||||
return softmaxOverLegal(legal.map((_, action) => Number(logits[action])), legal);
|
||||
}
|
||||
}
|
||||
@@ -67,8 +79,9 @@ class OnnxPolicy extends RankingPolicy {
|
||||
export class HeuristicPolicy extends RankingPolicy {
|
||||
readonly provider = "heuristic" as const;
|
||||
|
||||
async rank(state: GameState): Promise<RankedAction[]> {
|
||||
const legal = legalActionMask(state);
|
||||
async rank(match: MatchState): Promise<RankedAction[]> {
|
||||
const state = match.round;
|
||||
const legal = matchLegalActionMask(match);
|
||||
const hand = currentHandSorted(state);
|
||||
const ranked = legal.flatMap((isLegal, action) => {
|
||||
if (!isLegal) return [];
|
||||
|
||||
+157
-59
@@ -18,6 +18,23 @@
|
||||
}
|
||||
|
||||
* { 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; }
|
||||
/* Below the supported minimum width/height the board no longer shrinks
|
||||
(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;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.opponent-hand .card { margin-left: -17px; }
|
||||
.opponent-hand .card { margin-left: -14px; }
|
||||
.opponent-hand .card:first-child { margin-left: 0; }
|
||||
|
||||
.menu-wrap { position: absolute; top: 16px; right: 24px; z-index: 20; }
|
||||
@@ -316,28 +333,10 @@ button.lane__discard:disabled { opacity: 1; }
|
||||
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) {
|
||||
.lane__zone.is-target,
|
||||
.lane__discard.is-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; }
|
||||
}
|
||||
|
||||
@@ -367,15 +366,28 @@ button.card:disabled { cursor: default; }
|
||||
}
|
||||
.card__corner {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
left: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--card-color);
|
||||
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--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 i { margin-top: 1px; font-size: 12px; font-style: normal; line-height: 1; }
|
||||
.card__center {
|
||||
@@ -418,11 +430,25 @@ button.card:disabled { cursor: default; }
|
||||
border-width: 2px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.card--mini .card__corner { display: none; }
|
||||
.card--mini .card__center { inset: 4px; }
|
||||
.card--mini .card__center b { font-size: 20px; }
|
||||
.card--mini .card__center i { margin-top: 2px; font-size: 11px; }
|
||||
.card--mini .card__center--handshake i { font-size: 19px; }
|
||||
/* The rival's revealed cards overlap -14px, leaving a ~30px strip. The centered
|
||||
value is buried on every card but the top one, so a small top-left index rides
|
||||
in the visible strip -- the same trick as the player's hand, scaled down. */
|
||||
.card--mini .card__corner--bottom { display: none; }
|
||||
.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::after { inset: 3px; border-radius: 3px; }
|
||||
.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.is-draw-target .card { box-shadow: 0 0 0 3px var(--gold), 7px 8px 0 #171b1e, 0 0 32px rgba(201, 163, 75, 0.3); }
|
||||
|
||||
/* Insets keep the row clear of the history bar parked at the right. */
|
||||
/* Insets keep the prompt clear of the controls parked at the right. */
|
||||
.prompt-row {
|
||||
position: absolute;
|
||||
right: 330px;
|
||||
@@ -497,20 +523,6 @@ button.card:disabled { cursor: default; }
|
||||
.turn-prompt--thinking { color: #c6b887; }
|
||||
.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 {
|
||||
position: absolute;
|
||||
@@ -610,23 +622,25 @@ button.card:disabled { cursor: default; }
|
||||
.result-card button:hover { border-color: var(--gold); }
|
||||
.result-card button.is-primary { border-color: var(--gold); color: var(--gold); }
|
||||
|
||||
/* Sits in the band above the hand: the hand row is centered and its width is
|
||||
computed against the score plaques, so a wide bar down at the hand's level
|
||||
would overlap the leftmost/rightmost cards. */
|
||||
.history-bar {
|
||||
/* Player controls share the hand row's bottom edge and stay in the mirrored
|
||||
right-side keep-out reserved by the centered hand layout. */
|
||||
.control-stack {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 196px;
|
||||
bottom: 30px;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 142px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.history-bar button {
|
||||
.control-stack button {
|
||||
height: 32px;
|
||||
padding: 0 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--gold-dim);
|
||||
border-radius: 9px;
|
||||
@@ -634,15 +648,11 @@ button.card:disabled { cursor: default; }
|
||||
font: 700 13px/1 Georgia, serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-bar button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
|
||||
.history-bar button:hover:not(:disabled) { border-color: var(--gold); }
|
||||
.history-bar button:disabled { opacity: 0.3; cursor: default; }
|
||||
.history-bar__resume {
|
||||
border-color: var(--gold) !important;
|
||||
color: var(--gold);
|
||||
font: 700 9px Arial, sans-serif;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.control-stack button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
|
||||
.control-stack button:hover:not(:disabled) { border-color: var(--gold); }
|
||||
.control-stack button:disabled { opacity: 0.3; cursor: default; }
|
||||
.control-stack__score { visibility: hidden; }
|
||||
.control-stack__score.is-available { visibility: visible; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.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 { height: 146px; }
|
||||
.prompt-row { bottom: 177px; }
|
||||
.history-bar { bottom: 172px; }
|
||||
.table-center { bottom: 210px; }
|
||||
/* Narrow viewports: the plaques are the keep-out that squeezes the hand row,
|
||||
so they become compact chips (model-name line dropped) to give the cards
|
||||
@@ -675,7 +684,96 @@ button.card:disabled { cursor: default; }
|
||||
.human-hand { bottom: 18px; height: 138px; }
|
||||
.human-hand .card { width: 94px; height: 134px; }
|
||||
.prompt-row { bottom: 165px; }
|
||||
.history-bar { bottom: 160px; }
|
||||
.control-stack { bottom: 18px; }
|
||||
.score-plaque--human { bottom: 9px; }
|
||||
.deck-stack { transform: scale(0.86); }
|
||||
}
|
||||
|
||||
/* Match mode: which round is live, and what is already banked. The board only
|
||||
ever shows the round in play, so without this the score plaques would be the
|
||||
only hint that two more rounds are coming. */
|
||||
/* 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; }
|
||||
|
||||
@@ -153,11 +153,22 @@ export function useCardMotion(): CardMotion {
|
||||
continue;
|
||||
}
|
||||
|
||||
// getBoundingClientRect includes a transform applied by WAAPI. A render
|
||||
// while a deal/move is still in flight (for example when the policy
|
||||
// finishes loading) must retain the intended destination instead of
|
||||
// treating the animated visual position as a new layout and launching a
|
||||
// second flight.
|
||||
const previous = rects.current.get(card);
|
||||
if (element.getAnimations().some((animation) => animation.playState === "running")) {
|
||||
if (previous) next.set(card, previous);
|
||||
continue;
|
||||
}
|
||||
|
||||
const to = element.getBoundingClientRect();
|
||||
next.set(card, to);
|
||||
if (reduced) continue;
|
||||
|
||||
const from = rects.current.get(card);
|
||||
const from = previous;
|
||||
if (from) {
|
||||
animateMove(element, from, to);
|
||||
} else if (deckRect) {
|
||||
|
||||
Reference in New Issue
Block a user