Rev 18: REFLECTION's returning half is the caster's to counteract (96WE)

Kestrel's blade came back halved off the automaton's REFLECTION and
landed before he could raise the ABSORB in his hand. FULL REFLECTION's
return was already a fresh attack with the caster's own window; the
half now goes the same way — its damage and any split duration ride
a returned stack the caster may meet with an ABSORB, a BLUNT, or
another REFLECTION. Trapped spells and creatures' touches keep the
instant half. Older games replay as they landed. A test soaks the
half and takes it, under rev 18 and rev 17.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-05 22:37:19 -04:00
co-authored by Claude Fable 5.1
parent 2fd324a475
commit 34d76d9feb
2 changed files with 73 additions and 3 deletions
+31 -3
View File
@@ -238,7 +238,7 @@ export interface CastParams {
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 17;
export const CURRENT_RULES_REV = 18;
/** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house
@@ -260,6 +260,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 15, note: "BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do (FAQ: at an intersection the other player decides). Older games shove straight ahead whenever that way is open; rounding a corner was never possible before and is allowed in every game." },
{ rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." },
{ rev: 17, note: "A warp mouth on a pit's rim is a way off it like any square: a bare step beside one square and one mouth is a fork the walker names. In every game the mouth may be named, and is taken when no square offers; only an older game's bare step still lands on the square unasked." },
{ rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." },
];
export interface GameConfig {
@@ -6118,6 +6119,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
const attackId = stack.attackCard?.cardId ?? null;
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
/** REFLECTION's half, when it goes back as an attack of its own. */
let returned: { damage: number; duration: number } | null = null;
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
@@ -6355,13 +6358,22 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
}
if (pipe.reflectedDamage > 0 && !stack.trapped) {
// Rev 18: REFLECTION's returning half is a blow on the caster like
// FULL REFLECTION's whole — a fresh attack with the caster's own
// counteraction window, so an ABSORB or a BLUNT can meet it. Older
// games landed the half at once, and replay so.
const halfReturns = (state.config.deckRev ?? 1) >= 18 && !stack.trapped && !attackingCreature &&
attacker.alive && (pipe.reflectedDamage > 0 || (pipe.splitDuration && pipe.duration > 0));
if (pipe.reflectedDamage > 0 && !stack.trapped && !halfReturns) {
if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
}
}
if (halfReturns) {
returned = { damage: pipe.reflectedDamage, duration: pipe.splitDuration ? pipe.duration : 0 };
}
// EMPATHY: "Any attack done in any form against you acts against both
// you and the caster of the spell."
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
@@ -6374,7 +6386,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
}
if (effect?.sustains && pipe.duration > 0 && !pipe.fullyStopped) {
attachSustained(state, events, attackId!, attacker.id, defender.id, pipe.duration);
if (pipe.splitDuration) {
if (pipe.splitDuration && !returned) {
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
}
}
@@ -6413,6 +6425,22 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
stack,
});
}
if (returned && attacker.alive) {
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: returned,
};
}
}
/**
+42
View File
@@ -117,6 +117,48 @@ describe("attack spells", () => {
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("reflection's returning half is the caster's to counteract (rev 18)", () => {
const setup = (deckRev: number) => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "reflection#T", cardId: "reflection" };
const a = state.players.find((p) => p.id === attacker)!;
a.hand.push({ instanceId: "absorb#T", cardId: "absorb" });
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "reflection#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
return { state, attacker, defender };
};
const life = (s: GameState, id: string) => s.players.find((p) => p.id === id)!.life;
// Rev 18: the defender takes their half; the half coming back waits on the caster.
{
const { state, attacker, defender } = setup(18);
expect(life(state, defender)).toBe(12);
expect(life(state, attacker)).toBe(15);
expect(state.stack?.waitingOn).toBe(attacker);
expect(state.stack?.reflectedBase?.damage).toBe(3);
// A counter hands the word to the reflector, who may answer; both pass to resolve.
const soaked = must(state, attacker, { type: "counteract", instanceId: "absorb#T" });
const done = must(must(soaked, defender, { type: "pass" }), attacker, { type: "pass" });
expect(life(done, attacker)).toBe(15);
expect(done.stack).toBeNull();
const taken = must(state, attacker, { type: "pass" });
expect(life(taken, attacker)).toBe(12);
expect(taken.stack).toBeNull();
}
// Rev 17: the half landed at once.
{
const { state, attacker, defender } = setup(17);
expect(life(state, defender)).toBe(12);
expect(life(state, attacker)).toBe(12);
expect(state.stack).toBeNull();
}
});
it("absorb spell nullifies the attack and takes the card into hand", () => {
let { state } = newGame();
state = toRound2(state);