From 648f6ab8cd749decad09233f878f5dc38b138b58 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 16 Sep 2026 23:32:34 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 32 +++++- packages/engine/src/view.ts | 17 ++- .../engine/test/expansion-terrain.test.ts | 16 +++ packages/web/src/App.svelte | 49 +++++++-- packages/web/src/local.svelte.ts | 29 ++--- packages/web/src/net.svelte.ts | 7 ++ packages/web/src/receipt.ts | 102 ++++++++++++++++++ 7 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 packages/web/src/receipt.ts diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 382e3d9..0cb8e01 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -661,7 +661,10 @@ export type GameEvent = | { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance } | { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance } | { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" } - | { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean } + | { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean; + /** The blow before any counter, and what each counter left of it, in + * the order they were weighed — the receipt the table reads. */ + incoming?: number; incomingDuration?: number; trail?: CounterStep[] } | { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number; soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] } | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" } @@ -946,6 +949,17 @@ interface ResolutionContext { stack: CastStack; } +/** One counter's mark on the blow: the damage, the half sent back, and + * the duration left after it was weighed (unchanged when nullified). */ +export interface CounterStep { + cardId: string; + player: PlayerId; + nullified: boolean; + damage: number; + reflected: number; + duration: number; +} + interface DamagePipeline { damage: number; duration: number; @@ -6304,6 +6318,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void { // Whether this attack even tries to wound: utility attacks (DROP OBJECT, // TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped. const dealsDamage = base > 0; + const trail: CounterStep[] = []; + const receipt = { incoming: base, incomingDuration: baseDuration, trail }; const pipe: DamagePipeline = { damage: base, duration: baseDuration, @@ -6322,18 +6338,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } } for (const counter of stack.counters) { + trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration }); + const mark = trail[trail.length - 1]!; + const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; }; if (counter.nullified) continue; if (isNumberCard(counter.card.cardId)) { // SHIELDSTONE number counter: reduce point AND duration effects. const v = numberValue(counter.card.cardId); pipe.damage = Math.max(0, pipe.damage - v); pipe.duration = Math.max(0, pipe.duration - v); + weigh(); continue; } if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") { // Fire meets water, whichever was thrown first: entirely stopped. pipe.damage = 0; pipe.fullyStopped = true; + weigh(); continue; } if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") { @@ -6343,6 +6364,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { pipe.damage = 0; pipe.duration = 0; pipe.fullyStopped = true; + weigh(); continue; } if (stack.creatureId && @@ -6355,10 +6377,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } else { pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage) } + weigh(); continue; } const ce = CARD_EFFECTS[counter.card.cardId]; if (ce && ce.kind === "counter") ce.apply(pipe); + weigh(); } // A surviving teleport counter whisks the defender away before anything lands. @@ -6390,7 +6414,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { }); } events.push({ - type: "attackResolved", + type: "attackResolved", ...receipt, attacker: attacker.id, defender: defender.id, attackCardId: attackId, @@ -6422,7 +6446,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { reflectedBase: { damage: pipe.damage, duration: pipe.duration }, }; events.push({ - type: "attackResolved", + type: "attackResolved", ...receipt, attacker: attacker.id, defender: defender.id, attackCardId: attackId, @@ -6512,7 +6536,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } events.push({ - type: "attackResolved", + type: "attackResolved", ...receipt, attacker: attacker.id, defender: defender.id, attackCardId: attackId, diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index c2fc9c9..91b4c14 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -2,7 +2,7 @@ // The server sends this after every state change; clients never see the // deck order or other players' hands. -import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; +import { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; import { cardDef, type CardInstance } from "./cards"; import { boardView, @@ -516,7 +516,9 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa if (cardId === "teleport") { // Up to four spaces, walls and objects ignored; not into solid stone. - // BFS over existing cells, matching the engine's wallIgnoringDistance. + // BFS over existing cells, matching the engine's wallIgnoringDistance — + // the maze wraps for teleporters as it does for walkers, a warp mouth + // being one step like any doorway. const out = new Set(); const dist = new Map([[key(me.position.x, me.position.y), 0]]); const queue = [me.position]; @@ -524,10 +526,15 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa const cur = queue.shift()!; const d = dist.get(key(cur.x, cur.y))!; if (d >= 4) continue; - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { - const n = { x: cur.x + dx, y: cur.y + dy }; + for (const side of SIDES) { + let n = neighbor(cur, side); + if (!view.board.cells[key(n.x, n.y)]) { + const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side); + if (!w) continue; + n = w.to.cell; + } const nk = key(n.x, n.y); - if (!view.board.cells[nk] || dist.has(nk)) continue; + if (dist.has(nk)) continue; dist.set(nk, d + 1); queue.push(n); if (view.squareContents[nk]?.kind !== "stone") out.add(nk); diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 5bfa8a0..033947a 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -770,3 +770,19 @@ describe("attacks aimed at a bush or the ooze", () => { if (!refused.ok) expect(refused.error).toMatch(/beside/); }); }); + +describe("teleport's reach wraps through the board's openings", () => { + it("offers the square beyond a warp mouth, as the engine allows it", () => { + const state = toRound2(newGame().state); + const me = activePlayer(state); + const view = boardView(state); + const warp = view.warps[0]!; + me.position = { ...warp.from.cell }; + giveCard(state, me.id, "teleport"); + const cells = eligibleCellsFor(viewFor(state, me.id), "teleport")!; + const beyond = cellKey(warp.to.cell); + expect(cells.has(beyond)).toBe(true); + const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: warp.to.cell } }); + expect(r.ok).toBe(true); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 391316a..346406c 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1653,7 +1653,7 @@ /** The attack on the stack draws its sight line; a cast that resolved * at once (a creation, a curse) draws the line it was accepted on for a * few seconds, so the table can see how the aim was legal. */ - let castTrace = $state(null); + let castTrace = $state.raw(null); let castTraceTimer: ReturnType | null = null; const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace); @@ -3230,14 +3230,24 @@ {/if}
+ {#snippet receipt(line: { text: string; receipt?: string[] })} + {#if line.receipt} +
+ ⚖ {line.text} +
    {#each line.receipt as r, j (j)}
  • {r}
  • {/each}
+
+ {/if} + {/snippet} {#if local.active} {#each local.log as line, i (i)} -
{line}
+
+ {#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if} +
{/each} {:else} {#each net.log as line, i (i)}
- {line.text} + {#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if} {#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating} @@ -3689,14 +3699,22 @@ } .count-btn.current { background: #43331f; color: #e9e1cb; } + /* The toast floats over the page: a refusal that came and went must + * never push the table down and back. */ .toast { + position: fixed; + top: 3.6rem; + left: 50%; + transform: translateX(-50%); + z-index: 70; + max-width: min(40rem, calc(100% - 2rem)); background: #6d2119; color: #f2e6d8; border: 1px solid #96382c; padding: 0.5rem 0.85rem; border-radius: 4px; - margin-bottom: 0.7rem; font-size: 0.95rem; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); } .boxlid { @@ -4246,7 +4264,19 @@ } .offline-note { font-style: italic; color: #6b3a2f; } .game.offline .hand, .game.offline .board-zone, .game.offline .actions .stamp { pointer-events: none; opacity: 0.55; } - .sending { font-family: "Courier Prime", monospace; font-size: 0.78rem; color: #6b5a41; font-style: italic; } + .sending { + font-family: "Courier Prime", monospace; + font-size: 0.78rem; + color: #6b5a41; + font-style: italic; + /* Floats above its row: a note that came and went must never move the board. */ + position: absolute; + right: 0; + top: -1.2rem; + white-space: nowrap; + pointer-events: none; + } + .say-box .sending { position: static; } .sending.ok { color: #3f6b2f; font-style: normal; } .rematch-call { font-size: 0.9rem; } .seat.ghost .seat-name { font-style: italic; font-weight: 400; } @@ -4361,6 +4391,13 @@ box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45); } .chronicle div + div { margin-top: 0.05rem; } + /* A resolved attack's account, folded under a one-line summary. */ + .receipt { display: inline; } + .receipt summary { display: inline; cursor: pointer; color: #6b5a41; font-style: italic; } + .receipt summary:hover { color: #43331f; } + .receipt ul { margin: 0.15rem 0 0.3rem 1.1rem; padding: 0; list-style: none; } + .receipt li { font-size: 0.86rem; color: #43331f; margin: 0.05rem 0; } + .receipt li::before { content: "› "; color: #8a6d3f; } /* the acting wizard's color, so one player's lines can be picked out at a glance */ .log-mark { display: inline-block; @@ -4424,7 +4461,7 @@ color: #43331f; } - .actions { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; } + .actions { position: relative; display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; } .draw-pick { color: #a49c86; font-size: 0.9rem; } .attack-scrim { diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 03da767..39fa0e8 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -16,7 +16,8 @@ import { type GameEvent, redactEvent, } from "@wizwar/engine"; -import { humanize, net } from "./net.svelte"; +import { type LogLine, humanize, net } from "./net.svelte"; +import { receiptFor } from "./receipt"; const SAVE_KEY = "wizwar-hotseat"; @@ -47,7 +48,7 @@ class LocalGame { viewerId = $state(null); /** Set while the device should be handed to the named player. */ handoffTo = $state(null); - log = $state([]); + log = $state([]); /** The opening roll-off, shown once as the boards flip. */ openingRolls = $state<{ rolls: Record; first: string; players: string[] } | null>(null); /** Board flourishes: the app hooks in to animate command results. */ @@ -64,11 +65,19 @@ class LocalGame { private activeMs = 0; private lastMoveAt = 0; + /** A line of the chronicle for an event, and the receipt under a resolved attack. */ + private chronicle(e: GameEvent, batch: readonly GameEvent[]): void { + const line = humanize(e); + if (line) this.log = [...this.log, { text: line, turn: null, notable: false }]; + const receipt = receiptFor(batch, e); + if (receipt) this.log = [...this.log, { text: receipt.title, turn: null, notable: false, receipt: receipt.lines }]; + } + /** The tabletop D4 for house calls: chronicle only, no game state. */ rollTableDie(): void { if (!this.viewerId) return; const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4); - this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`]; + this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`, turn: null, notable: false }]; } /** Rebuild the whole game as replay steps (finished games only). */ @@ -148,8 +157,7 @@ class LocalGame { this.gameState = state; this.log = []; for (const e of events) { - const line = humanize(e); - if (line) this.log = [...this.log, line]; + this.chronicle(e, events); } this.active = true; this.viewerId = null; @@ -174,16 +182,14 @@ class LocalGame { let current = state; this.log = []; for (const e of events) { - const line = humanize(e); - if (line) this.log = [...this.log, line]; + this.chronicle(e, events); } for (const c of saved.commands) { const result = applyCommand(current, c.playerId, c.command); if (!result.ok) throw new Error(`replay failed: ${result.error}`); current = result.state; for (const e of result.events) { - const line = humanize(e); - if (line) this.log = [...this.log, line]; + this.chronicle(e, result.events); } } this.config = saved.config; @@ -218,7 +224,7 @@ class LocalGame { const plain = $state.snapshot(this.gameState) as GameState; const result = applyCommand(plain, this.viewerId, command); if (!result.ok) { - this.log = [...this.log, `— ${result.error} —`]; + this.log = [...this.log, { text: `— ${result.error} —`, turn: null, notable: false }]; return; } this.onFx?.(result.events); @@ -237,8 +243,7 @@ class LocalGame { }); } for (const e of result.events) { - const line = humanize(e); - if (line) this.log = [...this.log, line]; + this.chronicle(e, result.events); } this.persist(); if (this.gameState.phase === "playing") { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index ad69111..67cdf15 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -2,6 +2,7 @@ import type { Command, GameEvent, GameView, Side } from "@wizwar/engine"; import { cardDef } from "@wizwar/engine"; +import { receiptFor } from "./receipt"; const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? @@ -242,6 +243,8 @@ export interface LogLine { /** The wizard acting, for the color mark beside the line: whoever * cast, struck, walked, or countered — otherwise the turn's owner. */ actor?: string; + /** A resolved attack's account, folded under its line. */ + receipt?: string[]; } /** Events whose `player` is the one acting rather than the one acted on. */ @@ -578,6 +581,10 @@ class Net { (e.type === "gameWon" || TURN_BOUNDARY.has(e.type)); this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable, actor: actorOf(e, this.turnOwner) }]; } + const receipt = receiptFor(msg.events as GameEvent[], e); + if (receipt) { + this.log = [...this.log, { text: receipt.title, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable: false, actor: actorOf(e, this.turnOwner), receipt: receipt.lines }]; + } } if (talk > 0) { this.chatCount += talk; diff --git a/packages/web/src/receipt.ts b/packages/web/src/receipt.ts new file mode 100644 index 0000000..8c5ed42 --- /dev/null +++ b/packages/web/src/receipt.ts @@ -0,0 +1,102 @@ +// 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 }; +}