From e47db5eb1ea7c7dace810c81870c1668fd8ebc2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Tue, 14 Jul 2026 20:37:59 +0900 Subject: [PATCH] Show placed cards before drawing --- web/src/App.tsx | 16 +++++++++++---- web/src/game/engine.test.ts | 26 +++++++++++++++++++++++-- web/src/game/engine.ts | 39 ++++++++++++++++++++++++++----------- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2ec2b23..e3183d5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { encodeAction, legalActionMask, newGame, + previewPlacement, step, } from "./game/engine"; import { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types"; @@ -27,10 +28,18 @@ function App() { const [menuOpen, setMenuOpen] = useState(false); const generation = useRef(0); - const scores = useMemo(() => boardScore(state), [state]); const humanHand = useMemo(() => currentHandSorted(state, 0), [state]); const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]); const legal = useMemo(() => legalActionMask(state), [state]); + const selectedCard = selection.handSlot === null ? null : humanHand[selection.handSlot]; + const selectedColor = selectedCard === null ? null : cardColor(selectedCard); + const displayState = useMemo( + () => selection.handSlot === null || selection.placeType === null + ? state + : previewPlacement(state, selection.handSlot, selection.placeType), + [selection, state], + ); + const scores = useMemo(() => boardScore(displayState), [displayState]); useEffect(() => { let cancelled = false; @@ -106,8 +115,6 @@ function App() { setSelection(EMPTY_SELECTION); } - const selectedCard = selection.handSlot === null ? null : humanHand[selection.handSlot]; - const selectedColor = selectedCard === null ? null : cardColor(selectedCard); const canPlaySelected = selection.handSlot !== null && Array.from( { length: 6 }, (_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)], @@ -142,7 +149,7 @@ function App() {
{humanHand.map((card, index) => ( + selection.placeType !== null && selection.handSlot === index ? null : { expect(mask[encodeAction(0, DISCARD, DRAW_DECK)]).toBe(true); }); + test("previews a placed card without ending the turn", () => { + const state = resetFromOrder(orderedDeck); + const card = currentHandSorted(state)[3]; + const preview = previewPlacement(state, 3, PLAY); + + expect(currentHandSorted(preview, 0)).not.toContain(card); + expect(cardsOnBoard(preview, 0, cardColor(card))).toContain(card); + expect(preview.toMove).toBe(0); + expect(preview.drawPtr).toBe(state.drawPtr); + expect(preview.stepCount).toBe(state.stepCount); + expect(currentHandSorted(state, 0)).toContain(card); + }); + test("deck-only games terminate after exactly 44 plies", () => { let state = resetFromOrder(orderedDeck); let plies = 0; diff --git a/web/src/game/engine.ts b/web/src/game/engine.ts index 35de35e..d58713d 100644 --- a/web/src/game/engine.ts +++ b/web/src/game/engine.ts @@ -114,18 +114,8 @@ export function legalActionMask(state: GameState): boolean[] { return mask; } -export function step(state: GameState, action: number): GameState { - const mask = legalActionMask(state); - if (!Number.isInteger(action) || action < 0 || action >= N_ACTIONS || !mask[action]) { - return state; - } - const next = cloneState(state); - const { handSlot, placeType, drawSource } = decodeAction(action); - const player = state.toMove; - const hand = currentHandSorted(state, player); - const card = hand[handSlot]; +function placeCard(next: GameState, player: number, card: number, placeType: PlaceType): void { const color = cardColor(card); - next.handPublic[card] = false; if (placeType === PLAY) { next.cardLoc[card] = LOC_P0_BOARD + player; @@ -136,6 +126,33 @@ export function step(state: GameState, action: number): GameState { next.cardLoc[card] = LOC_DISCARD; next.piles[color].push(card); } +} + +export function previewPlacement(state: GameState, handSlot: number, placeType: PlaceType): GameState { + const mask = legalActionMask(state); + const hasLegalDraw = Array.from( + { length: N_COLORS + 1 }, + (_, drawSource) => mask[encodeAction(handSlot, placeType, drawSource)], + ).some(Boolean); + if (!hasLegalDraw) return state; + + const next = cloneState(state); + const card = currentHandSorted(state)[handSlot]; + placeCard(next, state.toMove, card, placeType); + return next; +} + +export function step(state: GameState, action: number): GameState { + const mask = legalActionMask(state); + if (!Number.isInteger(action) || action < 0 || action >= N_ACTIONS || !mask[action]) { + return state; + } + const next = cloneState(state); + const { handSlot, placeType, drawSource } = decodeAction(action); + const player = state.toMove; + const hand = currentHandSorted(state, player); + const card = hand[handSlot]; + placeCard(next, player, card, placeType); let drawnCard: number; if (drawSource === DRAW_DECK) {