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:
+86
-10
@@ -13,11 +13,37 @@ import {
|
||||
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";
|
||||
import { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy";
|
||||
|
||||
type Selection = { handSlot: number | null; placeType: 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() {
|
||||
const [state, setState] = useState<GameState>(() => newGame());
|
||||
const [history, setHistory] = useState<GameState[]>([]);
|
||||
@@ -26,8 +52,15 @@ function App() {
|
||||
const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO");
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
|
||||
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 opponentHand = useMemo(() => currentHandSorted(state, 1), [state]);
|
||||
const legal = useMemo(() => legalActionMask(state), [state]);
|
||||
@@ -57,12 +90,22 @@ function App() {
|
||||
const timer = window.setTimeout(async () => {
|
||||
setThinking(true);
|
||||
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;
|
||||
setHistory((items) => [...items, state]);
|
||||
setState(step(state, action));
|
||||
} catch (error) {
|
||||
console.error("AI action failed", error);
|
||||
console.error("AI action failed even with heuristic fallback", error);
|
||||
setModelMessage("MODEL INFERENCE ERROR");
|
||||
} finally {
|
||||
if (generation.current === currentGeneration) setThinking(false);
|
||||
@@ -81,8 +124,8 @@ function App() {
|
||||
}
|
||||
|
||||
function undoTurn() {
|
||||
generation.current += 1;
|
||||
if (!history.length) return;
|
||||
generation.current += 1;
|
||||
let index = history.length - 1;
|
||||
while (index > 0 && history[index].toMove !== 0) index -= 1;
|
||||
setState(history[index]);
|
||||
@@ -94,7 +137,11 @@ function App() {
|
||||
|
||||
function chooseCard(handSlot: number) {
|
||||
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) {
|
||||
@@ -102,6 +149,23 @@ function App() {
|
||||
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 {
|
||||
if (selection.handSlot === null || selection.placeType === null) return false;
|
||||
return legal[encodeAction(selection.handSlot, selection.placeType, drawSource)];
|
||||
@@ -115,6 +179,15 @@ function App() {
|
||||
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(
|
||||
{ length: 6 },
|
||||
(_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)],
|
||||
@@ -133,7 +206,9 @@ function App() {
|
||||
</section>
|
||||
|
||||
<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 className="menu-wrap">
|
||||
@@ -155,6 +230,7 @@ function App() {
|
||||
canPlay={canPlaySelected}
|
||||
canDraw={legalDraw}
|
||||
onChoosePlace={choosePlace}
|
||||
onCancelPlace={cancelPlace}
|
||||
onDraw={commit}
|
||||
/>
|
||||
</div>
|
||||
@@ -174,14 +250,14 @@ function App() {
|
||||
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""}`}>{status}</p>
|
||||
|
||||
<section className="human-hand" aria-label="Your hand">
|
||||
{humanHand.map((card, index) => (
|
||||
selection.placeType !== null && selection.handSlot === index ? null :
|
||||
{renderedHand.map(({ card, slot }, position) => (
|
||||
<Card
|
||||
card={card}
|
||||
key={card}
|
||||
selected={selection.handSlot === index}
|
||||
selected={selection.handSlot === slot}
|
||||
disabled={state.toMove !== 0 || state.done}
|
||||
onClick={() => chooseCard(index)}
|
||||
onClick={() => chooseCard(slot)}
|
||||
style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -16,9 +16,25 @@ interface BoardProps {
|
||||
canPlay: boolean;
|
||||
canDraw: (source: number) => boolean;
|
||||
onChoosePlace: (place: PlaceType) => void;
|
||||
onCancelPlace: () => 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 {
|
||||
if (!cards.length) return null;
|
||||
const handshakes = cards.filter(isHandshake).length;
|
||||
@@ -34,6 +50,7 @@ export function Board({
|
||||
canPlay,
|
||||
canDraw,
|
||||
onChoosePlace,
|
||||
onCancelPlace,
|
||||
onDraw,
|
||||
}: BoardProps) {
|
||||
return (
|
||||
@@ -45,6 +62,8 @@ export function Board({
|
||||
const playTarget = selectedColor === color && selectedPlace === null && canPlay;
|
||||
const discardTarget = selectedColor === color && selectedPlace === null;
|
||||
const drawTarget = selectedPlace !== null && canDraw(color + 1);
|
||||
const discardChosen = selectedPlace === DISCARD && selectedColor === color;
|
||||
const playChosen = selectedPlace === PLAY && selectedColor === color;
|
||||
return (
|
||||
<article
|
||||
className="lane"
|
||||
@@ -53,7 +72,7 @@ export function Board({
|
||||
>
|
||||
<div className="lane__zone lane__zone--opponent">
|
||||
{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} />)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -64,10 +83,10 @@ export function Board({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${selectedPlace === DISCARD && selectedColor === color ? "is-chosen" : ""}`}
|
||||
onClick={() => drawTarget ? onDraw(color + 1) : discardTarget ? onChoosePlace(DISCARD) : undefined}
|
||||
disabled={!discardTarget && !drawTarget}
|
||||
aria-label={`${name} discard pile`}
|
||||
className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${discardChosen ? "is-chosen" : ""}`}
|
||||
onClick={() => drawTarget ? onDraw(color + 1) : discardChosen ? onCancelPlace() : discardTarget ? onChoosePlace(DISCARD) : undefined}
|
||||
disabled={!discardTarget && !drawTarget && !discardChosen}
|
||||
aria-label={discardChosen ? `Cancel discarding to ${name} pile` : `${name} discard pile`}
|
||||
>
|
||||
{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>}
|
||||
@@ -76,13 +95,13 @@ export function Board({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${selectedPlace === PLAY && selectedColor === color ? "is-chosen" : ""}`}
|
||||
onClick={() => playTarget ? onChoosePlace(PLAY) : undefined}
|
||||
disabled={!playTarget}
|
||||
aria-label={`Your ${name} expedition`}
|
||||
className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${playChosen ? "is-chosen" : ""}`}
|
||||
onClick={() => playChosen ? onCancelPlace() : playTarget ? onChoosePlace(PLAY) : undefined}
|
||||
disabled={!playTarget && !playChosen}
|
||||
aria-label={playChosen ? `Cancel playing to your ${name} expedition` : `Your ${name} expedition`}
|
||||
>
|
||||
{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} />)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,14 +13,15 @@ interface CardProps {
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
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 value = cardValue(card);
|
||||
const handshake = isHandshake(card);
|
||||
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 = (
|
||||
<>
|
||||
<span className="card__corner card__corner--top">
|
||||
|
||||
@@ -40,7 +40,7 @@ class OnnxPolicy implements Policy {
|
||||
}
|
||||
}
|
||||
|
||||
class HeuristicPolicy implements Policy {
|
||||
export class HeuristicPolicy implements Policy {
|
||||
readonly provider = "heuristic" as const;
|
||||
|
||||
async action(state: GameState): Promise<number> {
|
||||
@@ -100,3 +100,10 @@ export function loadPolicy(): Promise<{ policy: Policy; warning?: string }> {
|
||||
policyPromise ??= initializePolicy();
|
||||
return policyPromise;
|
||||
}
|
||||
|
||||
let heuristicFallback: HeuristicPolicy | undefined;
|
||||
|
||||
export function fallbackHeuristicPolicy(): HeuristicPolicy {
|
||||
heuristicFallback ??= new HeuristicPolicy();
|
||||
return heuristicFallback;
|
||||
}
|
||||
|
||||
+21
-4
@@ -19,7 +19,10 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
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: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 {
|
||||
position: absolute;
|
||||
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;
|
||||
height: 64px;
|
||||
padding: 10px 15px;
|
||||
@@ -217,7 +223,7 @@ button.lane__discard:disabled { opacity: 1; }
|
||||
flex-direction: column;
|
||||
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--mine { justify-content: flex-end; }
|
||||
.score-chip {
|
||||
@@ -445,7 +451,18 @@ button.card:disabled { cursor: default; }
|
||||
.human-hand { height: 146px; }
|
||||
.turn-prompt { bottom: 177px; }
|
||||
.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) {
|
||||
|
||||
Reference in New Issue
Block a user