Rules rev 4: a creature's blow can be counteracted

The WRAITH card assumes the window exists — "REFLECTIONs used on the
wraith's touch will damage the wraith" — but creature damage applied
instantly, so BLUNT had nothing to catch, as playtesting found. Under
revision 4 every creature blow against a wizard opens the same
counteraction stack a spell does: the wraith's entry touch, the
democratic monster's claw, and commanded troll/skeleton/shadow
attacks. The blow's damage rides the stack; BLUNT halves it (round
up); reflections work by name against creatures — half back for
REFLECTION, the whole blow for FULL REFLECTION — landing on the
creature, not its controller; FULL SHIELD correctly does nothing
("does not stop any physical attack"); the wraith's card theft is a
secondary effect that lands only if damage does. No aim-miss rolls
against invisible or shrunk defenders — the creature is already in
the square. The attack fanfare shows the creature's own card.

Earlier revisions keep the instant touch so every stored game — and
the live one that found this — replays unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 13:25:45 -04:00
co-authored by Claude Fable 5
parent 3f3e6d9853
commit d1eace9530
5 changed files with 166 additions and 15 deletions
+93 -8
View File
@@ -182,6 +182,10 @@ export interface CastStack {
waitingOn: PlayerId;
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */
creatureId?: string;
/** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw";
}
export interface CastParams {
@@ -207,7 +211,8 @@ export interface GameConfig {
* Rules revision, frozen per game so stored games replay unchanged.
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS.
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
* creature blows open a counteraction window like any attack.
*/
deckRev?: number;
}
@@ -2527,12 +2532,19 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
// Touch effects on entering a player's square.
const rev4 = (state.config.deckRev ?? 1) >= 4;
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id });
if (rev4) {
// The victim may counteract ("REFLECTIONs used on the wraith's touch
// will damage the wraith" — the card assumes exactly this window).
openCreatureStack(state, creature, p, 2, "wraith");
return { ok: true, state, events };
}
applyDamage(state, events, p, 2, "wraith's touch", null);
if (p.alive && p.hand.length > 0) {
const [idx, rngNext] = nextInt(state.rng, p.hand.length);
@@ -2546,6 +2558,10 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
if (creature.kind === "democratic-monster" && !creature.attackUsed) {
creature.attackUsed = true; // "may attack only one player per round of turns"
events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id });
if (rev4) {
openCreatureStack(state, creature, p, 2, "claw");
return { ok: true, state, events };
}
applyDamage(state, events, p, 2, "clawing monster", null);
}
}
@@ -2553,6 +2569,31 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
return { ok: true, state, events };
}
/** Rules rev 4: a creature's blow opens a counteraction window like any attack. */
function openCreatureStack(
state: GameState,
creature: CreatureState,
victim: PlayerState,
damage: number,
touch?: "wraith" | "claw",
): void {
state.stack = {
attackerId: creature.controllerId,
defenderId: victim.id,
attackCard: null,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: { damage },
kind: "physical",
counters: [],
waitingOn: victim.id,
creatureId: creature.id,
...(touch ? { creatureTouch: touch } : {}),
};
}
function doCreatureAttack(prev: GameState, creatureId: string, targetId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
@@ -2593,6 +2634,10 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
}
events.push({ type: "creatureAttacked", creatureId: creature.id, target: targetId, dieRoll: roll });
if (targetPlayer) {
if ((state.config.deckRev ?? 1) >= 4) {
openCreatureStack(state, creature, targetPlayer, amount);
return { ok: true, state, events };
}
applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical");
} else {
damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`);
@@ -4447,8 +4492,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
// SHRINK (50% miss). "If a spell misses, it dissipates harmlessly."
if (sustainedOn(state, defender.id, "invisible").length > 0) {
// SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." A
// creature sharing the square has nothing to aim: no miss rolls.
if (!stack.creatureId && sustainedOn(state, defender.id, "invisible").length > 0) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
if (roll !== 1) {
@@ -4457,7 +4503,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
return;
}
}
if (sustainedOn(state, defender.id, "shrink").length > 0) {
if (!stack.creatureId && sustainedOn(state, defender.id, "shrink").length > 0) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
if (roll > 2) {
@@ -4467,7 +4513,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
}
}
let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1
let base = effect
? effect.baseDamage(stack.numberValue, stack.params)
: stack.creatureId
? (stack.params?.damage ?? 1) // a creature's blow
: 1; // a punch
// 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;
@@ -4532,14 +4582,33 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
pipe.fullyStopped = true;
continue;
}
if (stack.creatureId &&
(counter.card.cardId === "reflection" || counter.card.cardId === "full-reflection")) {
// "REFLECTIONs used on the wraith's touch will damage the wraith."
if (counter.card.cardId === "reflection") {
const half = Math.ceil(pipe.damage / 2);
pipe.reflectedDamage += half;
pipe.damage = half;
} else {
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
}
continue;
}
const ce = CARD_EFFECTS[counter.card.cardId];
if (ce && ce.kind === "counter") ce.apply(pipe);
}
let damageDealt = 0;
const attackingCreature = stack.creatureId
? state.creatures.find((c) => c.id === stack.creatureId)
: undefined;
if (pipe.redirected) {
if (pipe.damage > 0) {
applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id);
if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.damage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id);
}
}
if (effect?.sustains && pipe.duration > 0) {
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
@@ -4550,11 +4619,27 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
events.push({ type: "lifeGained", player: defender.id, amount: pipe.damage, source: `${attackId} (reversed)`, lifeAfter: defender.life });
damageDealt = pipe.damage; // secondary effects still take effect
} else if (pipe.damage > 0) {
applyDamage(state, events, defender, pipe.damage, attackId ?? `punch from ${attacker.id}`, attacker.id, pipe.kind);
const source = attackId ??
(attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`);
applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind);
damageDealt = pipe.damage;
}
// "he takes 2 points of damage and loses a random card" — the theft is a
// secondary effect, stopped only when all the damage is.
if (stack.creatureTouch === "wraith" && damageDealt > 0 && defender.alive && defender.hand.length > 0) {
const [idx, rngNext] = nextInt(state.rng, defender.hand.length);
state.rng = rngNext;
const [card] = defender.hand.splice(idx, 1);
defender.displayed = defender.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
}
if (pipe.reflectedDamage > 0) {
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
}
}
// EMPATHY: "Any attack done in any form against you acts against both
// you and the caster of the spell."
+60 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId } from "../src/game";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -282,3 +282,62 @@ describe("expansion support cards", () => {
expect(state.creatures.length).toBe(0);
});
});
describe("counteracting a creature's blow (rules rev 4)", () => {
function rev4() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
}
function wraithOnVictim(state: GameState) {
state = toRound2(state);
const controller = activePlayer(state);
const victim = state.players.find((p) => p.id !== controller.id)!;
const spot = emptyNeighborCell(state, controller.position);
state.creatures.push({
id: "w1", kind: "wraith", controllerId: controller.id, position: { ...controller.position },
damage: 0, maxDamage: 4, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 1, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
});
victim.position = { ...spot.cell };
return { state, controller: controller.id, victim: victim.id, side: spot.side };
}
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
let { state } = rev4();
const rig = wraithOnVictim(state);
state = rig.state;
giveCard(state, rig.victim, "blunt", "B", 0);
const handBefore = state.players.find((p) => p.id === rig.victim)!.hand.filter(Boolean).length;
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
expect(state.stack?.creatureId).toBe("w1");
state = must(state, rig.victim, { type: "counteract", instanceId: "blunt#B" });
state = must(state, rig.controller, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
const v = state.players.find((p) => p.id === rig.victim)!;
expect(v.life).toBe(14); // 2 halved up to 1
expect(v.hand.length).toBe(handBefore - 2); // blunt spent + a card stolen
});
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
let { state } = rev4();
const rig = wraithOnVictim(state);
state = rig.state;
giveCard(state, rig.victim, "full-reflection", "FR", 0);
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, rig.controller, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
const v = state.players.find((p) => p.id === rig.victim)!;
expect(v.life).toBe(15);
const wraith = state.creatures.find((c) => c.id === "w1")!;
expect(wraith.damage).toBe(2);
});
it("legacy games (rev < 4) keep the instant touch", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
const rig = wraithOnVictim(state);
state = rig.state;
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 3;
const RULES_REV = 4;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+11 -4
View File
@@ -83,9 +83,12 @@
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
const attackNoticeKey = $derived(
youMustRespond && view?.stack
? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? "punch"}:${view.stack.counters.length}`
? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}`
: null,
);
const attackingCreature = $derived(
view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
@@ -746,13 +749,17 @@
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
<div class="attack-notice">
<div class="attack-headline">
{#if view.stack.defenderId === view.you}
{#if view.stack.defenderId === view.you && attackingCreature}
<strong>{view.stack.attackerId}</strong>'s {cardDef(attackingCreature.kind).name} attacks you!
{:else if view.stack.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you!
{:else}
<strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
{/if}
</div>
{#if view.stack.defenderId === view.you && view.stack.attackCard}
{#if view.stack.defenderId === view.you && attackingCreature}
<div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
{:else if view.stack.defenderId === view.you && view.stack.attackCard}
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
{#if view.stack.numberValue != null}
<div class="attack-power">powered by a {view.stack.numberValue}</div>
@@ -1131,7 +1138,7 @@
<span class="hint-alert">
{#if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with
{view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"}
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"}
tap a counteraction card, or
{:else}
{view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
+1 -1
View File
@@ -101,7 +101,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 3,
deckRev: 4,
};
const { state, events } = createGame(config);
this.config = config;