diff --git a/web/src/App.tsx b/web/src/App.tsx index e3183d5..f2776c2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(() => newGame()); const [history, setHistory] = useState([]); @@ -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() {
- {opponentHand.map((card) => )} + {opponentHand.map((card) => ( + state.handPublic[card] ? : + ))}
@@ -155,6 +230,7 @@ function App() { canPlay={canPlaySelected} canDraw={legalDraw} onChoosePlace={choosePlace} + onCancelPlace={cancelPlace} onDraw={commit} />
@@ -174,14 +250,14 @@ function App() {

{status}

- {humanHand.map((card, index) => ( - selection.placeType !== null && selection.handSlot === index ? null : + {renderedHand.map(({ card, slot }, position) => ( chooseCard(index)} + onClick={() => chooseCard(slot)} + style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }} /> ))}
diff --git a/web/src/components/Board.tsx b/web/src/components/Board.tsx index ef35ecc..7ba576d 100644 --- a/web/src/components/Board.tsx +++ b/web/src/components/Board.tsx @@ -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 (
{opponent.length ? ( -
+
{opponent.map((card) => )}
) : ( @@ -64,10 +83,10 @@ export function Board({