From aef88d892a17ab90aff2f7bdcb403b67f77ebc6c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 15:03:34 -0400 Subject: [PATCH] Two treasures, one square: the taker names their prize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickUpTreasure grabbed whichever treasure the list offered first — with two on a square, no choice and sometimes the wrong one. The command now takes an optional treasureId (absent in every stored ledger, so old replays keep their old grab), and when the square holds more than one, the button asks "whose?" before dispatching. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 12 +++++---- .../test/terrain-objects-control.test.ts | 16 ++++++++++- packages/web/src/App.svelte | 27 ++++++++++++++++++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 6d04b76..cf42574 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -660,7 +660,7 @@ export type Command = | { type: "cancelAmbush"; ambushId: string } | { type: "counteract"; instanceId: string; params?: { cell?: Cell }; numberInstanceIds?: string[] } | { type: "pass" } - | { type: "pickUpTreasure" } + | { type: "pickUpTreasure"; treasureId?: string } | { type: "pickUpObject"; instanceId: string } | { type: "dropObject"; instanceId: string } | { type: "dropTreasure" } @@ -3380,7 +3380,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman case "cancelAmbush": return doCancelAmbush(state, command.ambushId); case "counteract": return err("nothing to counteract"); case "pass": return err("nothing to pass on"); - case "pickUpTreasure": return doPickUpTreasure(state); + case "pickUpTreasure": return doPickUpTreasure(state, command.treasureId); case "pickUpObject": return doPickUpObject(state, command.instanceId); case "dropObject": return doDropObject(state, command.instanceId); case "dropTreasure": return doDropTreasure(state); @@ -5248,7 +5248,7 @@ function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null { // --- Treasures --------------------------------------------------------------- -function doPickUpTreasure(prev: GameState): CommandResult { +function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); @@ -5262,10 +5262,12 @@ function doPickUpTreasure(prev: GameState): CommandResult { if (safe && state.squareContents[here]!.createdBy !== p.id && !state.openSafes.includes(here)) { return err("it is locked inside a safe"); } + // Two treasures can share a square; an id names the one wanted. const t = state.treasures.find( - (t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy, + (t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy && + (treasureId == null || t.id === treasureId), ); - if (!t) return err("no treasure here"); + if (!t) return err(treasureId ? "that treasure is not here for the taking" : "no treasure here"); t.carriedBy = p.id; t.position = null; diff --git a/packages/engine/test/terrain-objects-control.test.ts b/packages/engine/test/terrain-objects-control.test.ts index a56efd4..aecca54 100644 --- a/packages/engine/test/terrain-objects-control.test.ts +++ b/packages/engine/test/terrain-objects-control.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game"; +import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game"; import { cellKey, edgeKey, neighbor, SIDES } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newGame, must, giveCard, toRound2, faceOff, castAt, emptyNeighborCell } from "./helpers"; @@ -277,3 +277,17 @@ describe("control effects", () => { expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true); }); }); + +describe("picking one of two treasures", () => { + it("a named treasure is the one taken", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 16 }); + state = toRound2(state); + const p = activePlayer(state); + const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id); + t1!.position = { ...p.position }; t1!.carriedBy = null; + t2!.position = { ...p.position }; t2!.carriedBy = null; + const r = applyCommand(state, p.id, { type: "pickUpTreasure", treasureId: t2!.id }); + if (!r.ok) throw new Error(r.error); + expect(r.state.players.find((q) => q.id === p.id)!.carriedTreasureId).toBe(t2!.id); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 7c18943..97a21d1 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -313,6 +313,7 @@ selectedCard = null; megaBoost = "life"; holdDoor = false; + pickChoice = false; pendingTeleport = null; attachedNumber = null; attachedMods = []; @@ -821,11 +822,26 @@ dispatch({ type: "endTurn", draw: drawCount }); } - function pickUp() { dispatch({ type: "pickUpTreasure" }); } + let pickChoice = $state(false); + function pickUp() { + if (treasuresHere.length > 1) { pickChoice = true; return; } + dispatch({ type: "pickUpTreasure" }); + } + function pickUpNamed(treasureId: string) { + pickChoice = false; + dispatch({ type: "pickUpTreasure", treasureId }); + } function drop() { dispatch({ type: "dropTreasure" }); } function pass() { dispatch({ type: "pass" }); } const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null); + /** Two treasures can share a square: one dispatches, more ask whose. */ + const treasuresHere = $derived( + view && me + ? view.treasures.filter((t) => t.position && t.position.x === me.position.x && + t.position.y === me.position.y && !t.carriedBy) + : [], + ); const carryingTreasure = $derived(me?.carriedTreasureId != null); const treasureHere = $derived( view != null && me != null && @@ -1659,6 +1675,15 @@ {#if isYourTurn} + {#if pickChoice} + + — whose? + {#each treasuresHere as t (t.id)} + + {/each} + + + {/if} {#each objectsHere as obj (obj.instanceId)}