From 267db367d865cf2dc92de300d7d8af15096b6a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Wed, 15 Jul 2026 17:56:16 +0900 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh --- web/src/App.tsx | 133 ++++++++++++----------------------- web/src/components/Board.tsx | 16 +---- web/src/components/Card.tsx | 4 +- web/src/styles.css | 49 ++++--------- 4 files changed, 61 insertions(+), 141 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 385b701..87cf0fa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(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() { THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC + {policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"} )} @@ -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() { - - - + {undoEnabled && ( + <> + + + + )}