Add on-device web client

This commit is contained in:
2026-07-14 19:49:03 +09:00
parent 9517fd3ba8
commit 79273f7eb3
26 changed files with 3071 additions and 2 deletions
+41
View File
@@ -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
```
+14
View File
@@ -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>
+1868
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
+190
View File
@@ -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;
+34
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+28
View File
@@ -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)}`;
}
+63
View File
@@ -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);
}
}
});
});
+177
View File
@@ -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] : [],
);
}
+62
View File
@@ -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
+44
View File
@@ -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;
}
+11
View File
@@ -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>,
);
+100
View File
@@ -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;
}
+125
View File
@@ -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; }
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare module "*.css";
+20
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -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" },
});