Remove hints; gate undo/redo behind a setting and limit it to your turn

Three changes to how much the client helps you:

- The hint feature is gone -- button, board highlights, the whole path that asked
  the policy for your best move. Playing against the model shouldn't come with the
  model telling you what to do.

- Undo/redo is off by default and enabled from a menu toggle. Taking moves back is
  a training aid, not how the game is played, so the honest game is the default.
  The switch is reactive: flipping it on mid-game shows the controls immediately,
  no restart.

- When on, undo/redo is limited to the current turn. The floor is the most recent
  committed move -- a rival move or your own draw -- so you can revise a card
  selection or placement before you draw, but you can't rewind into the rival's
  move or an earlier turn. Verified in a browser: undo is disabled at turn start,
  enabled after selecting a card, and disabled again once you've drawn and the
  rival has answered.

Board and Card lose their now-dead hint props and the hint CSS goes with them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
This commit is contained in:
2026-07-15 17:56:16 +09:00
co-authored by Claude Opus 4.8
parent 5e177cc369
commit 267db367d8
4 changed files with 61 additions and 141 deletions
+40 -81
View File
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Board, type BoardHint } from "./components/Board";
import { Board } from "./components/Board";
import { Card, CardBack } from "./components/Card";
import { ResultCard } from "./components/ResultCard";
import { describeAction } from "./game/actions";
import { cardColor, cardName } from "./game/cards";
import {
boardScore,
@@ -39,10 +38,13 @@ function loadMode(): Mode {
return window.localStorage.getItem(MODE_KEY) === "3" ? 3 : 1;
}
interface Hint {
action: number;
text: string;
probability: number | null;
// Off by default: taking moves back is a training aid, not how the game is played,
// and the default should be the honest game. When on, it is limited to the current
// turn (see turnFloor) -- you can revise a misclick, not rewind the rival.
const UNDO_KEY = "lost-cities.undo";
function loadUndoEnabled(): boolean {
return window.localStorage.getItem(UNDO_KEY) === "on";
}
type Selection = { handSlot: number | null; placeType: PlaceType | null };
@@ -139,8 +141,7 @@ function App() {
const [thinking, setThinking] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
const [hint, setHint] = useState<Hint | null>(null);
const [hintPending, setHintPending] = useState(false);
const [undoEnabled, setUndoEnabled] = useState(loadUndoEnabled);
const generation = useRef(0);
const gameId = useRef(newGameId());
const startedAt = useRef(new Date().toISOString());
@@ -150,13 +151,26 @@ function App() {
const { state: match, selection } = frames[cursor];
// The board on screen is the round in play; the match is what decides the game.
const state = match.round;
const canUndo = cursor > 0;
const canRedo = cursor < frames.length - 1;
// The start of the current turn: the most recent committed move at or before the
// cursor (a rival move, or your own draw). Undo cannot cross it, so you can take
// back a card selection or placement within your turn but never rewind into the
// rival's move or an earlier turn.
const turnFloor = useMemo(() => {
for (let index = cursor; index > 0; index -= 1) {
if (frames[index].move) return index;
}
return 0;
}, [frames, cursor]);
const canUndo = undoEnabled && cursor > turnFloor;
const canRedo = undoEnabled && cursor < frames.length - 1;
const latestMatch = useRef(match);
useEffect(() => { publishSeed(seed); }, [seed]);
useEffect(() => { latestMatch.current = match; }, [match]);
useEffect(() => { window.localStorage.setItem(MODE_KEY, String(mode)); }, [mode]);
useEffect(() => {
window.localStorage.setItem(UNDO_KEY, undoEnabled ? "on" : "off");
}, [undoEnabled]);
useEffect(() => {
const saved: SavedGame = { version: 2, seed, mode, frames, cursor, resultOpen };
window.localStorage.setItem(SAVED_GAME_KEY, JSON.stringify(saved));
@@ -258,7 +272,6 @@ function App() {
setCursor(0);
setSeedDraft("");
setResultOpen(true);
setHint(null);
setThinking(false);
setMenuOpen(false);
}
@@ -272,49 +285,12 @@ function App() {
if (next < 0 || next >= frames.length) return;
generation.current += 1;
setCursor(next);
setHint(null);
setThinking(false);
setMenuOpen(false);
}
const undo = () => moveCursor(cursor - 1);
const redo = () => moveCursor(cursor + 1);
/** Leave the reviewed position as the live one and let the rival play on. */
function resumeFromHere() {
setFrames((items) => items.slice(0, cursor + 1));
setMenuOpen(false);
}
/** Ask the policy driving the rival what it would do in your seat. */
async function requestHint() {
if (!policy || match.done || state.toMove !== 0 || hintPending) return;
const position = match;
setHintPending(true);
setMenuOpen(false);
try {
const ranked = await policy.rank(position).catch(async (error) => {
console.error("hint inference failed; falling back to heuristic", error);
return fallbackHeuristicPolicy().rank(position);
});
const [best] = ranked;
// The position can move on while inference runs — a hint for a stale
// position would point at the wrong hand slot.
if (best === undefined || latestMatch.current !== position) return;
setHint({
action: best.action,
text: describeAction(position.round, best.action),
probability: best.probability,
});
} catch (error) {
console.error("hint failed", error);
} finally {
setHintPending(false);
}
}
/** A hint answers one position; drop it as soon as anything is done or undone. */
useEffect(() => { setHint(null); }, [cursor, frames]);
const undo = () => { if (canUndo) moveCursor(cursor - 1); };
const redo = () => { if (canRedo) moveCursor(cursor + 1); };
function chooseCard(handSlot: number) {
if (state.toMove !== 0 || match.done) return;
@@ -374,17 +350,6 @@ function App() {
(_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)],
).some(Boolean);
const hintMove = hint === null ? null : decodeAction(hint.action);
const hintedCard = hintMove === null ? undefined : humanHand[hintMove.handSlot];
const boardHint: BoardHint | null = hintMove === null || hintedCard === undefined ? null : {
color: cardColor(hintedCard),
place: hintMove.placeType,
drawSource: hintMove.drawSource,
};
const hintConfidence = hint !== null && hint.probability !== null
? `${Math.round(hint.probability * 100)}%`
: null;
const canHint = policy !== null && !match.done && state.toMove === 0;
const outcome = scores[0] === scores[1]
? "DRAW"
@@ -520,6 +485,14 @@ function App() {
</label>
<button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}>DEAL THIS SEED</button>
<span>THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC</span>
<label className="menu-toggle">
<input
type="checkbox"
checked={undoEnabled}
onChange={(event) => setUndoEnabled(event.target.checked)}
/>
<span>ENABLE UNDO / REDO · WITHIN YOUR TURN</span>
</label>
<span>{policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}</span>
</div>
)}
@@ -550,7 +523,6 @@ function App() {
selectedPlace={selection.placeType}
canPlay={canPlaySelected}
canDraw={legalDraw}
hint={boardHint}
cardRef={cardRef}
onChoosePlace={choosePlace}
onCancelPlace={cancelPlace}
@@ -560,7 +532,7 @@ function App() {
<button
type="button"
className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""} ${hintMove?.drawSource === DRAW_DECK ? "is-hinted" : ""}`}
className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""}`}
disabled={!legalDraw(DRAW_DECK)}
onClick={() => commit(DRAW_DECK)}
aria-label={`Draw from deck, ${N_CARDS - state.drawPtr} cards left`}
@@ -572,7 +544,7 @@ function App() {
<div className="prompt-row">
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""} ${reviewingRival ? "turn-prompt--review" : ""}`}>
{hint ? `Hint — ${hint.text}${hintConfidence ? ` (${hintConfidence})` : ""}` : status}
{status}
</p>
</div>
@@ -582,7 +554,6 @@ function App() {
card={card}
key={card}
selected={selection.handSlot === slot}
hinted={hintedCard === card}
disabled={state.toMove !== 0 || match.done}
onClick={() => chooseCard(slot)}
style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }}
@@ -596,28 +567,16 @@ function App() {
</section>
<div className="control-stack">
<button
type="button"
className={`hint-button ${hint ? "is-active" : ""}`}
onClick={requestHint}
disabled={!canHint || hintPending}
>
{hintPending ? "THINKING…" : "HINT"}
</button>
{undoEnabled && (
<>
<button type="button" onClick={undo} disabled={!canUndo} aria-label="Undo one action" title="Undo (←)">
<span>UNDO</span>
</button>
<button type="button" onClick={redo} disabled={!canRedo} aria-label="Redo one action" title="Redo (→)">
<span>REDO</span>
</button>
<button
type="button"
className="control-stack__resume"
onClick={resumeFromHere}
disabled={!rivalSuspended}
>
PLAY FROM HERE
</button>
</>
)}
<button
type="button"
className={`control-stack__score ${state.done && !resultOpen ? "is-available" : ""}`}
+2 -14
View File
@@ -9,20 +9,12 @@ import {
import { cardsOnBoard } from "../game/engine";
import { DISCARD, PLAY, type GameState, type PlaceType } from "../game/types";
/** Where the hint says to put the card and where to draw from, if hinting. */
export interface BoardHint {
color: number;
place: PlaceType;
drawSource: number;
}
interface BoardProps {
state: GameState;
selectedColor: number | null;
selectedPlace: PlaceType | null;
canPlay: boolean;
canDraw: (source: number) => boolean;
hint: BoardHint | null;
cardRef: (card: number) => (element: HTMLElement | null) => void;
onChoosePlace: (place: PlaceType) => void;
onCancelPlace: () => void;
@@ -58,7 +50,6 @@ export function Board({
selectedPlace,
canPlay,
canDraw,
hint,
cardRef,
onChoosePlace,
onCancelPlace,
@@ -75,9 +66,6 @@ export function Board({
const drawTarget = selectedPlace !== null && canDraw(color + 1);
const discardChosen = selectedPlace === DISCARD && selectedColor === color;
const playChosen = selectedPlace === PLAY && selectedColor === color;
const hintPlay = hint?.place === PLAY && hint.color === color;
const hintDiscard = hint?.place === DISCARD && hint.color === color;
const hintDraw = hint?.drawSource === color + 1;
return (
<article
className="lane"
@@ -97,7 +85,7 @@ export function Board({
<button
type="button"
className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${discardChosen ? "is-chosen" : ""} ${hintDiscard || hintDraw ? "is-hinted" : ""}`}
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`}
@@ -113,7 +101,7 @@ export function Board({
<button
type="button"
className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${playChosen ? "is-chosen" : ""} ${hintPlay ? "is-hinted" : ""}`}
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`}
+1 -3
View File
@@ -14,7 +14,6 @@ interface CardProps {
* hand row is only as tall as a card back, so a full card would burst it. */
mini?: boolean;
selected?: boolean;
hinted?: boolean;
disabled?: boolean;
onClick?: () => void;
style?: React.CSSProperties;
@@ -26,7 +25,6 @@ export function Card({
compact = false,
mini = false,
selected = false,
hinted = false,
disabled = false,
onClick,
style: styleOverride,
@@ -35,7 +33,7 @@ export function Card({
const color = cardColor(card);
const value = cardValue(card);
const handshake = isHandshake(card);
const className = `card ${compact ? "card--compact" : ""} ${mini ? "card--mini" : ""} ${selected ? "card--selected" : ""} ${hinted ? "card--hinted" : ""}`;
const className = `card ${compact ? "card--compact" : ""} ${mini ? "card--mini" : ""} ${selected ? "card--selected" : ""}`;
const style = { "--card-color": COLOR_HEX[color], ...styleOverride } as React.CSSProperties;
const content = (
<>
+12 -37
View File
@@ -333,28 +333,10 @@ button.lane__discard:disabled { opacity: 1; }
pointer-events: none;
}
/* The hinted card, its destination, and its draw source. */
.card--hinted { box-shadow: 0 0 0 3px var(--gold), 0 0 26px rgba(201, 163, 75, 0.35); }
.lane__zone.is-hinted,
.lane__discard.is-hinted,
.deck-stack.is-hinted { animation: target-pulse 1.6s ease-in-out infinite; }
.lane__zone.is-hinted .lane__ghost,
.lane__discard.is-hinted .lane__ghost {
border-color: var(--gold);
border-style: dashed;
opacity: 0.9;
}
.lane__zone.is-hinted .card,
.lane__discard.is-hinted .card,
.deck-stack.is-hinted .card { box-shadow: 0 0 0 2px var(--gold), 0 0 26px rgba(201, 163, 75, 0.3); }
@media (prefers-reduced-motion: reduce) {
.lane__zone.is-target,
.lane__discard.is-target,
.lane__discard.is-draw-target,
.lane__zone.is-hinted,
.lane__discard.is-hinted,
.deck-stack.is-hinted,
.deck-stack.is-draw-target { animation: none; }
}
@@ -541,19 +523,6 @@ button.card:disabled { cursor: default; }
.turn-prompt--thinking { color: #c6b887; }
.turn-prompt--review { color: #9fb4c8; }
.hint-button {
height: 32px;
padding: 0 14px;
border: 1px solid var(--gold-dim);
border-radius: 9px;
background: rgba(7, 13, 12, 0.9);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.16em;
cursor: pointer;
}
.hint-button:hover:not(:disabled) { border-color: var(--gold); }
.hint-button:disabled { opacity: 0.35; cursor: default; }
.hint-button.is-active { border-color: var(--gold); color: var(--gold); }
.human-hand {
position: absolute;
@@ -682,12 +651,6 @@ button.card:disabled { cursor: default; }
.control-stack button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.control-stack button:hover:not(:disabled) { border-color: var(--gold); }
.control-stack button:disabled { opacity: 0.3; cursor: default; }
.control-stack__resume {
border-color: var(--gold) !important;
color: var(--gold);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.14em;
}
.control-stack__score { visibility: hidden; }
.control-stack__score.is-available { visibility: visible; }
@@ -801,3 +764,15 @@ button.card:disabled { cursor: default; }
color: #f0d9a6;
font-weight: 700;
}
/* The undo/redo settings switch. Off by default: taking moves back is a training
aid, and the honest game is the sensible default. */
.menu-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
cursor: pointer;
}
.menu-toggle input { width: 15px; height: 15px; accent-color: var(--gold); cursor: pointer; }
.menu-toggle span { padding: 0; color: #9a9d97; }