diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index bdb271b..0953b23 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -956,8 +956,9 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm // Three or more wizards: LIFESAVER takes elimination off the table. const lifesaver = inHand(view, "lifesaver"); if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId }; - // The ward guards the gold while its owner is away robbing yours. - if (inHand(view, "ward") && !view.yourWardArmed) { + // The ward guards the gold while its owner is away robbing yours + // (arming retired at rev 31: the window asks in the moment instead). + if (view.deckRev < 31 && inHand(view, "ward") && !view.yourWardArmed) { return { type: "armWard", armed: true }; } const enemyNear = livingEnemies(view).some( @@ -1086,6 +1087,10 @@ export function automatonCommand( const tier = TIERS[tierName] ?? TIERS.archmage; if (view.phase !== "playing") return null; + if (view.wardPending) { + // A Ward is free damage on a thief of OUR gold: always spring it. + return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null; + } if (view.pendingDiscard === you) { return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) }; } @@ -1460,6 +1465,7 @@ export function automatonCommand( /** The safe fallback when the automaton's choice was refused. */ export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command { const you = view.you; + if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false }; if (view.pendingDiscard === you) { return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; } diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index db801e2..6cd8443 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -257,6 +257,9 @@ export interface GameState { slimeTraps: Record; /** Players whose WARD is set to spring (rules rev 3+; their secret). */ wardArmed: PlayerId[]; + /** A treasure was just grabbed and its owner holds WARD (rules rev 31): + * the table waits while they choose to play it "at that time" or not. */ + wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; /** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */ chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null; /** Permanent door-lock changes, by edge key. */ @@ -590,6 +593,7 @@ export type GameEvent = | { type: "itemsSwapped"; a: PlayerId; b: PlayerId } | { type: "swapFizzled"; player: PlayerId } | { type: "wardSprung"; owner: PlayerId; victim: PlayerId } + | { type: "wardWindow"; owner: PlayerId; victim: PlayerId } | { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId } | { type: "chaosShielded"; player: PlayerId } | { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string } @@ -654,6 +658,7 @@ export type Command = | { type: "punchWall"; cell: Cell; side: Side } | { type: "testIllusion"; cell: Cell; side: Side } | { type: "armWard"; armed: boolean } + | { type: "wardChoice"; play: boolean } | { type: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } | { type: "creatureAttack"; creatureId: string; targetId: string } @@ -3447,6 +3452,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game wallDamage: {}, slimeTraps: {}, wardArmed: [], + wardPending: null, chaosPending: null, doorStates: {}, openDoorEdges: [], @@ -3519,6 +3525,13 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm function applyCommandInner(state: GameState, playerId: PlayerId, command: Command): CommandResult { if (state.phase !== "playing") return err("game is over"); + // WARD's moment: the grab hangs while the treasure's owner decides. + if (state.wardPending) { + if (playerId !== state.wardPending.ownerId) return err("waiting on the treasure's owner"); + if (command.type !== "wardChoice") return err("play your Ward or let them go"); + return doWardChoice(state, command.play); + } + if (state.pendingDiscard) { if (playerId !== state.pendingDiscard) return err("waiting for another player to discard"); if (command.type !== "discard") return err("you must discard down to the hand limit first"); @@ -3637,6 +3650,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman case "punchWall": return doPunchWall(state, command.cell, command.side); case "testIllusion": return doTestIllusion(state, command.cell, command.side); case "armWard": return doArmWard(state, command.armed); + case "wardChoice": return err("no grab is hanging on your Ward"); case "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); @@ -4197,7 +4211,33 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean { } /** Arm (or stand down) the WARD trap on your treasures. Your secret. */ +/** The owner's answer to a hanging grab: spring the WARD, or let them go. */ +function doWardChoice(prev: GameState, play: boolean): CommandResult { + const state = clone(prev); + const wp = state.wardPending!; + state.wardPending = null; + const events: GameEvent[] = []; + if (play) { + const owner = state.players.find((q) => q.id === wp.ownerId)!; + const taker = state.players.find((q) => q.id === wp.takerId)!; + const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward"); + if (wardIdx === -1) return err("the Ward is no longer in your hand"); + const [card] = owner.hand.splice(wardIdx, 1); + owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId); + state.discard.push(card!); + events.push({ type: "wardSprung", owner: owner.id, victim: taker.id }); + applyDamage(state, events, taker, 3, "warded treasure", null); + checkVictory(state, events); + } + return { ok: true, state, events }; +} + function doArmWard(prev: GameState, armed: boolean): CommandResult { + // Rev 31 reads the card literally: the Ward is played in the moment of + // the grab, never set ahead. Older games keep their arming and replay so. + if ((prev.config.deckRev ?? 1) >= 31) { + return err("the Ward is played in the moment — you will be asked when your treasure is grabbed"); + } const state = clone(prev); const p = activePlayer(state); if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD"); @@ -5702,14 +5742,19 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult { const events: GameEvent[] = [ { type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }, ]; - // WARD: "you may play at that time (out of turn) this card on him" — the - // choice is made ahead of time by arming it (rev 3); earlier revisions - // spring automatically so stored games replay unchanged. + // WARD: "you may play at that time (out of turn) this card on him." + // From rules rev 31 that is literal: the grab hangs while the owner + // decides. Rev 3-30 committed the choice ahead of time by arming; rev + // 1-2 sprang automatically. Stored games replay their own vintage. const owner = state.players.find((q) => q.id === t.owner); - const wardSet = (state.config.deckRev ?? 1) >= 3 ? state.wardArmed.includes(owner?.id ?? "") : true; - if (owner && owner.alive && owner.id !== p.id && wardSet) { - const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward"); - if (wardIdx !== -1) { + const rev = state.config.deckRev ?? 1; + if (owner && owner.alive && owner.id !== p.id) { + const holdsWard = owner.hand.some((c) => c.cardId === "ward"); + if (rev >= 31 && holdsWard) { + state.wardPending = { ownerId: owner.id, takerId: p.id }; + events.push({ type: "wardWindow", owner: owner.id, victim: p.id }); + } else if (holdsWard && (rev >= 3 ? state.wardArmed.includes(owner.id) : true)) { + const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward"); const [card] = owner.hand.splice(wardIdx, 1); owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId); state.discard.push(card!); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 9826304..a7802e8 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -84,6 +84,8 @@ export interface GameView { chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; /** Whether YOUR ward is set to spring. */ yourWardArmed: boolean; + /** A grab hangs while the treasure's owner decides their Ward (rev 31). */ + wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; /** YOUR armed ambushes. Other players' ambushes are invisible. */ yourAmbushes: AmbushState[]; /** Once the game is finished, every hand goes face-up on the table. */ @@ -168,6 +170,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { ? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] } : null, yourWardArmed: state.wardArmed.includes(playerId), + wardPending: state.wardPending ? { ...state.wardPending } : null, yourAmbushes: state.ambushes .filter((a) => a.ownerId === playerId) .map((a) => ({ ...a, numbers: [...a.numbers] })), diff --git a/packages/engine/test/game.test.ts b/packages/engine/test/game.test.ts index 5968296..0dc9ead 100644 --- a/packages/engine/test/game.test.ts +++ b/packages/engine/test/game.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { giveCard } from "./helpers"; import { applyCommand, activePlayer, @@ -262,3 +263,52 @@ describe("elimination by lost treasures drops what the fallen carried (rev 30)", expect(btAfter.position).toEqual(cAfter.position); }); }); + +describe("the Ward is played in the moment (rules rev 31)", () => { + function grabRig() { + let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 }); + const thief = state.players.find((p) => p.id === "thief")!; + const owner = state.players.find((p) => p.id === "owner")!; + giveCard(state, "owner", "ward", "W", 0); + const t = state.treasures.find((t) => t.owner === "owner" && t.position)!; + thief.position = { ...t.position! }; + while (state.players[state.turn.activeIndex]!.id !== "thief") { + const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const r = applyCommand(state, "thief", { type: "pickUpTreasure" }); + if (!r.ok) throw new Error(r.error); + return { state: r.state, owner, thief }; + } + + it("the grab hangs on the owner; springing costs the thief 3 and the card", () => { + let { state } = grabRig(); + expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" }); + // Nobody else may act while it hangs. + expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false); + const r = applyCommand(state, "owner", { type: "wardChoice", play: true }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.wardPending).toBeNull(); + expect(state.players.find((p) => p.id === "thief")!.life).toBe(12); + expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false); + }); + + it("declining lets the thief go, Ward still in hand", () => { + let { state } = grabRig(); + const r = applyCommand(state, "owner", { type: "wardChoice", play: false }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.players.find((p) => p.id === "thief")!.life).toBe(15); + expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(true); + expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true); + }); + + it("arming is refused in this vintage", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 }); + giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0); + const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true }); + expect(r.ok).toBe(false); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 9a65c24..86db7b3 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 30; +const RULES_REV = 31; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -275,6 +275,7 @@ function actingSeat(room: Room): PlayerId | null { const s = room.state; if (!s || s.phase !== "playing") return null; return ( + s.wardPending?.ownerId ?? s.stack?.waitingOn ?? s.pendingDiscard ?? s.chaosPending?.queue[0] ?? diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index d62c1ff..3e6a71f 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1206,6 +1206,25 @@ {/if} + {#if view?.wardPending} + {#if view.wardPending.ownerId === view.you} +
+
+
{view.wardPending.takerId} grabs your treasure!
+
+ "When a player picks up one of your treasures, you may play at + that time (out of turn) this card on him." Three points, and the + Ward is spent. +
+
+ + +
+
+
+ {/if} + {/if} + {#if prefsOpen}
(prefsOpen = false)} onkeydown={() => {}}> @@ -2043,7 +2062,7 @@ onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> Pick up {cardDef(obj.cardId).name} {/each} - {#if holdingWard} + {#if holdingWard && view.deckRev < 31} {/if} diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index a4b5ebf..d90c437 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 30, + deckRev: 31, }; const { state, events } = createGame(config); for (const e of events) { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 5e17b39..c13402c 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -61,6 +61,7 @@ export function humanize(e: GameEvent): string | null { case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`; case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`; case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; + case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`; case "trapSprung": return e.cardId === "gift-from-below" ? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!` : `${e.player} walked into an old TRAP! Lose a turn.`;