diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 3aa8727..af91a18 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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, + }; + } } /** diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 6639b76..1ba69b5 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -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);