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:
co-authored by
Claude Fable 5
parent
dcaf358a93
commit
06faea0c79
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user