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":