diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 8b13d2d..fb30051 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -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"); diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 183d471..91e7e02 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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; } diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 03d327b..3d2c42f 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 39c3fcd..90b6c23 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** 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"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index de386ec..cb2ec8b 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1095,6 +1095,8 @@
{#if view.stack.defenderId === view.you && attackingCreature} {view.stack.attackerId}'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} {view.stack.attackerId} attacks you! {:else} @@ -1115,7 +1117,11 @@ {:else}
{/if} - {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1} + {#if view.stack.reflectedBase} +
+ REFLECTED — {view.stack.reflectedBase.damage} damage inbound unless you answer it +
+ {:else if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""} {view.stack.amplifyFactor > 1 ? ` — AMPLIFIED ×${view.stack.amplifyFactor}` : ""} diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index fedbf10..01b8fb7 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -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) {