Fix web client interaction and layout bugs

Placing a card locked the turn in: the chosen card left the hand and no
affordance reverted the placement, forcing the move through. Clicking the
chosen destination again or pressing Escape now steps the selection back.

A failing ONNX inference left the AI's turn unadvanced, permanently
stalling the game. The AI turn now falls back to the heuristic policy.

Other fixes: the score plaque no longer covers hand cards (plaques become
compact chips at narrow widths and hand spacing tracks the viewport),
opponent cards drawn from a discard pile render face up, long expedition
stacks stay inside their lane, undo no longer bumps the generation counter
on empty history, and small viewports scroll instead of clipping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmyprzuzanXRhpomc3Ga1i
This commit is contained in:
2026-07-14 21:04:27 +09:00
co-authored by Claude Opus 4.8
parent e47db5eb1e
commit 299c788545
5 changed files with 147 additions and 27 deletions
+86 -10
View File
@@ -13,11 +13,37 @@ import {
step, step,
} from "./game/engine"; } from "./game/engine";
import { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types"; import { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types";
import { loadPolicy, type Policy } from "./model/policy"; import { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy";
type Selection = { handSlot: number | null; placeType: PlaceType | null }; type Selection = { handSlot: number | null; placeType: PlaceType | null };
const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null }; const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null };
// The hand row is centered, so the binding constraint is the score plaque
// column on the left (mirrored as a keep-out on the right to stay centered).
// Available width for the hand = viewport - 2 * (plaque right edge + gutter).
// Card geometry must mirror styles.css (.card / the max-width:1250 override).
const HAND_GUTTER = 14;
const HAND_LAYOUTS = [
// maxWidth is the CSS breakpoint; keepOut = plaque left inset + plaque width.
{ maxWidth: 1250, cardWidth: 98, keepOut: 16 + 152, minVisible: 62 },
{ maxWidth: Infinity, cardWidth: 116, keepOut: 24 + 236, minVisible: 72 },
];
function handLayout(viewportWidth: number) {
return HAND_LAYOUTS.find((entry) => viewportWidth <= entry.maxWidth) ?? HAND_LAYOUTS[1];
}
/** Horizontal advance per hand card: as roomy as the viewport allows, never
* below the readability floor, never wider than a fully-gapped hand. */
function handCardStep(viewportWidth: number, count: number): number {
const { cardWidth, keepOut, minVisible } = handLayout(viewportWidth);
const relaxed = cardWidth + 12;
if (count <= 1) return relaxed;
const available = Math.max(0, viewportWidth - 2 * (keepOut + HAND_GUTTER));
const fitted = Math.floor((available - cardWidth) / (count - 1));
return Math.max(minVisible, Math.min(relaxed, fitted));
}
function App() { function App() {
const [state, setState] = useState<GameState>(() => newGame()); const [state, setState] = useState<GameState>(() => newGame());
const [history, setHistory] = useState<GameState[]>([]); const [history, setHistory] = useState<GameState[]>([]);
@@ -26,8 +52,15 @@ function App() {
const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO"); const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO");
const [thinking, setThinking] = useState(false); const [thinking, setThinking] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
const generation = useRef(0); const generation = useRef(0);
useEffect(() => {
function onResize() { setViewportWidth(window.innerWidth); }
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const humanHand = useMemo(() => currentHandSorted(state, 0), [state]); const humanHand = useMemo(() => currentHandSorted(state, 0), [state]);
const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]); const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]);
const legal = useMemo(() => legalActionMask(state), [state]); const legal = useMemo(() => legalActionMask(state), [state]);
@@ -57,12 +90,22 @@ function App() {
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
setThinking(true); setThinking(true);
try { try {
const action = await policy.action(state); let action: number;
try {
action = await policy.action(state);
} catch (error) {
console.error("AI action failed; falling back to heuristic policy", error);
const fallback = fallbackHeuristicPolicy();
action = await fallback.action(state);
if (generation.current !== currentGeneration) return;
setPolicy(fallback);
setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)");
}
if (generation.current !== currentGeneration) return; if (generation.current !== currentGeneration) return;
setHistory((items) => [...items, state]); setHistory((items) => [...items, state]);
setState(step(state, action)); setState(step(state, action));
} catch (error) { } catch (error) {
console.error("AI action failed", error); console.error("AI action failed even with heuristic fallback", error);
setModelMessage("MODEL INFERENCE ERROR"); setModelMessage("MODEL INFERENCE ERROR");
} finally { } finally {
if (generation.current === currentGeneration) setThinking(false); if (generation.current === currentGeneration) setThinking(false);
@@ -81,8 +124,8 @@ function App() {
} }
function undoTurn() { function undoTurn() {
generation.current += 1;
if (!history.length) return; if (!history.length) return;
generation.current += 1;
let index = history.length - 1; let index = history.length - 1;
while (index > 0 && history[index].toMove !== 0) index -= 1; while (index > 0 && history[index].toMove !== 0) index -= 1;
setState(history[index]); setState(history[index]);
@@ -94,7 +137,11 @@ function App() {
function chooseCard(handSlot: number) { function chooseCard(handSlot: number) {
if (state.toMove !== 0 || state.done) return; if (state.toMove !== 0 || state.done) return;
setSelection((current) => current.handSlot === handSlot ? EMPTY_SELECTION : { handSlot, placeType: null }); setSelection((current) => {
if (current.handSlot !== handSlot) return { handSlot, placeType: null };
if (current.placeType !== null) return { handSlot, placeType: null };
return EMPTY_SELECTION;
});
} }
function choosePlace(placeType: PlaceType) { function choosePlace(placeType: PlaceType) {
@@ -102,6 +149,23 @@ function App() {
setSelection({ ...selection, placeType }); setSelection({ ...selection, placeType });
} }
function cancelPlace() {
setSelection((current) => current.handSlot === null ? current : { ...current, placeType: null });
}
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
setSelection((current) => {
if (current.placeType !== null) return { ...current, placeType: null };
if (current.handSlot !== null) return EMPTY_SELECTION;
return current;
});
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
function legalDraw(drawSource: number): boolean { function legalDraw(drawSource: number): boolean {
if (selection.handSlot === null || selection.placeType === null) return false; if (selection.handSlot === null || selection.placeType === null) return false;
return legal[encodeAction(selection.handSlot, selection.placeType, drawSource)]; return legal[encodeAction(selection.handSlot, selection.placeType, drawSource)];
@@ -115,6 +179,15 @@ function App() {
setSelection(EMPTY_SELECTION); setSelection(EMPTY_SELECTION);
} }
// Cards actually drawn in the hand row (the selected card moves into the
// lane preview mid-placement); spacing is derived from this rendered list so
// the leftmost visible card never carries a stale margin.
const renderedHand = humanHand.flatMap((card, slot) =>
selection.placeType !== null && selection.handSlot === slot ? [] : [{ card, slot }],
);
const handCardMarginLeft =
handCardStep(viewportWidth, renderedHand.length) - handLayout(viewportWidth).cardWidth;
const canPlaySelected = selection.handSlot !== null && Array.from( const canPlaySelected = selection.handSlot !== null && Array.from(
{ length: 6 }, { length: 6 },
(_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)], (_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)],
@@ -133,7 +206,9 @@ function App() {
</section> </section>
<div className="opponent-hand" aria-label="Rival hand"> <div className="opponent-hand" aria-label="Rival hand">
{opponentHand.map((card) => <CardBack key={card} />)} {opponentHand.map((card) => (
state.handPublic[card] ? <Card compact card={card} key={card} /> : <CardBack key={card} />
))}
</div> </div>
<div className="menu-wrap"> <div className="menu-wrap">
@@ -155,6 +230,7 @@ function App() {
canPlay={canPlaySelected} canPlay={canPlaySelected}
canDraw={legalDraw} canDraw={legalDraw}
onChoosePlace={choosePlace} onChoosePlace={choosePlace}
onCancelPlace={cancelPlace}
onDraw={commit} onDraw={commit}
/> />
</div> </div>
@@ -174,14 +250,14 @@ function App() {
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""}`}>{status}</p> <p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""}`}>{status}</p>
<section className="human-hand" aria-label="Your hand"> <section className="human-hand" aria-label="Your hand">
{humanHand.map((card, index) => ( {renderedHand.map(({ card, slot }, position) => (
selection.placeType !== null && selection.handSlot === index ? null :
<Card <Card
card={card} card={card}
key={card} key={card}
selected={selection.handSlot === index} selected={selection.handSlot === slot}
disabled={state.toMove !== 0 || state.done} disabled={state.toMove !== 0 || state.done}
onClick={() => chooseCard(index)} onClick={() => chooseCard(slot)}
style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }}
/> />
))} ))}
</section> </section>
+29 -10
View File
@@ -16,9 +16,25 @@ interface BoardProps {
canPlay: boolean; canPlay: boolean;
canDraw: (source: number) => boolean; canDraw: (source: number) => boolean;
onChoosePlace: (place: PlaceType) => void; onChoosePlace: (place: PlaceType) => void;
onCancelPlace: () => void;
onDraw: (source: number) => void; onDraw: (source: number) => void;
} }
// Compact expedition cards are 124px tall with a default -94px overlap (30px
// visible per card). A full 12-card expedition would need ~454px of stack
// height, far more than the lane zone's available space, so long stacks
// overlap more tightly to stay within the zone (and never cover the discard
// button, score chip, or spill outside the board).
const STACK_ZONE_BUDGET = 190;
const COMPACT_CARD_HEIGHT = 124;
function stackOverlapStyle(count: number): React.CSSProperties {
if (count <= 1) return {};
const perCard = (STACK_ZONE_BUDGET - COMPACT_CARD_HEIGHT) / (count - 1);
const overlap = Math.min(-94, Math.round(perCard - COMPACT_CARD_HEIGHT));
return { "--stack-overlap": `${overlap}px` } as React.CSSProperties;
}
function expeditionScore(cards: number[]): string | null { function expeditionScore(cards: number[]): string | null {
if (!cards.length) return null; if (!cards.length) return null;
const handshakes = cards.filter(isHandshake).length; const handshakes = cards.filter(isHandshake).length;
@@ -34,6 +50,7 @@ export function Board({
canPlay, canPlay,
canDraw, canDraw,
onChoosePlace, onChoosePlace,
onCancelPlace,
onDraw, onDraw,
}: BoardProps) { }: BoardProps) {
return ( return (
@@ -45,6 +62,8 @@ export function Board({
const playTarget = selectedColor === color && selectedPlace === null && canPlay; const playTarget = selectedColor === color && selectedPlace === null && canPlay;
const discardTarget = selectedColor === color && selectedPlace === null; const discardTarget = selectedColor === color && selectedPlace === null;
const drawTarget = selectedPlace !== null && canDraw(color + 1); const drawTarget = selectedPlace !== null && canDraw(color + 1);
const discardChosen = selectedPlace === DISCARD && selectedColor === color;
const playChosen = selectedPlace === PLAY && selectedColor === color;
return ( return (
<article <article
className="lane" className="lane"
@@ -53,7 +72,7 @@ export function Board({
> >
<div className="lane__zone lane__zone--opponent"> <div className="lane__zone lane__zone--opponent">
{opponent.length ? ( {opponent.length ? (
<div className="card-stack card-stack--opponent"> <div className="card-stack card-stack--opponent" style={stackOverlapStyle(opponent.length)}>
{opponent.map((card) => <Card compact card={card} key={card} />)} {opponent.map((card) => <Card compact card={card} key={card} />)}
</div> </div>
) : ( ) : (
@@ -64,10 +83,10 @@ export function Board({
<button <button
type="button" type="button"
className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${selectedPlace === DISCARD && selectedColor === color ? "is-chosen" : ""}`} className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${discardChosen ? "is-chosen" : ""}`}
onClick={() => drawTarget ? onDraw(color + 1) : discardTarget ? onChoosePlace(DISCARD) : undefined} onClick={() => drawTarget ? onDraw(color + 1) : discardChosen ? onCancelPlace() : discardTarget ? onChoosePlace(DISCARD) : undefined}
disabled={!discardTarget && !drawTarget} disabled={!discardTarget && !drawTarget && !discardChosen}
aria-label={`${name} discard pile`} aria-label={discardChosen ? `Cancel discarding to ${name} pile` : `${name} discard pile`}
> >
{discard.length ? <Card compact card={discard.at(-1)!} /> : <span className="lane__ghost">{COLOR_GLYPHS[color]}</span>} {discard.length ? <Card compact card={discard.at(-1)!} /> : <span className="lane__ghost">{COLOR_GLYPHS[color]}</span>}
{discard.length > 1 && <b className="pile-count">×{discard.length}</b>} {discard.length > 1 && <b className="pile-count">×{discard.length}</b>}
@@ -76,13 +95,13 @@ export function Board({
<button <button
type="button" type="button"
className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${selectedPlace === PLAY && selectedColor === color ? "is-chosen" : ""}`} className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${playChosen ? "is-chosen" : ""}`}
onClick={() => playTarget ? onChoosePlace(PLAY) : undefined} onClick={() => playChosen ? onCancelPlace() : playTarget ? onChoosePlace(PLAY) : undefined}
disabled={!playTarget} disabled={!playTarget && !playChosen}
aria-label={`Your ${name} expedition`} aria-label={playChosen ? `Cancel playing to your ${name} expedition` : `Your ${name} expedition`}
> >
{mine.length ? ( {mine.length ? (
<div className="card-stack card-stack--mine"> <div className="card-stack card-stack--mine" style={stackOverlapStyle(mine.length)}>
{mine.map((card) => <Card compact card={card} key={card} />)} {mine.map((card) => <Card compact card={card} key={card} />)}
</div> </div>
) : ( ) : (
+3 -2
View File
@@ -13,14 +13,15 @@ interface CardProps {
selected?: boolean; selected?: boolean;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
style?: React.CSSProperties;
} }
export function Card({ card, compact = false, selected = false, disabled = false, onClick }: CardProps) { export function Card({ card, compact = false, selected = false, disabled = false, onClick, style: styleOverride }: CardProps) {
const color = cardColor(card); const color = cardColor(card);
const value = cardValue(card); const value = cardValue(card);
const handshake = isHandshake(card); const handshake = isHandshake(card);
const className = `card ${compact ? "card--compact" : ""} ${selected ? "card--selected" : ""}`; const className = `card ${compact ? "card--compact" : ""} ${selected ? "card--selected" : ""}`;
const style = { "--card-color": COLOR_HEX[color] } as React.CSSProperties; const style = { "--card-color": COLOR_HEX[color], ...styleOverride } as React.CSSProperties;
const content = ( const content = (
<> <>
<span className="card__corner card__corner--top"> <span className="card__corner card__corner--top">
+8 -1
View File
@@ -40,7 +40,7 @@ class OnnxPolicy implements Policy {
} }
} }
class HeuristicPolicy implements Policy { export class HeuristicPolicy implements Policy {
readonly provider = "heuristic" as const; readonly provider = "heuristic" as const;
async action(state: GameState): Promise<number> { async action(state: GameState): Promise<number> {
@@ -100,3 +100,10 @@ export function loadPolicy(): Promise<{ policy: Policy; warning?: string }> {
policyPromise ??= initializePolicy(); policyPromise ??= initializePolicy();
return policyPromise; return policyPromise;
} }
let heuristicFallback: HeuristicPolicy | undefined;
export function fallbackHeuristicPolicy(): HeuristicPolicy {
heuristicFallback ??= new HeuristicPolicy();
return heuristicFallback;
}
+21 -4
View File
@@ -19,7 +19,10 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { min-width: 1024px; min-height: 100%; margin: 0; } html, body, #root { min-width: 1024px; min-height: 100%; margin: 0; }
body { min-height: 100vh; overflow: hidden; background: #101313; } /* Below the supported minimum width/height the board no longer shrinks
(app-shell keeps a 1024x720 floor), so allow scrolling instead of clipping
content the user has no other way to reach. */
body { min-height: 100vh; overflow: auto; background: #101313; }
button { color: inherit; font: inherit; } button { color: inherit; font: inherit; }
button:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; } button:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; }
@@ -52,7 +55,10 @@ button:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; }
.score-plaque { .score-plaque {
position: absolute; position: absolute;
left: 24px; left: 24px;
z-index: 10; /* Below the hand rows in stacking order: the hand's card layout keeps clear
of the plaques at all supported widths, but this ensures a click always
lands on a card, never the plaque, if that ever regresses. */
z-index: 6;
width: 236px; width: 236px;
height: 64px; height: 64px;
padding: 10px 15px; padding: 10px 15px;
@@ -217,7 +223,7 @@ button.lane__discard:disabled { opacity: 1; }
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.card-stack .card + .card { margin-top: -94px; } .card-stack .card + .card { margin-top: var(--stack-overlap, -94px); }
.card-stack--opponent { justify-content: flex-start; } .card-stack--opponent { justify-content: flex-start; }
.card-stack--mine { justify-content: flex-end; } .card-stack--mine { justify-content: flex-end; }
.score-chip { .score-chip {
@@ -445,7 +451,18 @@ button.card:disabled { cursor: default; }
.human-hand { height: 146px; } .human-hand { height: 146px; }
.turn-prompt { bottom: 177px; } .turn-prompt { bottom: 177px; }
.table-center { bottom: 210px; } .table-center { bottom: 210px; }
.score-plaque { width: 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
back their readable width. Mirrored by HAND_LAYOUTS in App.tsx. */
.score-plaque {
left: 16px;
width: 152px;
height: 56px;
padding: 8px 12px;
}
.score-plaque small { display: none; }
.score-plaque strong { font-size: 12px; letter-spacing: 0.14em; }
.score-plaque > b { font-size: 26px; }
} }
@media (max-height: 820px) { @media (max-height: 820px) {