From 4d8584ae441433a59dd7caa0c3b590d8abef4676 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 21:07:47 -0400 Subject: [PATCH] Rev 12: vanishing and water answer the call to arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INVISIBLE's corner reads NEUTRAL/COUNTERACTION, and ANTI-ANTI's own face names it an escape — yet the engine refused it at the door. Now it counters: the vanishing goes up mid-stack (duration from a NUMBER card riding the counteract command), the attacker's 1-in-4 hit roll happens at resolution, the spell lingers after the exchange, and ANTI-ANTI cannot pin it. WATERWALL — "Acts as counteraction to FIREBALL" — now mirrors WALL OF FIRE's waterbolt-stopping role and counts as a total stop. WALL OF FIRE itself joins the total-stop set at rev 12 only: stored rev-10/11 games bounced after it and replay so. While responding, tapping a number card holds it to ride the next counter (unless a displayed Shieldstone makes the number itself the counteraction, as before). The automatons learn the trick too: vanish when the blow is heavy and the shields are spent, and never waste ANTI-ANTI against an escape. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 11 ++++- packages/engine/src/game.ts | 65 +++++++++++++++++++++++----- packages/engine/test/casting.test.ts | 62 ++++++++++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 15 ++++++- packages/web/src/local.svelte.ts | 2 +- 6 files changed, 141 insertions(+), 16 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 086e819..6b2a653 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -258,7 +258,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma if (stack.attackerId === you) { const last = [...stack.counters].reverse().find((c) => !c.nullified); const anti = find("anti-anti"); - if (anti && last && last.card.cardId !== "teleport") { + if (anti && last && last.card.cardId !== "teleport" && last.card.cardId !== "invisible") { return { type: "counteract", instanceId: anti.instanceId }; } } @@ -308,6 +308,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma const out = escapeCell(view, me(view).position); if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; } + // Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after. + const vanish = find("invisible"); + if (vanish && !stack.creatureId && incoming >= 3 - flinch) { + const num = numbersInHand(view)[0]; + return { + type: "counteract", instanceId: vanish.instanceId, + ...(num ? { numberInstanceIds: [num.instanceId] } : {}), + }; + } // A displayed shieldstone lets numbers soak the small ones. const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone"); if (stoneShown && incoming >= 2) { diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 12bde4a..35b96ed 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -635,7 +635,7 @@ export type Command = } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] } | { type: "cancelAmbush"; ambushId: string } - | { type: "counteract"; instanceId: string; params?: { cell?: Cell } } + | { type: "counteract"; instanceId: string; params?: { cell?: Cell }; numberInstanceIds?: string[] } | { type: "pass" } | { type: "pickUpTreasure" } | { type: "pickUpObject"; instanceId: string } @@ -3197,7 +3197,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm if (state.stack) { if (playerId !== state.stack.waitingOn) return err("waiting for another player's response"); - if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params); + if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds); if (command.type === "pass") return doPass(state, playerId); return err("an attack is being resolved — counteract or pass"); } @@ -4565,7 +4565,10 @@ function checkAmbushes( } } -function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }): CommandResult { +function doCounteract( + prev: GameState, playerId: PlayerId, instanceId: string, + params?: { cell?: Cell }, numberInstanceIds?: string[], +): CommandResult { const state = clone(prev); const stack = state.stack!; const player = state.players.find((p) => p.id === playerId)!; @@ -4637,10 +4640,39 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p }; } + // INVISIBLE (corner: NEUTRAL/COUNTERACTION): vanish as the bolt flies. + // The spell truly goes up — duration from an attached NUMBER card — and + // the attacker's 1-in-4 hit roll happens at resolution. + if (card.cardId === "invisible") { + if (stack.creatureId) return err("the creature shares your square — there is nowhere to hide"); + let duration = 1; + const attached = (numberInstanceIds ?? []) + .map((id) => player.hand.find((c) => c.instanceId === id)) + .filter((c): c is CardInstance => c != null && isNumberCard(c.cardId)); + const num = attached[0]; + if (num) { + duration = numberValue(num.cardId); + takeFromHand(player, num.instanceId); + state.discard.push(num); + } + takeFromHand(player, instanceId); + state.discard.push(card); + const events: GameEvent[] = [ + { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }, + ]; + attachSustained(state, events, "invisible", playerId, playerId, duration); + stack.counters.push({ player: playerId, card, nullified: false }); + stack.waitingOn = stack.attackerId; + state.lastSpellUsed[playerId] = card.cardId; + return { ok: true, state, events }; + } + // WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT." - if (card.cardId === "wall-of-fire") { - if (stack.attackCard?.cardId !== "waterbolt") { - return err("as a counteraction, Wall of Fire only stops a Waterbolt"); + // WATERWALL: "Acts as counteraction to FIREBALL" — the mirror image. + if (card.cardId === "wall-of-fire" || card.cardId === "waterwall") { + const stops = card.cardId === "wall-of-fire" ? "waterbolt" : "fireball"; + if (stack.attackCard?.cardId !== stops) { + return err(`as a counteraction, ${cardDef(card.cardId).name} only stops a ${cardDef(stops).name}`); } takeFromHand(player, instanceId); state.discard.push(card); @@ -4649,7 +4681,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p return { ok: true, state, - events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: "waterbolt" }], + events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stops }], }; } @@ -4719,6 +4751,9 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") { return err("ANTI-ANTI does not work against escape"); } + if (targetCounter.card.cardId === "invisible") { + return err("ANTI-ANTI does not work against escape"); + } takeFromHand(player, instanceId); state.discard.push(card); targetCounter.nullified = true; @@ -4736,7 +4771,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p /** Counters that leave the defender nothing worth adding: the spell is dead, * escaped, thrown back whole, or drunk as a tonic. */ const TOTAL_STOP_COUNTERS = new Set([ - "full-shield", "force-field", "teleport", "full-reflection", "reverse", + "full-shield", "force-field", "teleport", "full-reflection", "reverse", "waterwall", ]); function doPass(prev: GameState, playerId: PlayerId): CommandResult { @@ -4748,9 +4783,12 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult { // defender may stack further counters — but not past a total stop: // re-prompting then reads as "your shield failed" and goads the table // into burning cards against a spell that is already dead (rules rev 10). - const totalStop = (state.config.deckRev ?? 1) >= 10 && + const rev = state.config.deckRev ?? 1; + const totalStop = rev >= 10 && stack.counters.some((c) => - !c.nullified && TOTAL_STOP_COUNTERS.has(c.card.cardId) && + !c.nullified && + (TOTAL_STOP_COUNTERS.has(c.card.cardId) || + (rev >= 12 && c.card.cardId === "wall-of-fire")) && (stack.kind === "spell" || c.card.cardId === "teleport")); if (!totalStop) { stack.waitingOn = stack.defenderId; @@ -4854,12 +4892,15 @@ function resolveStack(state: GameState, events: GameEvent[]): void { pipe.duration = Math.max(0, pipe.duration - v); continue; } - if (counter.card.cardId === "wall-of-fire") { - // The wave meets the fire: the waterbolt is entirely stopped. + if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") { + // Fire meets water, whichever was thrown first: entirely stopped. pipe.damage = 0; pipe.fullyStopped = true; continue; } + if (counter.card.cardId === "invisible") { + continue; // its work is the sustained vanishing and the hit roll above + } if (counter.card.cardId === "teleport") { pipe.damage = 0; pipe.duration = 0; diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index b5470ee..1c3a1e2 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -844,3 +844,65 @@ describe("total stops end the exchange (rules rev 10)", () => { expect(state.players.find((p) => p.id === defender)!.life).toBeLessThan(15); }); }); + +describe("escapes and elemental walls as counteractions", () => { + function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const atk = giveCard(state, attacker, attackId); + const d = state.players.find((p) => p.id === defender)!; + defenderCards.forEach((c, i) => { d.hand[i] = c; }); + state = must(state, attacker, { + type: "cast", instanceId: atk.instanceId, target: { kind: "player", playerId: defender }, + }); + return { state, attacker, defender, atk }; + } + + it("invisible vanishes mid-stack: the sustained goes up, the hit needs a 1", () => { + let { state, defender } = rigged("fireball", [ + { instanceId: "invisible#T", cardId: "invisible" }, + { instanceId: "number-3#T", cardId: "number-3" }, + ]); + state = must(state, defender, { + type: "counteract", instanceId: "invisible#T", numberInstanceIds: ["number-3#T"], + }); + const fx = state.sustained.find((s) => s.cardId === "invisible" && s.targetId === defender); + expect(fx).toBeDefined(); + expect(fx!.remainingTurns).toBe(3); + // The attached number left the hand with the spell. + const d = state.players.find((p) => p.id === defender)!; + expect(d.hand.some((c) => c.instanceId === "number-3#T")).toBe(false); + }); + + it("anti-anti cannot pin the vanishing — escape is escape", () => { + let { state, attacker, defender } = rigged("fireball", [ + { instanceId: "invisible#T", cardId: "invisible" }, + ]); + state = must(state, defender, { type: "counteract", instanceId: "invisible#T" }); + const a = state.players.find((p) => p.id === attacker)!; + a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" }; + const refused = applyCommand(state, attacker, { type: "counteract", instanceId: "anti-anti#T" }); + expect(refused.ok).toBe(false); + }); + + it("waterwall meets the fireball and stops it whole", () => { + let { state, attacker, defender } = rigged("fireball", [ + { instanceId: "waterwall#T", cardId: "waterwall" }, + ]); + const lifeBefore = state.players.find((p) => p.id === defender)!.life; + state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" }); + state = must(state, attacker, { type: "pass" }); + // A total stop: the attacker's declined answer resolves at once (rev 10+). + expect(state.stack).toBeNull(); + expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore); + }); + + it("waterwall refuses everything that is not a fireball", () => { + let { state, defender } = rigged("dagger", [ + { instanceId: "waterwall#T", cardId: "waterwall" }, + ]); + const refused = applyCommand(state, defender, { type: "counteract", instanceId: "waterwall#T" }); + expect(refused.ok).toBe(false); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index ce339b8..d6e68e6 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 = 11; +const RULES_REV = 12; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index e54e829..44cf406 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -108,6 +108,9 @@ const attackingCreature = $derived( view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null, ); + const myShieldstoneDisplayed = $derived( + view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "shieldstone") ?? false, + ); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null); /** Picking up an object ends the turn's actions: the hand goes quiet until @@ -283,7 +286,17 @@ selectedCard = card; // then click the escape square return; } - dispatch({ type: "counteract", instanceId: card.instanceId }); + // A number can ride a counter (INVISIBLE's duration) — unless a + // displayed Shieldstone makes the number itself the counteraction. + if (youMustRespond && isNumberCard(card.cardId) && !myShieldstoneDisplayed) { + attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card; + return; + } + dispatch({ + type: "counteract", instanceId: card.instanceId, + ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), + }); + attachedNumber = null; return; } if (youMustDiscard || discardMode || discardSelection.size > 0) { diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index c5359df..e3f6fe7 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -132,7 +132,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 11, + deckRev: 12, }; const { state, events } = createGame(config); this.config = config;