From 789d927122f7db90e14dddf0305ca8e01de233ca Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 09:59:09 -0400 Subject: [PATCH] Daggers, rocks, blades, and wands can be set down too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rulebook's movable-object list is wider than the stones: "magic stones, treasure chests, the dagger, the large rock, and the wizardblade" — and the expansion expects wands to change hands, since "its remaining charges go with it." A new isMovableObject helper in the engine names that full set, doDropObject honors it, and the hint bar's "Drop it here" button follows suit. Charges are keyed by card instance, so a dropped wand carries them to whoever picks it up — covered by new tests, along with the refusal to drop spell cards. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 18 ++++++++++- packages/engine/test/wands.test.ts | 49 ++++++++++++++++++++++++++++++ packages/web/src/App.svelte | 4 +-- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 7b79d39..26ff127 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -4578,12 +4578,28 @@ function doPickUpObject(prev: GameState, instanceId: string): CommandResult { }; } +/** + * Physical, droppable objects beyond the object-type stones. Rulebook: + * "Movable objects include magic stones, treasure chests, the dagger, the + * large rock, and the wizardblade" — plus the expansion wands, which the + * rules expect to change hands ("its remaining charges go with it"; + * charges are keyed by instance, so they travel automatically). + */ +const MOVABLE_OBJECT_CARD_IDS = new Set([ + "dagger", "large-rock", "wizardblade", + "blaster-wand", "shift-wand", "sticky-wand", "warp-wand", +]); + +export function isMovableObject(cardId: string): boolean { + return cardDef(cardId).cardType === "object" || MOVABLE_OBJECT_CARD_IDS.has(cardId); +} + function doDropObject(prev: GameState, instanceId: string): CommandResult { const state = clone(prev); const p = activePlayer(state); const card = p.hand.find((c) => c.instanceId === instanceId); if (!card) return err("card not in hand"); - if (cardDef(card.cardId).cardType !== "object") return err("only objects can be dropped"); + if (!isMovableObject(card.cardId)) return err("only objects can be dropped"); takeFromHand(p, instanceId); const key = cellKey(p.position); state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card]; diff --git a/packages/engine/test/wands.test.ts b/packages/engine/test/wands.test.ts index e0ffe4e..2d9d808 100644 --- a/packages/engine/test/wands.test.ts +++ b/packages/engine/test/wands.test.ts @@ -198,3 +198,52 @@ describe("magic wands", () => { expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "fireball")).toBe(true); }); }); + +describe("dropping objects", () => { + it("a charged wand can be dropped and picked up, charges intact", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const wand = giveCard(state, attacker, "blaster-wand"); + giveCard(state, attacker, "number-3", "N", 1); + + // Charge it with a 3 (one charge spent on the shot, two remain). + state = must(state, attacker, { + type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-3#N"], + target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "pass" }); + expect(state.wandCharges[wand.instanceId]).toBe(2); + + // Set it down: leaves the hand and the displayed list, lands on the floor. + state = must(state, attacker, { type: "dropObject", instanceId: wand.instanceId }); + const owner = state.players.find((p) => p.id === attacker)!; + expect(owner.hand.some((c) => c.instanceId === wand.instanceId)).toBe(false); + expect(owner.displayed.includes(wand.instanceId)).toBe(false); + const key = cellKey(owner.position); + expect((state.groundObjects[key] ?? []).some((c) => c.instanceId === wand.instanceId)).toBe(true); + + // The wizard standing there picks it up on their turn — charges travel. + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = must(state, defender, { type: "pickUpObject", instanceId: wand.instanceId }); + const thief = state.players.find((p) => p.id === defender)!; + expect(thief.hand.some((c) => c.instanceId === wand.instanceId)).toBe(true); + expect(state.wandCharges[wand.instanceId]).toBe(2); + expect(state.players.find((p) => p.id === defender)!.id).toBe(activePlayer(state).id); + }); + + it("the dagger and wizardblade can be set down; spell cards cannot", () => { + let { state } = newGame(); + state = toRound2(state); + const who = activePlayer(state).id; + const dagger = giveCard(state, who, "dagger"); + const fireball = giveCard(state, who, "fireball", "F", 1); + + state = must(state, who, { type: "dropObject", instanceId: dagger.instanceId }); + expect((state.groundObjects[cellKey(activePlayer(state).position)] ?? []) + .some((c) => c.cardId === "dagger")).toBe(true); + + const refused = applyCommand(state, who, { type: "dropObject", instanceId: fireball.instanceId }); + expect(refused.ok).toBe(false); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index d0b519c..2b5ee88 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -5,7 +5,7 @@ import Help from "./Help.svelte"; import Replay from "./Replay.svelte"; import { local } from "./local.svelte"; - import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine"; + import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine"; net.connect(); @@ -912,7 +912,7 @@ Cast{attachedNumber ? ` with the ${numberTotal}` : ""} {/if} - {#if selectedCard && selectedDef.cardType === "object" && isYourTurn} + {#if selectedCard && isMovableObject(selectedCard.cardId) && isYourTurn}