Daggers, rocks, blades, and wands can be set down too

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 09:59:09 -04:00
co-authored by Claude Fable 5
parent c04ed1e0ee
commit 789d927122
3 changed files with 68 additions and 3 deletions
+17 -1
View File
@@ -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 { function doDropObject(prev: GameState, instanceId: string): CommandResult {
const state = clone(prev); const state = clone(prev);
const p = activePlayer(state); const p = activePlayer(state);
const card = p.hand.find((c) => c.instanceId === instanceId); const card = p.hand.find((c) => c.instanceId === instanceId);
if (!card) return err("card not in hand"); 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); takeFromHand(p, instanceId);
const key = cellKey(p.position); const key = cellKey(p.position);
state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card]; state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card];
+49
View File
@@ -198,3 +198,52 @@ describe("magic wands", () => {
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "fireball")).toBe(true); 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);
});
});
+2 -2
View File
@@ -5,7 +5,7 @@
import Help from "./Help.svelte"; import Help from "./Help.svelte";
import Replay from "./Replay.svelte"; import Replay from "./Replay.svelte";
import { local } from "./local.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"; import type { CardInstance, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -912,7 +912,7 @@
Cast{attachedNumber ? ` with the ${numberTotal}` : ""} Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
</button> </button>
{/if} {/if}
{#if selectedCard && selectedDef.cardType === "object" && isYourTurn} {#if selectedCard && isMovableObject(selectedCard.cardId) && isYourTurn}
<button class="stamp tiny" onclick={() => { <button class="stamp tiny" onclick={() => {
if (selectedCard) dispatch({ type: "dropObject", instanceId: selectedCard.instanceId }); if (selectedCard) dispatch({ type: "dropObject", instanceId: selectedCard.instanceId });
clearSelection(); clearSelection();