The Ward is played in the moment (rules rev 31)
'When a player picks up one of your treasures, you may play at that time (out of turn) this card on him' — the owner read the card and is right: no arming ahead. When a treasure whose owner holds WARD is grabbed, the grab hangs (new wardPending phase, outranking all other input) and the owner alone answers: spring it (3 damage, card spent) or let them go (card kept, silence). Automatons always spring it on a thief of their gold. The arming mechanic is refused at rev 31 and its button hidden; rev 3-30 games keep their armed wards and rev 1-2 keep the automatic spring, replaying unchanged. The window does reveal that the owner holds SOMETHING - as it would at any real table when the room turns to look at them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
b7f93fe454
commit
75d3d44c6c
@@ -257,6 +257,9 @@ export interface GameState {
|
||||
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
|
||||
/** 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!);
|
||||
|
||||
Reference in New Issue
Block a user