The reflected-attack window (rules rev 25)

A FULL REFLECTION's returned spell is a fresh attack on its own caster —
who, per the owner's ruling, gets a defender's counteraction window
against it (absorb the blast, shield it, or FULL REFLECTION again: the
ping-pong war ends when the cards run out). The reflection now settles
into a new stack with the roles swapped and the damage pre-priced
(numbers, amplifies, and surviving counters already applied); the blow
returns to its sender unerringly, with no fresh hit rolls. onResolved
effects run with the swapped roles, so a reflected IDIOT now afflicts
its own caster. Earlier revisions land the blow instantly and stored
games replay unchanged.

Automatons price the incoming reflected damage off the stack; the attack
modal announces 'Your own spell comes screaming back!' with the inbound
total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-17 22:02:16 -04:00
co-authored by Claude Fable 5
parent dcaf358a93
commit 06faea0c79
6 changed files with 130 additions and 15 deletions
+11 -8
View File
@@ -727,14 +727,17 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
}
// An affliction's weight is its duration, not points it does not carry.
const incoming = stack.attackCard
? attackId === "stone-dead"
// NUMBER times the stones in the victim's hand — our hand.
? (stack.numberValue ?? 1) * view.yourHand.filter((c) => STONES.has(c.cardId)).length
: affliction
? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
// A reflected blow arrives pre-priced — its number is on the stack.
const incoming = stack.reflectedBase
? Math.max(stack.reflectedBase.damage, stack.reflectedBase.duration)
: stack.attackCard
? attackId === "stone-dead"
// NUMBER times the stones in the victim's hand — our hand.
? (stack.numberValue ?? 1) * view.yourHand.filter((c) => STONES.has(c.cardId)).length
: affliction
? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing.
const reverse = find("reverse");
+49 -4
View File
@@ -189,6 +189,11 @@ export interface CastStack {
defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */
creatureId?: string;
/** A FULL REFLECTION turned the spell around (rules rev 25): this stack
* is the returned blow, damage and duration already settled — the new
* defender (the original caster) answers with counteractions of their
* own. Another full reflection here turns it around again. */
reflectedBase?: { damage: number; duration: number };
/** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw";
/** A spell freed from slime: counteractions cannot touch its caster. */
@@ -5170,7 +5175,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
// Creature blows roll the same dice from rev 13 — the cards carve out no
// exception for monsters; earlier games skipped the rolls and replay so.
const missRolls = !stack.creatureId || (state.config.deckRev ?? 1) >= 13;
// A reflected spell returns to its sender unerringly — no fresh hit rolls.
const missRolls = (!stack.creatureId || (state.config.deckRev ?? 1) >= 13) && !stack.reflectedBase;
if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) {
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
if (roll !== 1) {
@@ -5218,9 +5224,15 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
const strong = sustainedOn(state, defender.id, "strength").length;
if (weak > 0 && strong === 0) base *= 2;
}
const baseDuration = effect?.sustains
let baseDuration = effect?.sustains
? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor
: 0;
// A reflected blow arrives pre-priced: the original exchange already
// applied numbers, amplifies, and surviving counters.
if (stack.reflectedBase) {
base = stack.reflectedBase.damage;
baseDuration = stack.reflectedBase.duration;
}
// Whether this attack even tries to wound: utility attacks (DROP OBJECT,
// TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped.
@@ -5295,6 +5307,37 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
? state.creatures.find((c) => c.id === stack.creatureId)
: undefined;
if (pipe.redirected) {
// The returned spell is a fresh attack on its own caster — who gets a
// defender's counteraction window against it (rules rev 25). Earlier
// revisions land the blow instantly and stored games replay so.
if ((state.config.deckRev ?? 1) >= 25 && !stack.trapped && !attackingCreature &&
attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) {
state.stack = {
attackerId: defender.id,
defenderId: attacker.id,
attackCard: stack.attackCard,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: stack.params,
kind: "spell",
counters: [],
waitingOn: attacker.id,
reflectedBase: { damage: pipe.damage, duration: pipe.duration },
};
events.push({
type: "attackResolved",
attacker: attacker.id,
defender: defender.id,
attackCardId: attackId,
damageDealt: 0,
reflectedDamage: pipe.damage,
fullyStopped: false,
redirected: true,
});
return;
}
if (pipe.damage > 0) {
if (stack.trapped) {
// "Counteractions against 'trapped' attacks do not affect the caster."
@@ -5313,8 +5356,10 @@ 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) {
const source = attackId ??
(attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`);
const source = stack.reflectedBase
? `${attackId} (reflected)`
: attackId ??
(attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`);
applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind);
damageDealt = pipe.damage;
}