Files
coorl-lost-cities/web/src/components/Board.tsx
T
coolguyandClaude Opus 4.8 299c788545 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
2026-07-14 21:04:27 +09:00

118 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}