Show placed cards before drawing

This commit is contained in:
2026-07-14 20:37:59 +09:00
parent 891cc113f0
commit e47db5eb1e
3 changed files with 64 additions and 17 deletions
+12 -4
View File
@@ -9,6 +9,7 @@ import {
encodeAction, encodeAction,
legalActionMask, legalActionMask,
newGame, newGame,
previewPlacement,
step, step,
} from "./game/engine"; } from "./game/engine";
import { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types"; 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 [menuOpen, setMenuOpen] = useState(false);
const generation = useRef(0); const generation = useRef(0);
const scores = useMemo(() => boardScore(state), [state]);
const humanHand = useMemo(() => currentHandSorted(state, 0), [state]); const humanHand = useMemo(() => currentHandSorted(state, 0), [state]);
const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]); const opponentHand = useMemo(() => currentHandSorted(state, 1), [state]);
const legal = useMemo(() => legalActionMask(state), [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -106,8 +115,6 @@ function App() {
setSelection(EMPTY_SELECTION); 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( const canPlaySelected = selection.handSlot !== null && Array.from(
{ length: 6 }, { length: 6 },
(_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)], (_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)],
@@ -142,7 +149,7 @@ function App() {
<div className="table-center"> <div className="table-center">
<Board <Board
state={state} state={displayState}
selectedColor={selectedColor} selectedColor={selectedColor}
selectedPlace={selection.placeType} selectedPlace={selection.placeType}
canPlay={canPlaySelected} canPlay={canPlaySelected}
@@ -168,6 +175,7 @@ function App() {
<section className="human-hand" aria-label="Your hand"> <section className="human-hand" aria-label="Your hand">
{humanHand.map((card, index) => ( {humanHand.map((card, index) => (
selection.placeType !== null && selection.handSlot === index ? null :
<Card <Card
card={card} card={card}
key={card} key={card}
+24 -2
View File
@@ -1,7 +1,16 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { boardScore, currentHandSorted, encodeAction, legalActionMask, resetFromOrder, step } from "./engine"; import {
import { DISCARD, DRAW_DECK, N_CARDS } from "./types"; boardScore,
cardsOnBoard,
currentHandSorted,
encodeAction,
legalActionMask,
previewPlacement,
resetFromOrder,
step,
} from "./engine";
import { DISCARD, DRAW_DECK, N_CARDS, PLAY } from "./types";
import { cardColor } from "./cards"; import { cardColor } from "./cards";
import { observation } from "./observation"; import { observation } from "./observation";
import fixture from "./parity-fixture.json"; import fixture from "./parity-fixture.json";
@@ -24,6 +33,19 @@ describe("JAX-compatible game engine", () => {
expect(mask[encodeAction(0, DISCARD, DRAW_DECK)]).toBe(true); 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", () => { test("deck-only games terminate after exactly 44 plies", () => {
let state = resetFromOrder(orderedDeck); let state = resetFromOrder(orderedDeck);
let plies = 0; let plies = 0;
+28 -11
View File
@@ -114,18 +114,8 @@ export function legalActionMask(state: GameState): boolean[] {
return mask; return mask;
} }
export function step(state: GameState, action: number): GameState { function placeCard(next: GameState, player: number, card: number, placeType: PlaceType): void {
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];
const color = cardColor(card); const color = cardColor(card);
next.handPublic[card] = false; next.handPublic[card] = false;
if (placeType === PLAY) { if (placeType === PLAY) {
next.cardLoc[card] = LOC_P0_BOARD + player; 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.cardLoc[card] = LOC_DISCARD;
next.piles[color].push(card); 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; let drawnCard: number;
if (drawSource === DRAW_DECK) { if (drawSource === DRAW_DECK) {