From 5243a173a8cca000571939849e219a8fe9a6b652 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 18 Aug 2026 00:02:07 -0400 Subject: [PATCH] Stone soaks show their work in the damage line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Takes 2 damage' from a 3-point bolt read as broken arithmetic unless you remembered the bloodstone. The damaged event now carries its soaks (bloodstone's flat point, soulstone's spell-damage clamp) and the log prints them: 'takes 2 damage (lightning-blast (reflected) — bloodstone soaks 1)'. A blow the bloodstone drinks entirely — previously silent — announces itself too. Events are derived, so replays gain the notes retroactively. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/game.ts | 21 ++++++++++++++++----- packages/web/src/net.svelte.ts | 9 +++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index f82c8ee..2504fb2 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -501,8 +501,9 @@ export type GameEvent = | { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance } | { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" } | { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean } - | { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number } - | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" } + | { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number; + soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] } + | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" } | { type: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number } | { type: "stunned"; player: PlayerId; turnsLost: number } | { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number } @@ -5569,17 +5570,27 @@ function applyDamage( } // BLOODSTONE: "Lowers all damage done you by one point per attack." + const soaks: { what: "bloodstone" | "soulstone"; amount: number }[] = []; if (displays(target, "bloodstone")) { amount = Math.max(0, amount - 1); - if (amount === 0) return; + if (amount === 0) { + events.push({ type: "damageImmune", player: target.id, source, because: "bloodstone" }); + return; + } + soaks.push({ what: "bloodstone", amount: 1 }); } // SOULSTONE: "Last three points ... can only be lost to physical damage." if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) { - amount = Math.min(amount, target.life - 3); + const clamped = Math.min(amount, target.life - 3); + if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped }); + amount = clamped; } target.life -= amount; - events.push({ type: "damaged", player: target.id, amount, source, lifeAfter: target.life }); + events.push({ + type: "damaged", player: target.id, amount, source, lifeAfter: target.life, + ...(soaks.length > 0 ? { soaks } : {}), + }); if (target.life > 0) return; target.alive = false; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index a779e08..5e17b39 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -50,7 +50,10 @@ export function humanize(e: GameEvent): string | null { if (e.redirected) return `The spell is reflected back at ${e.attacker}!`; if (e.fullyStopped) return `The attack is completely stopped.`; return null; // the damaged event tells the story - case "damaged": return `${e.player} takes ${e.amount} damage (${e.source}) — ${e.lifeAfter} life left.`; + case "damaged": { + const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", "); + return `${e.player} takes ${e.amount} damage (${e.source}${soak ? ` — ${soak}` : ""}) — ${e.lifeAfter} life left.`; + } case "stunned": return `${e.player} is stunned and loses a turn!`; case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`; case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`; @@ -64,7 +67,9 @@ export function humanize(e: GameEvent): string | null { case "attackMissed": return e.because === "invisible" ? `The attack passes through empty air — ${e.defender} is invisible!` : `${e.defender} is too small to hit — the attack misses!`; - case "damageImmune": return `${e.player} is stone — the damage has no effect.`; + case "damageImmune": return e.because === "bloodstone" + ? `${e.player}'s bloodstone drinks the whole blow — no damage.` + : `${e.player} is stone — the damage has no effect.`; case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`; case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;