Files
wizwar6e/packages/web/src/receipt.ts
T
Eric WagonerandClaude Fable 5.1 648f6ab8cd Receipts under resolved attacks; the cast line clears; the table holds still; teleport wraps
Each resolved attack now folds a receipt under its line, read off the
engine's own record: the blow as it came in, what each counter left of
it in the order they were weighed, what landed, and whose life moved.
The resolution event carries the incoming damage and duration and the
trail of counters for it. Hotseat chronicles carry the same.

The line a cast was accepted on stayed on the board: its timer compared
a reactive proxy to the object it held. Kept raw, it clears.

The board jumped while walking. The "sending" note added a line beside
End turn and took it away again, and the refusal toast stood in the
flow above the table for five seconds; both now float and move
nothing.

Teleport reaches through the lettered openings in the engine, but the
board dimmed the squares beyond them. The eligibility map follows the
warps as the engine does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 23:32:34 -04:00

103 lines
4.6 KiB
TypeScript

// The receipt under a resolved attack: the blow as it came in, what each
// counter left of it, what landed, and whose life moved — read off the
// engine's own events, so it explains what happened rather than what the
// cards promise.
import { cardDef, type GameEvent } from "@wizwar/engine";
export interface Receipt {
title: string;
lines: string[];
}
function nameOf(id: string | null): string {
if (!id) return "The punch";
try { return cardDef(id).name; } catch { return id; }
}
/** The receipt for a resolution event, or null when there is nothing to
* account for. `batch` is the event list the resolution arrived in: the
* damage and life bookkeeping of the same exchange sits beside it. */
export function receiptFor(batch: readonly GameEvent[], e: GameEvent): Receipt | null {
if (e.type === "attackMissed") {
const why =
e.because === "invisible" ? `${e.defender} is invisible; the die sent it astray.`
: e.because === "shrink" ? `${e.defender} is shrunk; the die said miss.`
: `${e.defender} outran it.`;
return { title: `${nameOf(e.attackCardId)} misses ${e.defender}`, lines: [why] };
}
if (e.type !== "attackResolved") return null;
const at = batch.indexOf(e);
let start = 0;
for (let i = at - 1; i >= 0; i--) {
if (batch[i]!.type === "attackResolved") { start = i + 1; break; }
}
const exchange = batch.slice(start, at + 1);
const returned = exchange.some((x) => x.type === "damaged" && /\(reflected\)/.test(x.source));
const lines: string[] = [];
const healed = exchange.find((x) => x.type === "lifeGained" && /\(reversed\)/.test(x.source));
const tookHold = exchange.find((x) => x.type === "spellSustained");
if (e.incoming != null) {
const card = nameOf(e.attackCardId);
const dur0 = e.incomingDuration ?? 0;
const parts = [
e.incoming > 0 ? `${e.incoming} incoming`
: dur0 > 0 ? `${dur0} turn${dur0 === 1 ? "" : "s"} of ${card} incoming`
: `${card} incoming`,
];
let damage = e.incoming;
let duration = dur0;
let reflected = 0;
for (const t of e.trail ?? []) {
const c = nameOf(t.cardId);
if (t.nullified) { parts.push(`${t.player}'s ${c} is nullified`); continue; }
const off = damage - t.damage;
const back = t.reflected - reflected;
const turnsOff = duration - t.duration;
let verb: string;
if (t.cardId === "full-reflection") verb = "turns it around";
else if (t.cardId === "reverse") verb = healed ? "turns it into healing" : "reverses it";
else if (t.cardId === "empathy") verb = "shares it with the caster";
else if (t.cardId === "anti-anti") verb = "cancels the counter before it";
else if (back > 0) verb = t.damage > 0 ? `sends ${back} back and lets ${t.damage} through` : `sends ${back} back`;
else if ((t.damage === 0 && damage > 0) || (t.duration === 0 && duration > 0 && damage === 0)) verb = "stops it cold";
else if (off > 0) verb = `takes ${off} off`;
else if (turnsOff > 0) verb = `takes ${turnsOff} turn${turnsOff === 1 ? "" : "s"} off`;
else verb = "changes nothing";
parts.push(`${t.player}'s ${c} ${verb}`);
damage = t.damage;
duration = t.duration;
reflected = t.reflected;
}
parts.push(
e.redirected ? `the whole blow turns back on ${e.attacker}`
: e.fullyStopped ? "nothing lands"
: healed && healed.type === "lifeGained" ? `${healed.amount} heals ${e.defender} instead`
: e.damageDealt > 0 ? `${e.damageDealt} lands on ${e.defender}`
: tookHold && tookHold.type === "spellSustained" ? `${card} takes hold of ${e.defender} for ${tookHold.turns} turn${tookHold.turns === 1 ? "" : "s"}`
: "nothing to land",
);
lines.push(parts.join(" → "));
} else if (e.redirected) {
lines.push(`the whole blow turns back on ${e.attacker}`);
} else if (e.fullyStopped) {
lines.push("nothing lands");
}
for (const x of exchange) {
if (x.type === "damaged" && x.amount > 0) {
const soak = x.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
lines.push(`${x.player}: ${x.lifeAfter + x.amount}${x.lifeAfter} life${soak ? ` (${soak})` : ""}`);
} else if (x.type === "damageImmune") {
lines.push(`${x.player} takes nothing — ${x.because}`);
} else if (x.type === "lifeGained") {
lines.push(`${x.player}: ${x.lifeAfter - x.amount}${x.lifeAfter} life, reversed`);
} else if (x.type === "stonesDestroyed") {
lines.push(`${x.player}'s magic stones are destroyed`);
}
}
if (lines.length === 0) return null;
return { title: `${nameOf(e.attackCardId)}${returned ? ", returned," : ""} resolved`, lines };
}