From 7a0a972452cfdec4f6012072fdb4ba5cfbad366b Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 24 Sep 2026 21:32:34 -0400 Subject: [PATCH] A creature's blow is named on the stack: the counter is played against the fire imp's scorch, not a punch (SC59) The caster whose creature is countered now sees "your attack, answered" and the name of their own blow, instead of an incoming punch with the opponent's counter card standing in the attack slot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 12 +++++++++++ packages/engine/test/creatures.test.ts | 28 +++++++++++++++++++++++--- packages/web/src/App.svelte | 22 ++++++++++++++------ packages/web/src/net.svelte.ts | 6 +++++- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index e0f303d..07d3f82 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -3358,6 +3358,17 @@ function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerSt } /** A creature's blow opens a counteraction window like any attack. */ +/** What a creature's blow is called on the stack and in the chronicle: + * the counteraction is played against this, and the damage is dealt as it. */ +function creatureBlowName(kind: CreatureState["kind"]): string { + switch (kind) { + case "fire-imp": return "fire imp's scorch"; + case "wraith": return "wraith's touch"; + case "democratic-monster": return "democratic monster's claw"; + default: return `${kind}'s blow`; + } +} + function openCreatureStack( state: GameState, creature: CreatureState, @@ -3370,6 +3381,7 @@ function openCreatureStack( params: { damage }, kind, creatureId: creature.id, + sourceName: creatureBlowName(creature.kind), ...(touch ? { creatureTouch: touch } : {}), }); } diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 2bccfd2..4572d17 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -931,15 +931,37 @@ describe("the imp's fire is a spell (rev 8)", () => { if (!r.ok) throw new Error(r.error); state = r.state; if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 }); - // The scorch rides the stack now: a counteraction window, spell-kind. + // The scorch rides the stack now: a counteraction window, spell-kind, + // named for the imp so the counter is played against the scorch, never a punch. expect(state.stack?.creatureId).toBeTruthy(); expect(state.stack?.kind).toBe("spell"); + expect(state.stack?.sourceName).toBe("fire imp's scorch"); giveCard(state, "mark", "full-shield", "FS", 0); - state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" }); - state = drain(state); + const countered = applyCommand(state, "mark", { type: "counteract", instanceId: "full-shield#FS" }); + if (!countered.ok) throw new Error(countered.error); + expect(countered.events).toContainEqual(expect.objectContaining({ type: "counteractionPlayed", against: "fire imp's scorch" })); + state = drain(countered.state); expect(state.players.find((p) => p.id === "mark")!.life).toBe(15); }); + it("an uncountered scorch is dealt in the imp's name", () => { + let state = impRig(); + const owner = state.players.find((p) => p.id === "imp-owner")!; + const mark = state.players.find((p) => p.id === "mark")!; + mark.position = { ...owner.position }; + const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 0); + const spot = emptyNeighborCell(state, owner.position); + let r = applyCommand(state, "imp-owner", { + type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + if (!r.ok) throw new Error(r.error); + state = r.state; + if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 }); + const done = applyCommand(state, "mark", { type: "pass" }); + if (!done.ok) throw new Error(done.error); + expect(done.events).toContainEqual(expect.objectContaining({ type: "damaged", player: "mark", amount: 2, source: "fire imp's scorch" })); + }); + it("soulstone's floor holds from below: a bearer at 2 takes nothing from the flame", () => { let state = impRig(); const owner = state.players.find((p) => p.id === "imp-owner")!; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index a9e9404..4901408 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -276,7 +276,11 @@ let peekTreasure = $state<{ src: string; note: string } | null>(null); /** Cardless blows that a card nonetheless made: the stack names the * blow, and this names the card to show for it. */ - const BLOW_CARD: Record = { "warded treasure": "ward" }; + const BLOW_CARD: Record = { + "warded treasure": "ward", + "fire imp's scorch": "fire-imp", "troll's blow": "troll", "skeleton's blow": "skeleton", + "wraith's touch": "wraith", "democratic monster's claw": "democratic-monster", + }; /** Bare-knuckle demolition: click a wall to punch it. */ let punchWallMode = $state(false); @@ -415,6 +419,11 @@ ? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}` : null, ); + /** The attack on the stack, named for its caster: the card, the creature's blow, or the punch. */ + const yourAttackName = $derived( + view?.stack?.attackCard ? cardDef(view.stack.attackCard.cardId).name + : view?.stack?.sourceName ?? "punch", + ); const attackingCreature = $derived( view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null, ); @@ -2424,7 +2433,7 @@ {:else if view.stack.defenderId === view.you} {view.stack.attackerId} attacks you! {:else} - {view.stack.counters[view.stack.counters.length - 1]?.player} counters your spell! + {view.stack.counters[view.stack.counters.length - 1]?.player} counters your {yourAttackName}! {/if} {#if view.stack.defenderId === view.you && attackingCreature} @@ -2948,7 +2957,7 @@ : view.stack.sourceName ? `a ${view.stack.sourceName}` : "a punch"} — tap a counteraction card, or {:else} - {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""} + {view.stack.waitingOn === view.you ? `They counter your ${yourAttackName} — counter back, or` : ""} {/if} @@ -3450,16 +3459,17 @@ {#if castPanelOn && view}
{#if youMustRespond && view.stack} -
⚔ incoming
+ {@const defending = view.stack.defenderId === view.you} +
{defending ? "⚔ incoming" : "⚔ your attack, answered"}
{#if view.stack.attackCard}
(faqCardId = id)} />
{:else if view.stack.sourceName} {#if BLOW_CARD[view.stack.sourceName]}
(faqCardId = id)} />
{/if} -
{view.stack.sourceName} — it bites for {view.stack.params?.damage ?? 0}
+
{defending ? "" : "your "}{view.stack.sourceName} — it bites for {view.stack.params?.damage ?? 0}
{:else} -
a physical blow — no card behind it
+
{defending ? "a physical blow" : "your punch"} — no card behind it
{/if} {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 2922e7a..03d00c1 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -52,7 +52,11 @@ export function humanize(e: GameEvent): string | null { const at = e.target ? ` at ${e.target}` : ""; return `${e.caster} casts ${cardDef(e.cardId).name}${num}${amp}${at}.`; } - case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`; + case "counteractionPlayed": { + // A card's id has no spaces; a creature's blow or the ward's bite is named in words. + const what = e.against === "punch" ? "the punch" : /[ ']/.test(e.against) ? `the ${e.against}` : spellName(e.against); + return `${e.player} counters ${what} with ${cardDef(e.cardId).name}!`; + } case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`; case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand — the card is theirs now!`; case "attackResolved":