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
118 lines
4.9 KiB
TypeScript
118 lines
4.9 KiB
TypeScript
import { Card } from "./Card";
|
||
import {
|
||
COLOR_GLYPHS,
|
||
COLOR_HEX,
|
||
EXPEDITION_NAMES,
|
||
cardRank,
|
||
isHandshake,
|
||
} from "../game/cards";
|
||
import { cardsOnBoard } from "../game/engine";
|
||
import { DISCARD, PLAY, type GameState, type PlaceType } from "../game/types";
|
||
|
||
interface BoardProps {
|
||
state: GameState;
|
||
selectedColor: number | null;
|
||
selectedPlace: PlaceType | null;
|
||
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;
|
||
const ranks = cards.reduce((sum, card) => sum + cardRank(card), 0);
|
||
const score = (ranks - 20) * (1 + handshakes) + (cards.length >= 8 ? 20 : 0);
|
||
return `${score >= 0 ? "+" : ""}${score}${handshakes ? ` ×${handshakes + 1}` : ""}`;
|
||
}
|
||
|
||
export function Board({
|
||
state,
|
||
selectedColor,
|
||
selectedPlace,
|
||
canPlay,
|
||
canDraw,
|
||
onChoosePlace,
|
||
onCancelPlace,
|
||
onDraw,
|
||
}: BoardProps) {
|
||
return (
|
||
<section className="board" aria-label="Expeditions and discard piles">
|
||
{EXPEDITION_NAMES.map((name, color) => {
|
||
const opponent = cardsOnBoard(state, 1, color);
|
||
const mine = cardsOnBoard(state, 0, color);
|
||
const discard = state.piles[color];
|
||
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"
|
||
key={name}
|
||
style={{ "--lane-color": COLOR_HEX[color] } as React.CSSProperties}
|
||
>
|
||
<div className="lane__zone lane__zone--opponent">
|
||
{opponent.length ? (
|
||
<div className="card-stack card-stack--opponent" style={stackOverlapStyle(opponent.length)}>
|
||
{opponent.map((card) => <Card compact card={card} key={card} />)}
|
||
</div>
|
||
) : (
|
||
<span className="lane__ghost">{COLOR_GLYPHS[color]}</span>
|
||
)}
|
||
{expeditionScore(opponent) && <b className="score-chip">{expeditionScore(opponent)}</b>}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
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>}
|
||
</button>
|
||
<span className="lane__name">{name}</span>
|
||
|
||
<button
|
||
type="button"
|
||
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" style={stackOverlapStyle(mine.length)}>
|
||
{mine.map((card) => <Card compact card={card} key={card} />)}
|
||
</div>
|
||
) : (
|
||
<span className="lane__ghost">{COLOR_GLYPHS[color]}</span>
|
||
)}
|
||
{expeditionScore(mine) && <b className="score-chip">{expeditionScore(mine)}</b>}
|
||
</button>
|
||
</article>
|
||
);
|
||
})}
|
||
</section>
|
||
);
|
||
}
|