Files
wizwar6e/packages/web/src/receipt.ts
T
Eric WagonerandClaude Fable 5.1 66b5c06f17 Credibility pass: one teleport step, one bell, one step type, and the comments that argued are gone
Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-22 09:56:49 -04:00

104 lines
4.7 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 { isPermanentDuration, type GameEvent } from "@wizwar/engine";
import { cardName } from "./reference";
export interface Receipt {
title: string;
lines: string[];
}
function nameOf(id: string | null): string {
return id ? cardName(id) : "The punch";
}
/** 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"
? (isPermanentDuration(tookHold.turns) ? `${card} takes hold of ${e.defender} for good` : `${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 };
}