diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 507c7eb..40853a7 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -4628,6 +4628,17 @@ function checkAmbushes( } } +/** A NUMBER card riding a counteraction sets the raised spell's duration. */ +function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number { + const num = (numberInstanceIds ?? []) + .map((id) => player.hand.find((c) => c.instanceId === id)) + .find((c): c is CardInstance => c != null && isNumberCard(c.cardId)); + if (!num) return 1; + takeFromHand(player, num.instanceId); + state.discard.push(num); + return numberValue(num.cardId); +} + function doCounteract( prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }, numberInstanceIds?: string[], @@ -4710,16 +4721,7 @@ function doCounteract( if (stack.creatureId && (state.config.deckRev ?? 1) < 13) { return err("in this game's revision, creatures are not fooled by the vanishing"); } - let duration = 1; - const attached = (numberInstanceIds ?? []) - .map((id) => player.hand.find((c) => c.instanceId === id)) - .filter((c): c is CardInstance => c != null && isNumberCard(c.cardId)); - const num = attached[0]; - if (num) { - duration = numberValue(num.cardId); - takeFromHand(player, num.instanceId); - state.discard.push(num); - } + const duration = counterDuration(state, player, numberInstanceIds); takeFromHand(player, instanceId); state.discard.push(card); const events: GameEvent[] = [ @@ -4732,6 +4734,23 @@ function doCounteract( return { ok: true, state, events }; } + // EMPATHY (corner: COUNTERACTION): "Any attack done in any form against + // you acts against both you and the caster" — raised as the blow lands, + // lingering for the NUMBER card's duration after. + if (card.cardId === "empathy") { + const duration = counterDuration(state, player, numberInstanceIds); + takeFromHand(player, instanceId); + state.discard.push(card); + const events: GameEvent[] = [ + { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }, + ]; + attachSustained(state, events, "empathy", playerId, playerId, duration); + stack.counters.push({ player: playerId, card, nullified: false }); + stack.waitingOn = stack.attackerId; + state.lastSpellUsed[playerId] = card.cardId; + return { ok: true, state, events }; + } + // WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT." // WATERWALL: "Acts as counteraction to FIREBALL" — the mirror image. if (card.cardId === "wall-of-fire" || card.cardId === "waterwall") { @@ -4822,12 +4841,17 @@ function doCounteract( takeFromHand(player, instanceId); state.discard.push(card); targetCounter.nullified = true; + const events: GameEvent[] = [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }]; + if (targetCounter.card.cardId === "empathy") { + // The raised link dies with its counteraction. + const fx = state.sustained.filter((f) => f.cardId === "empathy" && f.targetId === stack.defenderId).pop(); + if (fx) { + state.sustained = state.sustained.filter((f) => f !== fx); + events.push({ type: "spellExpired", effectId: fx.id, cardId: "empathy", target: stack.defenderId }); + } + } stack.waitingOn = stack.defenderId; - return { - ok: true, - state, - events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }], - }; + return { ok: true, state, events }; } return err("you are not part of this exchange"); @@ -4967,8 +4991,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void { pipe.fullyStopped = true; continue; } - if (counter.card.cardId === "invisible") { - continue; // its work is the sustained vanishing and the hit roll above + if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") { + continue; // their work is the sustained effect, not the damage pipe } if (counter.card.cardId === "teleport") { pipe.damage = 0; diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 1c3a1e2..f92f0ee 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -906,3 +906,44 @@ describe("escapes and elemental walls as counteractions", () => { expect(refused.ok).toBe(false); }); }); + +describe("empathy as a counteraction", () => { + function empathyRig() { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 15 }); + 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: "empathy#T", cardId: "empathy" }; + d.hand[1] = { instanceId: "number-3#T", cardId: "number-3" }; + state = must(state, attacker, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, + }); + return { state, attacker, defender }; + } + + it("the blow lands on both — and the link lingers its number's turns", () => { + let { state, attacker, defender } = empathyRig(); + state = must(state, defender, { + type: "counteract", instanceId: "empathy#T", numberInstanceIds: ["number-3#T"], + }); + state = must(state, attacker, { type: "pass" }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(10); + expect(state.players.find((p) => p.id === attacker)!.life).toBe(10); + const fx = state.sustained.find((f) => f.cardId === "empathy" && f.targetId === defender); + expect(fx?.remainingTurns).toBe(3); + }); + + it("anti-anti severs the link: no mirror, no lingering spell", () => { + let { state, attacker, defender } = empathyRig(); + state = must(state, defender, { type: "counteract", instanceId: "empathy#T" }); + const a = state.players.find((p) => p.id === attacker)!; + a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" }; + state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(10); + expect(state.players.find((p) => p.id === attacker)!.life).toBe(15); + expect(state.sustained.some((f) => f.cardId === "empathy" && f.targetId === defender)).toBe(false); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 4936105..82f8711 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1040,7 +1040,17 @@ {#if view.stack.defenderId === view.you && attackingCreature}