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) {