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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}` : ""}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user