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