diff --git a/packages/engine/data/cards.json b/packages/engine/data/cards.json index c0a80d8..b8955f5 100644 --- a/packages/engine/data/cards.json +++ b/packages/engine/data/cards.json @@ -1337,7 +1337,7 @@ "set": "expansion1", "cardType": "attack", "subtypes": [ - "stick", + "wand", "object" ], "los": true, @@ -2009,7 +2009,7 @@ "set": "expansion1", "cardType": "attack", "subtypes": [ - "stick", + "wand", "object" ], "los": true, @@ -2042,7 +2042,7 @@ "set": "expansion1", "cardType": "attack", "subtypes": [ - "stick", + "wand", "object" ], "los": true, @@ -2242,7 +2242,7 @@ "set": "expansion1", "cardType": "neutral", "subtypes": [ - "stick", + "wand", "object" ], "los": true, @@ -2878,7 +2878,7 @@ "set": "expansion2", "cardType": "attack", "subtypes": [ - "stick", + "wand", "object" ], "los": true, diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 326e184..ba3e7b0 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -121,6 +121,8 @@ export interface TurnState { attackUsed: boolean; /** ADRENALINE's second attack, once spent. */ secondAttackUsed: boolean; + /** Wand instances already used this turn ("maximum of once per turn"). */ + wandsUsed: string[]; /** SLOW: "his attacks [reduce] to every other turn". */ attackForbidden: boolean; actionsEnded: boolean; @@ -179,6 +181,10 @@ export interface GameState { illusionWalls: Record }>; creatures: CreatureState[]; nextCreatureId: number; + /** Remaining charges per wand card instance (set on first use). */ + wandCharges: Record; + /** WARP WAND: walls opened for this turn only, with their prior state. */ + tempWarpEdges: { key: string; prior: EdgeState | null }[]; players: PlayerState[]; treasures: TreasureState[]; sustained: SustainedEffect[]; @@ -402,6 +408,14 @@ export type GameEvent = | { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number } | { type: "impScorches"; creatureId: string; player: PlayerId } | { type: "monsterBoosted"; creatureId: string; boost: "life" | "movement" } + | { type: "wandCharged"; player: PlayerId; card: CardInstance; charges: number } + | { type: "wandUsed"; player: PlayerId; cardId: string; chargesLeft: number } + | { type: "wandExhausted"; player: PlayerId; card: CardInstance } + | { type: "wallWarpedOpen"; player: PlayerId; edge: { cell: Cell; side: Side } } + | { type: "wallsWarpedBack"; count: number } + | { type: "shoved"; player: PlayerId; from: Cell; to: Cell; by: PlayerId } + | { type: "webbed"; player: PlayerId } + | { type: "cardRetrieved"; player: PlayerId; cardId: string } | { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean } | { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorsRelocked"; count: number } @@ -1340,6 +1354,90 @@ const CARD_EFFECTS: Record return null; }, }, + // --- Expansion #1: magic wands ------------------------------------------- + "blaster-wand": { + kind: "attack", + requiresLos: true, + keepInHand: true, + // "does 3 points of magical damage per charge" + baseDamage: () => 3, + }, + "sticky-wand": { + kind: "attack", + requiresLos: true, + keepInHand: true, + baseDamage: () => 0, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + // "reducing movement by 3 ... Lasts one turn. Any fire damage done to + // player in webs causes two extra points." + attachSustained(ctx.state, ctx.events, "sticky-web", ctx.attacker.id, ctx.defender.id, 1); + ctx.events.push({ type: "webbed", player: ctx.defender.id }); + }, + }, + "shift-wand": { + kind: "attack", + requiresLos: true, + keepInHand: true, + baseDamage: () => 0, + validate: (state, cmd) => { + const cell = cmd.params?.cell; + if (!cell) return "choose the adjacent space to shove them into"; + if (!boardView(state).cells[cellKey(cell)]) return "off the board"; + if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; + return null; + }, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + if (isLockedInPlace(ctx.state, ctx.defender.id)) return; + const to = ctx.stack.params!.cell!; + const from = ctx.defender.position; + const dist = Math.abs(to.x - from.x) + Math.abs(to.y - from.y); + if (dist !== 1) return; // must be adjacent to where they stand + // "he could be shoved through a stone wall" — walls do not stop it. + ctx.defender.position = to; + ctx.events.push({ type: "shoved", player: ctx.defender.id, from, to, by: ctx.attacker.id }); + }, + }, + "warp-wand": { + kind: "neutral", + keepInHand: true, + // "makes 1 section (one space long) of wall disappear during your turn, + // reappearing at the end of your turn." + resolve: (state, events, caster, cmd) => { + if (!cmd.target || cmd.target.kind !== "edge") return "aim the wand at a wall section"; + const { cell, side } = cmd.target; + const key = edgeKey(cell, side); + const view = boardView(state); + if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall"; + if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; + state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null }); + state.edgeOverrides[key] = "open"; + events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } }); + return null; + }, + }, + "deja-vu": { + kind: "neutral", + // "Retrieve any one card from the discard pile (except a MAGIC WAND)." + resolve: (state, events, caster, cmd) => { + const wanted = cmd.params?.cardId; + if (!wanted) return "name the card to retrieve"; + if (["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(wanted)) { + return "deja-vu cannot retrieve a magic wand"; + } + for (let i = state.discard.length - 1; i >= 0; i--) { + if (state.discard[i]!.cardId === wanted) { + const [card] = state.discard.splice(i, 1); + caster.hand.push(card!); + events.push({ type: "cardRetrieved", player: caster.id, cardId: wanted }); + if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id; + return null; + } + } + return "that card is not in the discard pile"; + }, + }, "reuse-spell": { kind: "neutral", // "You may retrieve any spell you use immediately after you use it (but @@ -1995,6 +2093,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game illusionWalls: {}, creatures: [], nextCreatureId: 1, + wandCharges: {}, + tempWarpEdges: [], players, treasures, sustained: [], @@ -2009,6 +2109,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game numberPlayedForMovement: false, attackUsed: false, secondAttackUsed: false, + wandsUsed: [], attackForbidden: false, actionsEnded: false, }, @@ -2190,7 +2291,8 @@ function doMove(prev: GameState, direction: Side): CommandResult { if (crossedFirewall) { events.push({ type: "firewallBurned", player: p.id }); - applyDamage(state, events, p, 4, "wall of fire", null); + const webbed = sustainedOn(state, p.id, "sticky-web").length > 0; + applyDamage(state, events, p, webbed ? 6 : 4, "wall of fire", null); checkVictory(state, events); } @@ -2426,8 +2528,13 @@ function consumeCast( mods: CastConsumables, keepInHand: boolean, ): void { + // (Wand cards manage their own lifetime via charges — callers pass + // keepInHand=true for them and never discard here.) if (keepInHand) { - if (!caster.displayed.includes(card.instanceId)) caster.displayed.push(card.instanceId); + if (caster.hand.some((c) => c.instanceId === card.instanceId) && + !caster.displayed.includes(card.instanceId)) { + caster.displayed.push(card.instanceId); + } } else { takeFromHand(caster, card.instanceId); state.discard.push(card); @@ -2458,13 +2565,23 @@ function doCast(prev: GameState, cmd: Extract): Comma return err(`${def.name} is already displayed`); } + // Magic wands: charged on first use by the number card(s) played; one + // charge per use, one use per turn; discarded when the last charge goes. + const WANDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]); + const isWand = WANDS.has(inHand.cardId); + if (isWand && state.turn.wandsUsed.includes(inHand.instanceId)) { + return err("any wand operates a maximum of once per turn"); + } + // "Some cards involve physical actions, like picking locks, removing // locks, jamming locks, and throwing daggers. These are not spells." // (PICK LOCK's 6e face: "This is not a spell.") const NOT_SPELLS = new Set([ "pick-lock", "master-key", "jam-lock", "remove-lock", "dagger", "large-rock", ]); - const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId); + // "This isolation of the wand from you allows you to use it even with + // NO SPELL cast on you." + const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand; if (isSpell) { const castBlock = castingBlocked(state, caster.id); if (castBlock) return err(castBlock); @@ -2475,6 +2592,32 @@ function doCast(prev: GameState, cmd: Extract): Comma const mods = gatherModifiers(caster, cmd); if (typeof mods === "string") return err(mods); + /** Set charges on first use, spend one, discard the wand when empty. */ + const spendWandCharge = (st: GameState, wielder: PlayerState, events: GameEvent[]): string | null => { + if (!isWand) return null; + let charges = st.wandCharges[inHand.instanceId]; + if (charges === undefined) { + if (mods.numbers.length === 0) return "a wand's first use needs a number card to set its charges"; + charges = mods.magnitude.power; // AMPLIFY and ADD both work here + st.wandCharges[inHand.instanceId] = charges; + if (!wielder.displayed.includes(inHand.instanceId)) wielder.displayed.push(inHand.instanceId); + events.push({ type: "wandCharged", player: wielder.id, card: inHand, charges }); + } + charges -= 1; + st.turn.wandsUsed.push(inHand.instanceId); + if (charges <= 0) { + delete st.wandCharges[inHand.instanceId]; + takeFromHand(wielder, inHand.instanceId); + st.discard.push(inHand); + events.push({ type: "wandUsed", player: wielder.id, cardId: inHand.cardId, chargesLeft: 0 }); + events.push({ type: "wandExhausted", player: wielder.id, card: inHand }); + } else { + st.wandCharges[inHand.instanceId] = charges; + events.push({ type: "wandUsed", player: wielder.id, cardId: inHand.cardId, chargesLeft: charges }); + } + return null; + }; + if (effect.kind === "attack") { const pre = attackPreconditions(state); if (pre) return err(pre); @@ -2489,11 +2632,16 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) { return err("no line of sight to the creature"); } + const wandEvents: GameEvent[] = []; + { + const werr = spendWandCharge(state, caster, wandEvents); + if (werr) return err(werr); + } consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); if (state.turn.attackUsed) state.turn.secondAttackUsed = true; state.turn.attackUsed = true; state.lastSpellUsed[caster.id] = inHand.cardId; - const events: GameEvent[] = [{ + const events: GameEvent[] = [...wandEvents, { type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, from: caster.position, target: null, targetCell: creature.position, @@ -2544,6 +2692,12 @@ function doCast(prev: GameState, cmd: Extract): Comma } } + const wandEvents: GameEvent[] = []; + { + const werr = spendWandCharge(state, caster, wandEvents); + if (werr) return err(werr); + } + // BLIND: casts at others fly in a rolled direction. "Misdirected spells // go intended distance" — if the die disagrees with the true direction, // the spell hits whoever lies that way, or dissipates. @@ -2569,7 +2723,7 @@ function doCast(prev: GameState, cmd: Extract): Comma consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); state.turn.attackUsed = true; state.lastSpellUsed[caster.id] = inHand.cardId; - const missEvents: GameEvent[] = [...preEvents, { + const missEvents: GameEvent[] = [...wandEvents, ...preEvents, { type: "attackMisdirected", attacker: caster.id, intended: target.id, rolledDirection: rolled, newTarget: along?.id ?? null, }]; @@ -2613,7 +2767,7 @@ function doCast(prev: GameState, cmd: Extract): Comma waitingOn: target.id, }; state.lastSpellUsed[caster.id] = inHand.cardId; - const events: GameEvent[] = [...preEvents]; + const events: GameEvent[] = [...wandEvents, ...preEvents]; if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id }); events.push({ type: "spellCast", @@ -2650,6 +2804,10 @@ function doCast(prev: GameState, cmd: Extract): Comma ); if (problem) return err(problem); + if (isWand) { + const werr = spendWandCharge(state, caster, events); + if (werr) return err(werr); + } consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); if (effect.keepInHand) { events.push({ type: "cardDisplayed", player: caster.id, card: inHand }); @@ -2678,6 +2836,9 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): if (playerId === stack.defenderId) { if (card.cardId === "absorb-spell") { if (stack.kind !== "spell") return err("absorb spell only works against spells"); + if (stack.attackCard && ["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"].includes(stack.attackCard.cardId)) { + return err("Absorb Spell has no effect on magic wands"); + } takeFromHand(player, instanceId); state.discard.push(card); const attackCard = stack.attackCard!; @@ -2809,6 +2970,10 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1 + // Webs burn: "Any fire damage done to player in webs causes two extra points." + if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) { + base += 2; + } // STONE DEAD: number x the stones the defender carries. if (attackId === "stone-dead") { base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).length; @@ -3233,6 +3398,9 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi let allowance = BASE_MOVEMENT; if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2); if (displays(player, "speedstone")) allowance += 1; + // STICKY WAND webs: "reducing movement by 3 (enemy can still use NUMBER + // cards for additional movement)." + allowance = Math.max(0, allowance - 3 * sustainedOn(state, player.id, "sticky-web").length); const slows = sustainedOn(state, player.id, "slow"); if (slows.length > 0) allowance = 1; @@ -3253,6 +3421,7 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi numberPlayedForMovement: false, attackUsed: false, secondAttackUsed: false, + wandsUsed: [], attackForbidden, actionsEnded: false, }; @@ -3296,6 +3465,16 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { } } + // WARP WAND: opened walls reappear at the end of the turn. + if (state.tempWarpEdges.length > 0) { + for (const t of state.tempWarpEdges) { + if (t.prior === null) delete state.edgeOverrides[t.key]; + else state.edgeOverrides[t.key] = t.prior; + } + events.push({ type: "wallsWarpedBack", count: state.tempWarpEdges.length }); + state.tempWarpEdges = []; + } + // Doors unlocked this turn relock ("the door will relock behind you"). if (state.openDoorEdges.length > 0) { events.push({ type: "doorsRelocked", count: state.openDoorEdges.length }); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 503546f..9f1d525 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -54,6 +54,8 @@ export interface GameView { /** Illusion edges YOU know are fake (creator or saw through); others see walls. */ knownIllusionEdges: string[]; creatures: CreatureState[]; + /** Charges left on displayed wands (public), by card instance id. */ + wandCharges: Record; } export function viewFor(state: GameState, playerId: PlayerId): GameView { @@ -106,5 +108,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { openDoorEdges: [...state.openDoorEdges], knownIllusionEdges, creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), + wandCharges: { ...state.wandCharges }, }; } diff --git a/packages/engine/test/wands.test.ts b/packages/engine/test/wands.test.ts new file mode 100644 index 0000000..e0ffe4e --- /dev/null +++ b/packages/engine/test/wands.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + activePlayer, + createGame, + boardView, + type Command, + type GameState, + type PlayerId, +} from "../src/game"; +import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; +import type { CardInstance } from "../src/cards"; + +function newGame(seed = 42) { + return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] }); +} + +function must(state: GameState, player: PlayerId, command: Command): GameState { + const result = applyCommand(state, player, command); + if (!result.ok) throw new Error(`command failed: ${result.error}`); + return result.state; +} + +function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance { + const p = state.players.find((p) => p.id === playerId)!; + const instance = { instanceId: `${cardId}#${tag}`, cardId }; + p.hand[slot] = instance; + return instance; +} + +function toRound2(state: GameState): GameState { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + return state; +} + +function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } { + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + defender.position = { ...attacker.position }; + return { attacker: attacker.id, defender: defender.id }; +} + +describe("magic wands", () => { + it("blaster wand: charges on first use, once per turn, discards when spent", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const wand = giveCard(state, attacker, "blaster-wand"); + giveCard(state, attacker, "number-2", "N", 1); + + // First use without a number card is refused. + expect(applyCommand(state, attacker, { + type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender }, + }).ok).toBe(false); + + // Charged with a 2: fires (3 damage), one charge left, wand displayed. + state = must(state, attacker, { + type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"], + target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(12); + expect(state.wandCharges[wand.instanceId]).toBe(1); + const a = state.players.find((p) => p.id === attacker)!; + expect(a.displayed).toContain(wand.instanceId); + + // A second use the same turn is refused ("once per turn") — even with + // adrenaline-style second attacks it is the WAND that is limited. + expect(applyCommand(state, attacker, { + type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender }, + }).ok).toBe(false); + + // Next turn: the last charge fires and the wand crumbles to the discard. + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = must(state, defender, { type: "endTurn", draw: 0 }); + state = must(state, attacker, { + type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(9); + const a2 = state.players.find((p) => p.id === attacker)!; + expect(a2.hand.some((c) => c.instanceId === wand.instanceId)).toBe(false); + expect(state.wandCharges[wand.instanceId]).toBeUndefined(); + }); + + it("wands fire even under NO SPELL, and Absorb Spell cannot eat them", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + // Defender silences the attacker first: give defender the first move. + state.sustained.push({ + id: "fx-ns", cardId: "no-spell", casterId: defender, targetId: attacker, + remainingTurns: 3, data: {}, + }); + const wand = giveCard(state, attacker, "blaster-wand"); + giveCard(state, attacker, "number-2", "N", 1); + giveCard(state, defender, "absorb-spell", "AS", 0); + + // Fireball is silenced... + const fb = giveCard(state, attacker, "fireball", "F", 2); + expect(applyCommand(state, attacker, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, + }).ok).toBe(false); + // ...but the wand is isolated from the wizard. + state = must(state, attacker, { + type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"], + target: { kind: "player", playerId: defender }, + }); + // Absorb Spell has no effect on magic wands. + expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AS" }).ok).toBe(false); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(12); + }); + + it("sticky wand webs the target: movement -3 and fire burns hotter", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const wand = giveCard(state, attacker, "sticky-wand"); + giveCard(state, attacker, "number-3", "N", 1); + state = must(state, attacker, { + type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-3#N"], + target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "pass" }); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + // Webbed: base 3 movement drops to 0. + expect(activePlayer(state).id).toBe(defender); + expect(state.turn.movementAllowance).toBe(0); + }); + + it("shift wand shoves a wizard through a wall", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const d = state.players.find((p) => p.id === defender)!; + // Find a walled direction beside the defender with a real cell behind it. + const view = boardView(state); + let through: Cell | null = null; + for (const side of SIDES) { + const k = edgeKey(d.position, side); + const dest = { x: d.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: d.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) { through = dest; break; } + } + if (!through) return; // no adjacent wall on this seed; covered elsewhere + const wand = giveCard(state, attacker, "shift-wand"); + giveCard(state, attacker, "number-2", "N", 1); + state = must(state, attacker, { + type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"], + target: { kind: "player", playerId: defender }, + params: { cell: through }, + }); + state = must(state, defender, { type: "pass" }); + expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(through)); + }); + + it("warp wand opens a wall for one turn only", () => { + let { state } = newGame(); + const me = activePlayer(state); + const view = boardView(state); + let edge: { cell: Cell; side: Side } | null = null; + for (const side of SIDES) { + const k = edgeKey(me.position, side); + const dest = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) { + edge = { cell: me.position, side }; + break; + } + } + if (!edge) return; + const key = edgeKey(edge.cell, edge.side); + const wand = giveCard(state, me.id, "warp-wand"); + giveCard(state, me.id, "number-2", "N", 1); + state = must(state, me.id, { + type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"], + target: { kind: "edge", cell: edge.cell, side: edge.side }, + }); + // Open now: walk through. + state = must(state, me.id, { type: "move", direction: edge.side }); + // At end of turn the wall reappears. + state = must(state, me.id, { type: "endTurn", draw: 0 }); + expect(boardView(state).edges[key]).toBe("wall"); + }); + + it("deja-vu retrieves from the discard, but never a wand", () => { + let { state } = newGame(); + const me = activePlayer(state); + state.discard.push({ instanceId: "fireball#D", cardId: "fireball" }); + state.discard.push({ instanceId: "blaster-wand#D", cardId: "blaster-wand" }); + const dv = giveCard(state, me.id, "deja-vu"); + expect(applyCommand(state, me.id, { + type: "cast", instanceId: dv.instanceId, params: { cardId: "blaster-wand" }, + }).ok).toBe(false); + state = must(state, me.id, { type: "cast", instanceId: dv.instanceId, params: { cardId: "fireball" } }); + expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "fireball")).toBe(true); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 30f35da..77b5311 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -43,6 +43,7 @@ const EDGE_CARDS = new Set([ "create-wall", "destroy-wall", "wall-of-fire", "waterwall", "pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation", + "warp-wand", ]); const CELL_CARDS = new Set([ "teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag", @@ -54,7 +55,7 @@ const SELF_CARDS = new Set([ "invisible", "shrink", "mist-body", ]); - const NAMED_CARDS = new Set(["card-erasure", "drop-object"]); + const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu"]); const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)); const cellSelectMode = $derived( (selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null, @@ -284,7 +285,7 @@ } return; } - if (selectedCard.cardId === "teleport-opponent") { + if (selectedCard.cardId === "teleport-opponent" || selectedCard.cardId === "shift-wand") { pendingCellFor = playerId; return; // next: click the destination cell } @@ -437,7 +438,17 @@ {/if} {#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)} - — then click the target wizard + {#if selectedCard.cardId === "deja-vu"} + + {:else} + — then click the target wizard + {/if} {/if} {#if attachedMods.length > 0} [+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}] @@ -486,7 +497,9 @@ {def.cardType} {def.name} {#if view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId)} - displayed + + displayed{view.wandCharges[card.instanceId] != null ? ` · ${view.wandCharges[card.instanceId]} ⚡` : ""} + {/if} {/each}