Rev 12: vanishing and water answer the call to arms

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 21:07:47 -04:00
co-authored by Claude Fable 5
parent fb2266b401
commit 4d8584ae44
6 changed files with 141 additions and 16 deletions
+10 -1
View File
@@ -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) {
+53 -12
View File
@@ -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;
+62
View File
@@ -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);
});
});