Add on-device web client
This commit is contained in:
@@ -27,3 +27,9 @@ tools/julia/
|
||||
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
|
||||
# Web dependencies and locally exported deployment models
|
||||
web/node_modules/
|
||||
web/*.tsbuildinfo
|
||||
web/public/models/*.onnx
|
||||
web/public/models/*.json
|
||||
|
||||
@@ -10,8 +10,8 @@ The current implementation starts with the classic two-player card game:
|
||||
- random, discard-only, and safe-heuristic bots
|
||||
- core rule, scoring, mask, env, canonical-state, bot, and GUI smoke tests
|
||||
|
||||
Training code, Deep CFR, learned-policy evaluation, GUI, and web client are
|
||||
intentionally outside the first port.
|
||||
Training code, Deep CFR, learned-policy evaluation, desktop GUI, and an
|
||||
on-device web client now live alongside the original rules port.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -34,6 +34,24 @@ uv run lost-cities-classic-gui --mode pvc --bot safe-heuristic
|
||||
|
||||
The GUI uses the in-process Cython game engine.
|
||||
|
||||
## On-device Web Client
|
||||
|
||||
The `web/` app runs its TypeScript rules engine and exported JAX PPO policy
|
||||
entirely in the browser. It prefers WebGPU and falls back to WebAssembly.
|
||||
|
||||
Export a local Orbax checkpoint and start Vite:
|
||||
|
||||
```bash
|
||||
uv run --with onnx scripts/export_jax_ppo_onnx.py \
|
||||
--checkpoint /path/to/checkpoint \
|
||||
--output web/public/models/jax-ppo.onnx
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
See [web/README.md](web/README.md) for tests and model parity tooling.
|
||||
|
||||
## JAX Rules Engine
|
||||
|
||||
`lost_cities_jax` is a standalone pure rules simulator for one two-player
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export the actor head of a JAX PPO Orbax checkpoint to ONNX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import jax.numpy as jnp
|
||||
import numpy as np
|
||||
|
||||
from lost_cities_jax.human_play import infer_config_path, load_agent
|
||||
from lost_cities_jax.ppo import load_config
|
||||
from lost_cities_jax.types import N_ACTIONS, OBS_DIM
|
||||
|
||||
|
||||
def build_argparser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--checkpoint", type=Path, required=True)
|
||||
parser.add_argument("--config", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def export_model(checkpoint: Path, config: Path | None, output: Path) -> 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
|
||||
|
||||
config_path = infer_config_path(checkpoint) if config is None else config
|
||||
cfg = load_config(config_path)
|
||||
params, flax_model = load_agent(cfg, checkpoint)
|
||||
dense = params["params"]
|
||||
nodes = []
|
||||
initializers = []
|
||||
previous = "obs"
|
||||
|
||||
for index in range(cfg.network.num_layers):
|
||||
layer = dense[f"Dense_{index}"]
|
||||
weight_name = f"dense_{index}.weight"
|
||||
bias_name = f"dense_{index}.bias"
|
||||
linear_name = f"dense_{index}.linear"
|
||||
output_name = f"dense_{index}.relu"
|
||||
initializers.extend(
|
||||
[
|
||||
numpy_helper.from_array(np.asarray(layer["kernel"], dtype=np.float32), weight_name),
|
||||
numpy_helper.from_array(np.asarray(layer["bias"], dtype=np.float32), bias_name),
|
||||
]
|
||||
)
|
||||
nodes.append(helper.make_node("Gemm", [previous, weight_name, bias_name], [linear_name]))
|
||||
nodes.append(helper.make_node("Relu", [linear_name], [output_name]))
|
||||
previous = output_name
|
||||
|
||||
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,
|
||||
"coolrl-lost-cities-jax-ppo-actor",
|
||||
[helper.make_tensor_value_info("obs", TensorProto.FLOAT, [None, 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)
|
||||
sample = np.random.default_rng(20260713).normal(size=(3, OBS_DIM)).astype(np.float32)
|
||||
expected_logits, _ = flax_model.apply(params, jnp.asarray(sample))
|
||||
actual_logits = ReferenceEvaluator(model).run(None, {"obs": sample})[0]
|
||||
np.testing.assert_allclose(actual_logits, np.asarray(expected_logits), rtol=2e-5, atol=2e-5)
|
||||
np.testing.assert_array_equal(
|
||||
np.argmax(actual_logits, axis=1), np.argmax(np.asarray(expected_logits), axis=1)
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
onnx.save(model, output)
|
||||
manifest = {
|
||||
"format": "coolrl-lost-cities-jax-ppo-onnx-v1",
|
||||
"source_checkpoint": str(checkpoint.resolve()),
|
||||
"source_config": str(config_path.resolve()),
|
||||
"observation_size": 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_logits - np.asarray(expected_logits)))
|
||||
),
|
||||
}
|
||||
output.with_suffix(".json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(f"exported {output} ({output.stat().st_size:,} bytes)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_argparser().parse_args()
|
||||
export_model(args.checkpoint, args.config, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic JAX states for TypeScript engine parity tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import jax.numpy as jnp
|
||||
import numpy as np
|
||||
|
||||
from lost_cities_jax.engine import legal_action_mask, reset_from_order, step
|
||||
from lost_cities_jax.obs import observation
|
||||
|
||||
OUTPUT = Path(__file__).resolve().parents[1] / "web" / "src" / "game" / "parity-fixture.json"
|
||||
|
||||
|
||||
def state_json(state) -> dict:
|
||||
return {
|
||||
"deckOrder": np.asarray(state.deck_order).astype(int).tolist(),
|
||||
"drawPtr": int(state.draw_ptr),
|
||||
"cardLoc": np.asarray(state.card_loc).astype(int).tolist(),
|
||||
"handPublic": np.asarray(state.hand_public).astype(bool).tolist(),
|
||||
"colTop": np.asarray(state.col_top).astype(int).tolist(),
|
||||
"colHandshakes": np.asarray(state.col_hs).astype(int).tolist(),
|
||||
"colLength": np.asarray(state.col_len).astype(int).tolist(),
|
||||
"piles": [
|
||||
np.asarray(state.pile[color, : int(state.pile_len[color])]).astype(int).tolist()
|
||||
for color in range(5)
|
||||
],
|
||||
"toMove": int(state.to_move),
|
||||
"stepCount": int(state.step_count),
|
||||
"done": bool(state.done),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
rng = np.random.default_rng(20260713)
|
||||
order = rng.permutation(60).astype(np.int8)
|
||||
state = reset_from_order(jnp.asarray(order))
|
||||
rows = []
|
||||
for index in range(24):
|
||||
mask = np.asarray(legal_action_mask(state), dtype=bool)
|
||||
rows.append(
|
||||
{
|
||||
"index": index,
|
||||
"state": state_json(state),
|
||||
"legalMask": mask.tolist(),
|
||||
"observationP0": np.asarray(observation(state, jnp.int32(0))).tolist(),
|
||||
"observationP1": np.asarray(observation(state, jnp.int32(1))).tolist(),
|
||||
}
|
||||
)
|
||||
legal = np.flatnonzero(mask)
|
||||
action = int(legal[(index * 17 + 3) % len(legal)])
|
||||
rows[-1]["action"] = action
|
||||
state, _, _ = step(state, jnp.int32(action))
|
||||
if bool(state.done):
|
||||
break
|
||||
OUTPUT.write_text(json.dumps({"format": "jax-web-parity-v1", "rows": rows}) + "\n")
|
||||
print(f"wrote {OUTPUT} ({len(rows)} states)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,41 @@
|
||||
# COOLRL Lost Cities Web
|
||||
|
||||
Browser-only Lost Cities client. The rules engine, observation builder, and PPO
|
||||
inference all run on the device. There is no application server.
|
||||
|
||||
## Setup
|
||||
|
||||
From the repository root, export an Orbax checkpoint to the browser model:
|
||||
|
||||
```bash
|
||||
uv run --with onnx scripts/export_jax_ppo_onnx.py \
|
||||
--checkpoint /path/to/checkpoint \
|
||||
--output web/public/models/jax-ppo.onnx
|
||||
```
|
||||
|
||||
Then install and run the web app:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The policy tries WebGPU first and falls back to ONNX Runtime WebAssembly. If
|
||||
the model asset is absent, the UI remains playable using a simple local
|
||||
heuristic and reports that fallback in the header.
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
The TypeScript engine follows `src/lost_cities_jax/engine.py` and its 96-action
|
||||
atomic action space. Cross-runtime fixtures can be regenerated with:
|
||||
|
||||
```bash
|
||||
uv run python scripts/generate_web_parity_fixture.py
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#050607" />
|
||||
<meta name="description" content="Play Lost Cities against an on-device PPO agent." />
|
||||
<title>COOLRL LOST CITIES</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1868
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "coolrl-lost-cities-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"onnxruntime-web": "1.27.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.4",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Board } from "./components/Board";
|
||||
import { Card, CardBack } from "./components/Card";
|
||||
import { COLOR_NAMES, cardColor, cardName } from "./game/cards";
|
||||
import {
|
||||
boardScore,
|
||||
currentHandSorted,
|
||||
encodeAction,
|
||||
legalActionMask,
|
||||
newGame,
|
||||
step,
|
||||
} from "./game/engine";
|
||||
import { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types";
|
||||
import { loadPolicy, type Policy } from "./model/policy";
|
||||
|
||||
type Selection = { handSlot: number | null; placeType: PlaceType | null };
|
||||
const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null };
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<GameState>(() => newGame());
|
||||
const [history, setHistory] = useState<GameState[]>([]);
|
||||
const [selection, setSelection] = useState<Selection>(EMPTY_SELECTION);
|
||||
const [policy, setPolicy] = useState<Policy | null>(null);
|
||||
const [modelMessage, setModelMessage] = useState("모델 로딩 중…");
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const generation = useRef(0);
|
||||
|
||||
const scores = useMemo(() => boardScore(state), [state]);
|
||||
const humanHand = useMemo(() => currentHandSorted(state, 0), [state]);
|
||||
const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]);
|
||||
const legal = useMemo(() => legalActionMask(state), [state]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadPolicy().then(({ policy: loaded, warning }) => {
|
||||
if (cancelled) return;
|
||||
setPolicy(loaded);
|
||||
setModelMessage(warning ?? `${loaded.provider.toUpperCase()} · ON-DEVICE`);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!policy || state.done || state.toMove !== 1) return;
|
||||
const currentGeneration = generation.current;
|
||||
const timer = window.setTimeout(async () => {
|
||||
setThinking(true);
|
||||
try {
|
||||
const action = await policy.action(state);
|
||||
if (generation.current !== currentGeneration) return;
|
||||
setHistory((items) => [...items, state]);
|
||||
setState(step(state, action));
|
||||
} catch (error) {
|
||||
console.error("AI action failed", error);
|
||||
setModelMessage("MODEL INFERENCE ERROR");
|
||||
} finally {
|
||||
if (generation.current === currentGeneration) setThinking(false);
|
||||
}
|
||||
}, 420);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [policy, state]);
|
||||
|
||||
function restart() {
|
||||
generation.current += 1;
|
||||
setState(newGame());
|
||||
setHistory([]);
|
||||
setSelection(EMPTY_SELECTION);
|
||||
setThinking(false);
|
||||
}
|
||||
|
||||
function undoTurn() {
|
||||
generation.current += 1;
|
||||
if (!history.length) return;
|
||||
let index = history.length - 1;
|
||||
while (index > 0 && history[index].toMove !== 0) index -= 1;
|
||||
setState(history[index]);
|
||||
setHistory(history.slice(0, index));
|
||||
setSelection(EMPTY_SELECTION);
|
||||
setThinking(false);
|
||||
}
|
||||
|
||||
function chooseCard(handSlot: number) {
|
||||
if (state.toMove !== 0 || state.done) return;
|
||||
setSelection({ handSlot, placeType: null });
|
||||
}
|
||||
|
||||
function choosePlace(placeType: PlaceType) {
|
||||
if (selection.handSlot === null) return;
|
||||
setSelection({ ...selection, placeType });
|
||||
}
|
||||
|
||||
function legalDraw(drawSource: number): boolean {
|
||||
if (selection.handSlot === null || selection.placeType === null) return false;
|
||||
return legal[encodeAction(selection.handSlot, selection.placeType, drawSource)];
|
||||
}
|
||||
|
||||
function commit(drawSource: number) {
|
||||
if (!legalDraw(drawSource) || selection.handSlot === null || selection.placeType === null) return;
|
||||
const action = encodeAction(selection.handSlot, selection.placeType, drawSource);
|
||||
setHistory((items) => [...items, state]);
|
||||
setState(step(state, action));
|
||||
setSelection(EMPTY_SELECTION);
|
||||
}
|
||||
|
||||
const selectedCard = selection.handSlot === null ? null : humanHand[selection.handSlot];
|
||||
const status = state.done
|
||||
? scores[0] === scores[1] ? "DRAW" : scores[0] > scores[1] ? "YOU WIN" : "AI WINS"
|
||||
: state.toMove === 1 ? thinking ? "AI THINKING" : "AI TURN" : "YOUR TURN";
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">COOLRL / CLASSIC</p>
|
||||
<h1>LOST CITIES</h1>
|
||||
</div>
|
||||
<div className="topbar__status">
|
||||
<span className={`status-dot ${thinking ? "status-dot--pulse" : ""}`} />
|
||||
<div><strong>{status}</strong><small>{modelMessage}</small></div>
|
||||
</div>
|
||||
<div className="topbar__actions">
|
||||
<button className="text-button" onClick={undoTurn} disabled={!history.length}>UNDO</button>
|
||||
<button className="primary-button" onClick={restart}>NEW GAME</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="opponent-strip">
|
||||
<div className="player-label"><span>AI</span><b>{scores[1]}</b></div>
|
||||
<div className="hidden-hand">{opponentHand.map((card) => <CardBack key={card} />)}</div>
|
||||
<div className="deck-meter"><span>DECK</span><strong>{N_CARDS - state.drawPtr}</strong></div>
|
||||
</section>
|
||||
|
||||
<Board state={state} />
|
||||
|
||||
<section className="control-deck">
|
||||
<div className="player-label"><span>YOU</span><b>{scores[0]}</b></div>
|
||||
<div className="hand" aria-label="Your hand">
|
||||
{humanHand.map((card, index) => (
|
||||
<Card
|
||||
card={card}
|
||||
key={card}
|
||||
selected={selection.handSlot === index}
|
||||
disabled={state.toMove !== 0 || state.done}
|
||||
onClick={() => chooseCard(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="decision-panel">
|
||||
<div className="decision-panel__step">
|
||||
<span>01</span><p>{selectedCard === null ? "SELECT A CARD" : cardName(selectedCard)}</p>
|
||||
</div>
|
||||
<div className="decision-panel__buttons">
|
||||
<button
|
||||
className={selection.placeType === PLAY ? "active" : ""}
|
||||
disabled={selection.handSlot === null || !Array.from({ length: 6 }, (_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)]).some(Boolean)}
|
||||
onClick={() => choosePlace(PLAY)}
|
||||
>PLAY</button>
|
||||
<button
|
||||
className={selection.placeType === DISCARD ? "active" : ""}
|
||||
disabled={selection.handSlot === null}
|
||||
onClick={() => choosePlace(DISCARD)}
|
||||
>DISCARD</button>
|
||||
</div>
|
||||
<div className="decision-panel__step"><span>02</span><p>CHOOSE DRAW</p></div>
|
||||
<div className="draw-buttons">
|
||||
<button disabled={!legalDraw(DRAW_DECK)} onClick={() => commit(DRAW_DECK)}>DECK</button>
|
||||
{COLOR_NAMES.map((name, color) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`draw-color draw-color--${color}`}
|
||||
disabled={!legalDraw(color + 1)}
|
||||
onClick={() => commit(color + 1)}
|
||||
title={`Draw from ${name} discard pile`}
|
||||
><span style={{ background: `var(--color-${color})` }} />{name.slice(0, 1)}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{state.done && (
|
||||
<div className="result-overlay">
|
||||
<div className="result-card"><p>ROUND COMPLETE</p><h2>{status}</h2><strong>{scores[0]} <i>:</i> {scores[1]}</strong><button onClick={restart}>PLAY AGAIN</button></div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Card } from "./Card";
|
||||
import { COLOR_HEX, COLOR_NAMES } from "../game/cards";
|
||||
import { cardsOnBoard } from "../game/engine";
|
||||
import type { GameState } from "../game/types";
|
||||
|
||||
export function Board({ state }: { state: GameState }) {
|
||||
return (
|
||||
<section className="board" aria-label="Expeditions and discard piles">
|
||||
{COLOR_NAMES.map((name, color) => {
|
||||
const opponent = cardsOnBoard(state, 1, color);
|
||||
const mine = cardsOnBoard(state, 0, color);
|
||||
const discard = state.piles[color];
|
||||
return (
|
||||
<article className="lane" key={name} style={{ "--lane-color": COLOR_HEX[color] } as React.CSSProperties}>
|
||||
<header className="lane__header">
|
||||
<span className="lane__dot" />
|
||||
<span>{name}</span>
|
||||
</header>
|
||||
<div className="lane__zone lane__zone--opponent">
|
||||
{opponent.length ? opponent.map((card) => <Card compact card={card} key={card} />) : <span className="lane__empty">AI</span>}
|
||||
</div>
|
||||
<div className="lane__discard">
|
||||
{discard.length ? <Card compact card={discard.at(-1)!} /> : <span>DISCARD</span>}
|
||||
{discard.length > 1 && <b>+{discard.length - 1}</b>}
|
||||
</div>
|
||||
<div className="lane__zone lane__zone--mine">
|
||||
{mine.length ? mine.map((card) => <Card compact card={card} key={card} />) : <span className="lane__empty">YOU</span>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { COLOR_HEX, COLOR_NAMES, cardColor, cardValue } from "../game/cards";
|
||||
|
||||
interface CardProps {
|
||||
card: number;
|
||||
compact?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ card, compact = false, selected = false, disabled = false, onClick }: CardProps) {
|
||||
const color = cardColor(card);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`card ${compact ? "card--compact" : ""} ${selected ? "card--selected" : ""}`}
|
||||
style={{ "--card-color": COLOR_HEX[color] } as React.CSSProperties}
|
||||
onClick={onClick}
|
||||
disabled={disabled || !onClick}
|
||||
aria-label={`${COLOR_NAMES[color]} ${cardValue(card)}`}
|
||||
>
|
||||
<span className="card__color">{COLOR_NAMES[color]}</span>
|
||||
<span className="card__value">{cardValue(card)}</span>
|
||||
<span className="card__mark">{cardValue(card)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardBack({ count }: { count?: number }) {
|
||||
return (
|
||||
<div className="card card--back" aria-label={count === undefined ? "Hidden card" : `${count} cards`}>
|
||||
<span className="card__back-mark">LC</span>
|
||||
{count !== undefined && <span className="card__count">{count}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CARDS_PER_COLOR } from "./types";
|
||||
|
||||
export const COLOR_NAMES = ["Red", "Blue", "Green", "Gold", "Violet"] as const;
|
||||
export const COLOR_HEX = ["#ff4e53", "#538df4", "#74ae52", "#e8b53b", "#a463ca"] as const;
|
||||
|
||||
export function cardColor(card: number): number {
|
||||
return Math.floor(card / CARDS_PER_COLOR);
|
||||
}
|
||||
|
||||
export function cardSlot(card: number): number {
|
||||
return card % CARDS_PER_COLOR;
|
||||
}
|
||||
|
||||
export function isHandshake(card: number): boolean {
|
||||
return cardSlot(card) < 3;
|
||||
}
|
||||
|
||||
export function cardRank(card: number): number {
|
||||
return isHandshake(card) ? 0 : cardSlot(card) - 1;
|
||||
}
|
||||
|
||||
export function cardValue(card: number): string {
|
||||
return isHandshake(card) ? "H" : String(cardRank(card));
|
||||
}
|
||||
|
||||
export function cardName(card: number): string {
|
||||
return `${COLOR_NAMES[cardColor(card)]} ${cardValue(card)}`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { boardScore, currentHandSorted, encodeAction, legalActionMask, resetFromOrder, step } from "./engine";
|
||||
import { DISCARD, DRAW_DECK, N_CARDS } from "./types";
|
||||
import { cardColor } from "./cards";
|
||||
import { observation } from "./observation";
|
||||
import fixture from "./parity-fixture.json";
|
||||
import type { GameState } from "./types";
|
||||
|
||||
const orderedDeck = Array.from({ length: N_CARDS }, (_, index) => index);
|
||||
|
||||
describe("JAX-compatible game engine", () => {
|
||||
test("deals eight cards to each player in explicit deck order", () => {
|
||||
const state = resetFromOrder(orderedDeck);
|
||||
expect(currentHandSorted(state, 0)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
expect(currentHandSorted(state, 1)).toEqual([8, 9, 10, 11, 12, 13, 14, 15]);
|
||||
});
|
||||
|
||||
test("cannot discard and redraw the same card", () => {
|
||||
const state = resetFromOrder(orderedDeck);
|
||||
const color = cardColor(currentHandSorted(state)[0]);
|
||||
const mask = legalActionMask(state);
|
||||
expect(mask[encodeAction(0, DISCARD, color + 1)]).toBe(false);
|
||||
expect(mask[encodeAction(0, DISCARD, DRAW_DECK)]).toBe(true);
|
||||
});
|
||||
|
||||
test("deck-only games terminate after exactly 44 plies", () => {
|
||||
let state = resetFromOrder(orderedDeck);
|
||||
let plies = 0;
|
||||
while (!state.done) {
|
||||
const action = legalActionMask(state).findIndex((legal, action) => legal && action % 6 === 0);
|
||||
state = step(state, action);
|
||||
plies += 1;
|
||||
}
|
||||
expect(plies).toBe(44);
|
||||
expect(state.drawPtr).toBe(N_CARDS);
|
||||
});
|
||||
|
||||
test("observation has the trained model shape", () => {
|
||||
expect(observation(resetFromOrder(orderedDeck), 0)).toHaveLength(454);
|
||||
});
|
||||
|
||||
test("illegal actions are no-ops", () => {
|
||||
const state = resetFromOrder(orderedDeck);
|
||||
expect(step(state, -1)).toBe(state);
|
||||
expect(boardScore(state)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
test("matches deterministic JAX masks, observations, and transitions", () => {
|
||||
for (let index = 0; index < fixture.rows.length; index += 1) {
|
||||
const row = fixture.rows[index];
|
||||
const state = row.state as GameState;
|
||||
expect(legalActionMask(state)).toEqual(row.legalMask);
|
||||
const p0 = observation(state, 0);
|
||||
const p1 = observation(state, 1);
|
||||
row.observationP0.forEach((value, offset) => expect(p0[offset]).toBeCloseTo(value, 6));
|
||||
row.observationP1.forEach((value, offset) => expect(p1[offset]).toBeCloseTo(value, 6));
|
||||
if (index + 1 < fixture.rows.length) {
|
||||
expect(step(state, row.action)).toEqual(fixture.rows[index + 1].state);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
CARDS_PER_COLOR,
|
||||
DISCARD,
|
||||
DRAW_DECK,
|
||||
HAND_SIZE,
|
||||
INITIAL_DEAL,
|
||||
LOC_DECK,
|
||||
LOC_DISCARD,
|
||||
LOC_P0_BOARD,
|
||||
LOC_P0_HAND,
|
||||
MAX_STEPS,
|
||||
N_ACTIONS,
|
||||
N_CARDS,
|
||||
N_COLORS,
|
||||
PLAY,
|
||||
type DecodedAction,
|
||||
type GameState,
|
||||
type PlaceType,
|
||||
} from "./types";
|
||||
import { cardColor, cardRank, isHandshake } from "./cards";
|
||||
|
||||
function emptyMatrix(rows: number, columns: number): number[][] {
|
||||
return Array.from({ length: rows }, () => Array(columns).fill(0));
|
||||
}
|
||||
|
||||
export function shuffledDeck(random: () => number = Math.random): number[] {
|
||||
const deck = Array.from({ length: N_CARDS }, (_, index) => index);
|
||||
for (let index = deck.length - 1; index > 0; index -= 1) {
|
||||
const swap = Math.floor(random() * (index + 1));
|
||||
[deck[index], deck[swap]] = [deck[swap], deck[index]];
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
export function resetFromOrder(deckOrder: number[]): GameState {
|
||||
if (deckOrder.length !== N_CARDS || new Set(deckOrder).size !== N_CARDS) {
|
||||
throw new Error("deckOrder must be a permutation of 0..59");
|
||||
}
|
||||
const cardLoc = Array(N_CARDS).fill(LOC_DECK);
|
||||
for (const card of deckOrder.slice(0, HAND_SIZE)) cardLoc[card] = LOC_P0_HAND;
|
||||
for (const card of deckOrder.slice(HAND_SIZE, INITIAL_DEAL)) cardLoc[card] = LOC_P0_HAND + 1;
|
||||
return {
|
||||
deckOrder: [...deckOrder],
|
||||
drawPtr: INITIAL_DEAL,
|
||||
cardLoc,
|
||||
handPublic: Array(N_CARDS).fill(false),
|
||||
colTop: emptyMatrix(2, N_COLORS),
|
||||
colHandshakes: emptyMatrix(2, N_COLORS),
|
||||
colLength: emptyMatrix(2, N_COLORS),
|
||||
piles: Array.from({ length: N_COLORS }, () => []),
|
||||
toMove: 0,
|
||||
stepCount: 0,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function newGame(random: () => number = Math.random): GameState {
|
||||
return resetFromOrder(shuffledDeck(random));
|
||||
}
|
||||
|
||||
export function cloneState(state: GameState): GameState {
|
||||
return {
|
||||
...state,
|
||||
deckOrder: [...state.deckOrder],
|
||||
cardLoc: [...state.cardLoc],
|
||||
handPublic: [...state.handPublic],
|
||||
colTop: state.colTop.map((row) => [...row]),
|
||||
colHandshakes: state.colHandshakes.map((row) => [...row]),
|
||||
colLength: state.colLength.map((row) => [...row]),
|
||||
piles: state.piles.map((pile) => [...pile]),
|
||||
};
|
||||
}
|
||||
|
||||
export function currentHandSorted(state: GameState, player = state.toMove): number[] {
|
||||
const location = LOC_P0_HAND + player;
|
||||
return state.cardLoc.flatMap((loc, card) => (loc === location ? [card] : [])).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function decodeAction(action: number): DecodedAction {
|
||||
return {
|
||||
handSlot: Math.floor(action / 12),
|
||||
placeType: Math.floor((action % 12) / 6) as PlaceType,
|
||||
drawSource: action % 6,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeAction(handSlot: number, placeType: PlaceType, drawSource: number): number {
|
||||
return handSlot * 12 + placeType * 6 + drawSource;
|
||||
}
|
||||
|
||||
export function legalActionMask(state: GameState): boolean[] {
|
||||
const mask = Array(N_ACTIONS).fill(false);
|
||||
if (state.done) return mask;
|
||||
const hand = currentHandSorted(state);
|
||||
for (let handSlot = 0; handSlot < hand.length; handSlot += 1) {
|
||||
const card = hand[handSlot];
|
||||
const color = cardColor(card);
|
||||
const playLegal = isHandshake(card)
|
||||
? state.colTop[state.toMove][color] === 0
|
||||
: cardRank(card) > state.colTop[state.toMove][color];
|
||||
for (const placeType of [PLAY, DISCARD] as const) {
|
||||
if (placeType === PLAY && !playLegal) continue;
|
||||
mask[encodeAction(handSlot, placeType, DRAW_DECK)] = true;
|
||||
for (let pileColor = 0; pileColor < N_COLORS; pileColor += 1) {
|
||||
const pileHasCardAfterPlace = state.piles[pileColor].length > 0 ||
|
||||
(placeType === DISCARD && pileColor === color);
|
||||
const drawingJustDiscarded = placeType === DISCARD && pileColor === color;
|
||||
if (pileHasCardAfterPlace && !drawingJustDiscarded) {
|
||||
mask[encodeAction(handSlot, placeType, pileColor + 1)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
export function step(state: GameState, action: number): GameState {
|
||||
const mask = legalActionMask(state);
|
||||
if (!Number.isInteger(action) || action < 0 || action >= N_ACTIONS || !mask[action]) {
|
||||
return state;
|
||||
}
|
||||
const next = cloneState(state);
|
||||
const { handSlot, placeType, drawSource } = decodeAction(action);
|
||||
const player = state.toMove;
|
||||
const hand = currentHandSorted(state, player);
|
||||
const card = hand[handSlot];
|
||||
const color = cardColor(card);
|
||||
|
||||
next.handPublic[card] = false;
|
||||
if (placeType === PLAY) {
|
||||
next.cardLoc[card] = LOC_P0_BOARD + player;
|
||||
next.colLength[player][color] += 1;
|
||||
if (isHandshake(card)) next.colHandshakes[player][color] += 1;
|
||||
else next.colTop[player][color] = cardRank(card);
|
||||
} else {
|
||||
next.cardLoc[card] = LOC_DISCARD;
|
||||
next.piles[color].push(card);
|
||||
}
|
||||
|
||||
let drawnCard: number;
|
||||
if (drawSource === DRAW_DECK) {
|
||||
drawnCard = next.deckOrder[next.drawPtr];
|
||||
next.drawPtr += 1;
|
||||
next.handPublic[drawnCard] = false;
|
||||
} else {
|
||||
const pile = next.piles[drawSource - 1];
|
||||
drawnCard = pile.pop()!;
|
||||
next.handPublic[drawnCard] = true;
|
||||
}
|
||||
next.cardLoc[drawnCard] = LOC_P0_HAND + player;
|
||||
next.stepCount += 1;
|
||||
next.done = (drawSource === DRAW_DECK && next.drawPtr >= N_CARDS) || next.stepCount >= MAX_STEPS;
|
||||
next.toMove = (1 - player) as 0 | 1;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function boardScore(state: GameState): [number, number] {
|
||||
const scores: [number, number] = [0, 0];
|
||||
for (let player = 0; player < 2; player += 1) {
|
||||
for (let color = 0; color < N_COLORS; color += 1) {
|
||||
const cards = state.cardLoc.flatMap((loc, card) =>
|
||||
loc === LOC_P0_BOARD + player && cardColor(card) === color ? [card] : [],
|
||||
);
|
||||
if (cards.length === 0) continue;
|
||||
const rankSum = cards.reduce((sum, card) => sum + cardRank(card), 0);
|
||||
const handshakes = cards.filter(isHandshake).length;
|
||||
scores[player] += (rankSum - 20) * (1 + handshakes) + (cards.length >= 8 ? 20 : 0);
|
||||
}
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
||||
export function cardsOnBoard(state: GameState, player: number, color: number): number[] {
|
||||
return state.cardLoc.flatMap((loc, card) =>
|
||||
loc === LOC_P0_BOARD + player && cardColor(card) === color ? [card] : [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { boardScore } from "./engine";
|
||||
import {
|
||||
CARDS_PER_COLOR,
|
||||
DECK_DRAWS,
|
||||
HAND_SIZE,
|
||||
LOC_DECK,
|
||||
LOC_DISCARD,
|
||||
LOC_P0_BOARD,
|
||||
LOC_P0_HAND,
|
||||
MAX_ABS_SCORE,
|
||||
MAX_STEPS,
|
||||
N_CARDS,
|
||||
N_COLORS,
|
||||
OBS_DIM,
|
||||
type GameState,
|
||||
} from "./types";
|
||||
import { cardColor } from "./cards";
|
||||
|
||||
export function observation(state: GameState, player: number): Float32Array {
|
||||
const opponent = 1 - player;
|
||||
const myHandLoc = LOC_P0_HAND + player;
|
||||
const opponentHandLoc = LOC_P0_HAND + opponent;
|
||||
const myBoardLoc = LOC_P0_BOARD + player;
|
||||
const opponentBoardLoc = LOC_P0_BOARD + opponent;
|
||||
const pileTops = state.piles.map((pile) => pile.at(-1) ?? -1);
|
||||
const values: number[] = [];
|
||||
let hiddenOpponentCards = 0;
|
||||
|
||||
for (let card = 0; card < N_CARDS; card += 1) {
|
||||
const loc = state.cardLoc[card];
|
||||
const isDiscard = loc === LOC_DISCARD;
|
||||
const isDiscardTop = isDiscard && pileTops[cardColor(card)] === card;
|
||||
const isOpponentHand = loc === opponentHandLoc;
|
||||
const isOpponentPublic = isOpponentHand && state.handPublic[card];
|
||||
if (isOpponentHand && !state.handPublic[card]) hiddenOpponentCards += 1;
|
||||
values.push(
|
||||
Number(loc === myHandLoc),
|
||||
Number(loc === myBoardLoc),
|
||||
Number(loc === opponentBoardLoc),
|
||||
Number(isDiscardTop),
|
||||
Number(isDiscard && !isDiscardTop),
|
||||
Number(isOpponentPublic),
|
||||
Number(loc === LOC_DECK || (isOpponentHand && !state.handPublic[card])),
|
||||
);
|
||||
}
|
||||
|
||||
const scores = boardScore(state);
|
||||
values.push(
|
||||
(N_CARDS - state.drawPtr) / DECK_DRAWS,
|
||||
hiddenOpponentCards / HAND_SIZE,
|
||||
state.stepCount / MAX_STEPS,
|
||||
...state.colTop[player].map((value) => value / 10),
|
||||
...state.colTop[opponent].map((value) => value / 10),
|
||||
...state.colHandshakes[player].map((value) => value / 3),
|
||||
...state.colHandshakes[opponent].map((value) => value / 3),
|
||||
...state.colLength[player].map((value) => value / CARDS_PER_COLOR),
|
||||
...state.colLength[opponent].map((value) => value / CARDS_PER_COLOR),
|
||||
(scores[player] - scores[opponent]) / MAX_ABS_SCORE,
|
||||
);
|
||||
if (values.length !== OBS_DIM) throw new Error(`observation length ${values.length} != ${OBS_DIM}`);
|
||||
return Float32Array.from(values);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
|
||||
export const N_PLAYERS = 2;
|
||||
export const N_COLORS = 5;
|
||||
export const CARDS_PER_COLOR = 12;
|
||||
export const N_CARDS = 60;
|
||||
export const HAND_SIZE = 8;
|
||||
export const INITIAL_DEAL = 16;
|
||||
export const DECK_DRAWS = 44;
|
||||
export const N_ACTIONS = 96;
|
||||
export const MAX_STEPS = 400;
|
||||
export const OBS_DIM = 454;
|
||||
export const MAX_ABS_SCORE = 780;
|
||||
|
||||
export const LOC_DECK = 0;
|
||||
export const LOC_P0_HAND = 1;
|
||||
export const LOC_P1_HAND = 2;
|
||||
export const LOC_P0_BOARD = 3;
|
||||
export const LOC_P1_BOARD = 4;
|
||||
export const LOC_DISCARD = 5;
|
||||
|
||||
export const PLAY = 0;
|
||||
export const DISCARD = 1;
|
||||
export const DRAW_DECK = 0;
|
||||
|
||||
export type PlaceType = typeof PLAY | typeof DISCARD;
|
||||
|
||||
export interface GameState {
|
||||
deckOrder: number[];
|
||||
drawPtr: number;
|
||||
cardLoc: number[];
|
||||
handPublic: boolean[];
|
||||
colTop: number[][];
|
||||
colHandshakes: number[][];
|
||||
colLength: number[][];
|
||||
piles: number[][];
|
||||
toMove: 0 | 1;
|
||||
stepCount: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface DecodedAction {
|
||||
handSlot: number;
|
||||
placeType: PlaceType;
|
||||
drawSource: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
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 { cardColor, cardRank, isHandshake } from "../game/cards";
|
||||
|
||||
export type ExecutionProvider = "webgpu" | "wasm" | "heuristic";
|
||||
|
||||
export interface Policy {
|
||||
readonly provider: ExecutionProvider;
|
||||
action(state: GameState): Promise<number>;
|
||||
}
|
||||
|
||||
class OnnxPolicy implements Policy {
|
||||
constructor(
|
||||
private readonly session: ort.InferenceSession,
|
||||
readonly provider: ExecutionProvider,
|
||||
) {}
|
||||
|
||||
async action(state: GameState): Promise<number> {
|
||||
const obs = observation(state, state.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);
|
||||
let bestAction = -1;
|
||||
let bestLogit = Number.NEGATIVE_INFINITY;
|
||||
for (let action = 0; action < legal.length; action += 1) {
|
||||
const logit = Number(logits[action]);
|
||||
if (legal[action] && logit > bestLogit) {
|
||||
bestAction = action;
|
||||
bestLogit = logit;
|
||||
}
|
||||
}
|
||||
if (bestAction < 0) throw new Error("model received a state without legal actions");
|
||||
return bestAction;
|
||||
}
|
||||
}
|
||||
|
||||
class HeuristicPolicy implements Policy {
|
||||
readonly provider = "heuristic" as const;
|
||||
|
||||
async action(state: GameState): Promise<number> {
|
||||
const legal = legalActionMask(state);
|
||||
const hand = currentHandSorted(state);
|
||||
let bestAction = -1;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
for (let action = 0; action < legal.length; action += 1) {
|
||||
if (!legal[action]) continue;
|
||||
const handSlot = Math.floor(action / 12);
|
||||
const place = Math.floor((action % 12) / 6);
|
||||
const draw = action % 6;
|
||||
const card = hand[handSlot];
|
||||
let score = place === PLAY ? cardRank(card) + (isHandshake(card) ? 7 : 0) : -cardRank(card);
|
||||
if (draw !== DRAW_DECK) score += 2;
|
||||
if (place === DISCARD && draw > 0 && draw - 1 === cardColor(card)) score -= 100;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestAction = action;
|
||||
}
|
||||
}
|
||||
if (bestAction < 0) throw new Error("state has no legal actions");
|
||||
return bestAction;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(provider: "webgpu" | "wasm"): Promise<ort.InferenceSession> {
|
||||
return ort.InferenceSession.create("/models/jax-ppo.onnx", {
|
||||
executionProviders: [provider],
|
||||
graphOptimizationLevel: "all",
|
||||
});
|
||||
}
|
||||
|
||||
let policyPromise: Promise<{ policy: Policy; warning?: string }> | undefined;
|
||||
|
||||
async function initializePolicy(): Promise<{ policy: Policy; warning?: string }> {
|
||||
const canUseWebGpu = "gpu" in navigator;
|
||||
if (canUseWebGpu) {
|
||||
try {
|
||||
return { policy: new OnnxPolicy(await createSession("webgpu"), "webgpu") };
|
||||
} catch (error) {
|
||||
console.warn("WebGPU model initialization failed; trying WASM", error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return { policy: new OnnxPolicy(await createSession("wasm"), "wasm") };
|
||||
} catch (error) {
|
||||
console.warn("ONNX model initialization failed; using heuristic policy", error);
|
||||
return {
|
||||
policy: new HeuristicPolicy(),
|
||||
warning: "ONNX 모델을 찾지 못해 로컬 휴리스틱으로 플레이합니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPolicy(): Promise<{ policy: Policy; warning?: string }> {
|
||||
policyPromise ??= initializePolicy();
|
||||
return policyPromise;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
color: #e7e7eb;
|
||||
background: #050607;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-synthesis: none;
|
||||
--muted: #8e9099;
|
||||
--line: #292b30;
|
||||
--panel: #0a0b0d;
|
||||
--color-0: #ff4e53;
|
||||
--color-1: #538df4;
|
||||
--color-2: #74ae52;
|
||||
--color-3: #e8b53b;
|
||||
--color-4: #a463ca;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: #050607; }
|
||||
button { font: inherit; }
|
||||
button:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
|
||||
|
||||
.app-shell { min-height: 100vh; padding-bottom: 24px; background: radial-gradient(circle at 50% -20%, #1d2026 0, #090a0c 34%, #050607 64%); }
|
||||
.topbar { height: 94px; padding: 16px 28px; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; background: rgba(5, 6, 7, .9); backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 20; }
|
||||
.eyebrow { margin: 0 0 3px; color: var(--muted); font: 500 10px/1 IBM Plex Mono, monospace; letter-spacing: .16em; }
|
||||
h1 { margin: 0; font: 600 25px/1 IBM Plex Mono, monospace; letter-spacing: -.04em; }
|
||||
.topbar__status { display: flex; gap: 12px; align-items: center; min-width: 180px; }
|
||||
.topbar__status div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.topbar__status strong { font: 600 12px/1.2 IBM Plex Mono, monospace; letter-spacing: .08em; }
|
||||
.topbar__status small { color: var(--muted); font: 9px/1.2 IBM Plex Mono, monospace; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #70c48f; box-shadow: 0 0 16px #70c48f; }
|
||||
.status-dot--pulse { animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .25; transform: scale(.75); } }
|
||||
.topbar__actions { justify-self: end; display: flex; gap: 8px; }
|
||||
.text-button, .primary-button { min-height: 38px; padding: 0 16px; border: 1px solid var(--line); color: #ddd; background: transparent; font: 600 10px IBM Plex Mono, monospace; letter-spacing: .08em; cursor: pointer; }
|
||||
.primary-button { background: #ececf0; color: #08090a; border-color: #ececf0; }
|
||||
.text-button:disabled { opacity: .3; cursor: default; }
|
||||
|
||||
.opponent-strip, .control-deck { width: min(1480px, calc(100% - 40px)); margin: 0 auto; display: grid; align-items: center; }
|
||||
.opponent-strip { min-height: 116px; grid-template-columns: 100px 1fr 90px; border-bottom: 1px solid var(--line); }
|
||||
.player-label { display: flex; align-items: baseline; gap: 10px; }
|
||||
.player-label span { color: var(--muted); font: 500 10px IBM Plex Mono, monospace; letter-spacing: .15em; }
|
||||
.player-label b { font: 500 26px IBM Plex Mono, monospace; }
|
||||
.hidden-hand { display: flex; justify-content: center; height: 66px; }
|
||||
.hidden-hand .card { margin-left: -20px; }
|
||||
.hidden-hand .card:first-child { margin-left: 0; }
|
||||
.deck-meter { justify-self: end; display: flex; flex-direction: column; align-items: flex-end; }
|
||||
.deck-meter span { color: var(--muted); font: 9px IBM Plex Mono, monospace; }
|
||||
.deck-meter strong { font: 500 24px IBM Plex Mono, monospace; }
|
||||
|
||||
.board { width: min(1480px, calc(100% - 40px)); min-height: 445px; margin: 0 auto; display: grid; grid-template-columns: repeat(5, 1fr); border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.lane { min-width: 0; border-right: 1px solid var(--line); display: grid; grid-template-rows: 34px 1fr 76px 1fr; background: linear-gradient(180deg, color-mix(in srgb, var(--lane-color) 3%, transparent), transparent 42%); }
|
||||
.lane__header { display: flex; align-items: center; justify-content: center; gap: 7px; color: #bfc0c5; font: 500 9px IBM Plex Mono, monospace; letter-spacing: .12em; border-bottom: 1px solid #181a1e; }
|
||||
.lane__dot { width: 5px; height: 5px; border-radius: 50%; background: var(--lane-color); box-shadow: 0 0 10px var(--lane-color); }
|
||||
.lane__zone { min-height: 118px; padding: 12px; display: flex; justify-content: center; align-items: center; overflow: hidden; }
|
||||
.lane__zone .card { margin-left: -35px; }
|
||||
.lane__zone .card:first-of-type { margin-left: 0; }
|
||||
.lane__zone--opponent { align-items: flex-start; }
|
||||
.lane__zone--mine { align-items: flex-end; }
|
||||
.lane__empty { color: #25272d; font: 600 10px IBM Plex Mono, monospace; }
|
||||
.lane__discard { position: relative; display: flex; align-items: center; justify-content: center; border-top: 1px dashed #202228; border-bottom: 1px dashed #202228; color: #383b43; font: 500 8px IBM Plex Mono, monospace; }
|
||||
.lane__discard b { position: absolute; margin: 0 0 -40px 50px; color: var(--muted); font: 9px IBM Plex Mono, monospace; }
|
||||
|
||||
.card { --card-color: #aaa; position: relative; flex: 0 0 auto; width: 74px; height: 96px; padding: 8px; border: 1px solid color-mix(in srgb, var(--card-color) 80%, #fff); border-radius: 3px; color: #e9e9ed; background: linear-gradient(145deg, color-mix(in srgb, var(--card-color) 10%, #0d0e11), #08090b 60%); box-shadow: 0 8px 20px rgba(0,0,0,.35); text-align: left; cursor: pointer; transition: transform .16s ease, border-color .16s, filter .16s; }
|
||||
button.card:not(:disabled):hover { transform: translateY(-7px); filter: brightness(1.18); z-index: 4; }
|
||||
button.card:disabled { cursor: default; }
|
||||
.card--selected { transform: translateY(-10px); border-width: 2px; box-shadow: 0 0 24px color-mix(in srgb, var(--card-color) 25%, transparent); }
|
||||
.card__color { color: var(--card-color); font: 500 7px IBM Plex Mono, monospace; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.card__value { display: block; margin-top: 11px; font: 500 28px/1 IBM Plex Mono, monospace; }
|
||||
.card__mark { position: absolute; right: 7px; bottom: 7px; color: color-mix(in srgb, var(--card-color) 30%, #15171b); font: 600 22px IBM Plex Mono, monospace; }
|
||||
.card--compact { width: 52px; height: 67px; padding: 5px; cursor: default; }
|
||||
.card--compact .card__value { margin-top: 6px; font-size: 18px; }
|
||||
.card--compact .card__color { display: none; }
|
||||
.card--compact .card__mark { font-size: 13px; }
|
||||
.card--back { width: 48px; height: 64px; display: grid; place-items: center; border-color: #3c3f47; color: #555962; background: repeating-linear-gradient(135deg, #101217, #101217 4px, #0a0b0e 4px, #0a0b0e 8px); cursor: default; }
|
||||
.card__back-mark { font: 600 10px IBM Plex Mono, monospace; letter-spacing: .1em; }
|
||||
.card__count { position: absolute; bottom: 3px; right: 5px; font: 8px IBM Plex Mono, monospace; }
|
||||
|
||||
.control-deck { grid-template-columns: 100px minmax(500px, 1fr) 370px; gap: 20px; min-height: 188px; padding-top: 20px; }
|
||||
.control-deck > .player-label { align-self: start; padding-top: 22px; }
|
||||
.hand { display: flex; align-items: flex-end; justify-content: center; min-width: 0; padding: 18px 4px; }
|
||||
.hand .card { margin-left: -10px; }
|
||||
.hand .card:first-child { margin-left: 0; }
|
||||
.decision-panel { border: 1px solid var(--line); background: rgba(11,12,15,.78); padding: 13px; }
|
||||
.decision-panel__step { display: flex; align-items: center; gap: 9px; }
|
||||
.decision-panel__step span { color: #51545c; font: 9px IBM Plex Mono, monospace; }
|
||||
.decision-panel__step p { margin: 0; color: #b8bac0; font: 500 9px IBM Plex Mono, monospace; letter-spacing: .08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.decision-panel__buttons, .draw-buttons { display: grid; gap: 6px; margin: 9px 0 12px; }
|
||||
.decision-panel__buttons { grid-template-columns: 1fr 1fr; }
|
||||
.draw-buttons { grid-template-columns: 1.4fr repeat(5, 1fr); margin-bottom: 0; }
|
||||
.decision-panel button { height: 34px; border: 1px solid #30323a; color: #bec0c6; background: #111318; font: 600 9px IBM Plex Mono, monospace; cursor: pointer; }
|
||||
.decision-panel button:hover:not(:disabled), .decision-panel button.active { border-color: #bbbcc2; color: #fff; background: #1b1d22; }
|
||||
.decision-panel button:disabled { opacity: .22; cursor: default; }
|
||||
.draw-color span { display: inline-block; width: 5px; height: 5px; margin-right: 4px; border-radius: 50%; }
|
||||
|
||||
.result-overlay { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; background: rgba(0,0,0,.72); backdrop-filter: blur(8px); }
|
||||
.result-card { width: min(430px, calc(100% - 40px)); padding: 42px; border: 1px solid #40434b; background: #090a0c; text-align: center; }
|
||||
.result-card p { color: var(--muted); font: 9px IBM Plex Mono, monospace; letter-spacing: .18em; }
|
||||
.result-card h2 { margin: 14px; font: 600 34px IBM Plex Mono, monospace; }
|
||||
.result-card strong { display: block; margin: 24px; font: 500 26px IBM Plex Mono, monospace; }
|
||||
.result-card i { color: #4c4f57; font-style: normal; }
|
||||
.result-card button { height: 42px; padding: 0 26px; border: 0; background: #eeeef1; color: #090a0c; font: 600 10px IBM Plex Mono, monospace; cursor: pointer; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.topbar { grid-template-columns: 1fr auto; }
|
||||
.topbar__status { order: 3; grid-column: 1 / -1; min-width: 0; margin-top: 8px; }
|
||||
.topbar { height: auto; }
|
||||
.board { overflow-x: auto; grid-template-columns: repeat(5, minmax(160px, 1fr)); }
|
||||
.control-deck { grid-template-columns: 70px 1fr; }
|
||||
.decision-panel { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.topbar { padding: 14px; }
|
||||
h1 { font-size: 20px; }
|
||||
.text-button { display: none; }
|
||||
.opponent-strip, .board, .control-deck { width: calc(100% - 20px); }
|
||||
.opponent-strip { grid-template-columns: 70px 1fr 52px; }
|
||||
.hidden-hand .card:nth-child(-n+3) { display: none; }
|
||||
.control-deck { display: block; }
|
||||
.control-deck > .player-label { padding: 10px 0 0; }
|
||||
.hand { justify-content: flex-start; overflow-x: auto; padding: 16px 4px 24px; }
|
||||
.hand .card { margin-left: -5px; }
|
||||
.decision-panel { position: sticky; bottom: 8px; z-index: 10; }
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.css";
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: "0.0.0.0", port: 5173 },
|
||||
test: { environment: "node" },
|
||||
});
|
||||
Reference in New Issue
Block a user