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:
+46
-87
@@ -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>
|
||||
<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>
|
||||
{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__score ${state.done && !resultOpen ? "is-available" : ""}`}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user