// 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 }; }