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;
}
+61
View File
@@ -962,3 +962,64 @@ describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
expect(15 - aAfter.life).toBe(15 - dAfter.life);
});
});
describe("the reflected-attack window (rules rev 25)", () => {
function reflectRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "sudden-death");
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
// The attacker's pass meets the total stop and settles the reflection —
// which opens the returned spell's window, waiting on the caster.
state = must(state, attacker, { type: "pass" });
return { state, attacker, defender };
}
it("the returned spell opens a window: absorb soaks the reflected blast", () => {
let { state, attacker, defender } = reflectRig();
// The reflection settled into a NEW stack: the caster now defends.
expect(state.stack).not.toBeNull();
expect(state.stack!.defenderId).toBe(attacker);
expect(state.stack!.reflectedBase).toEqual({ damage: 10, duration: 0 });
giveCard(state, attacker, "absorb", "AB", 0);
state = must(state, attacker, { type: "counteract", instanceId: "absorb#AB" });
state = must(state, defender, { type: "pass" });
state = must(state, attacker, { type: "pass" });
// 10 reflected minus absorb's 3.
expect(state.players.find((p) => p.id === attacker)!.life).toBe(8);
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
});
it("a second full reflection turns it around again — the ping-pong war", () => {
let { state, attacker, defender } = reflectRig();
giveCard(state, attacker, "full-reflection", "FR2", 0);
state = must(state, attacker, { type: "counteract", instanceId: "full-reflection#FR2" });
state = must(state, defender, { type: "pass" });
// Turned around again: now the original defender must answer it.
expect(state.stack).not.toBeNull();
expect(state.stack!.defenderId).toBe(defender);
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(5);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
});
it("rev 24 lands the reflected blow instantly, as stored games replay", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 24 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "sudden-death");
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, attacker, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === attacker)!.life).toBe(5);
});
});
+1 -1
View File
@@ -54,7 +54,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 = 24;
const RULES_REV = 25;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+7 -1
View File
@@ -1095,6 +1095,8 @@
<div class="attack-headline">
{#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 && view.stack.reflectedBase}
Your own spell comes screaming back!
{:else if view.stack.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you!
{:else}
@@ -1115,7 +1117,11 @@
{:else}
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
{/if}
{#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
{#if view.stack.reflectedBase}
<div class="attack-power">
REFLECTED — {view.stack.reflectedBase.damage} damage inbound unless you answer it
</div>
{:else if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
<div class="attack-power">
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
{view.stack.amplifyFactor > 1 ? ` — AMPLIFIED ×${view.stack.amplifyFactor}` : ""}
+1 -1
View File
@@ -136,7 +136,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 24,
deckRev: 25,
};
const { state, events } = createGame(config);
for (const e of events) {