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
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-16 23:32:34 -04:00
1 parent 7d0c1c68a3
commit 648f6ab8cd
7 files changed
+225 -27

No files matched your search

+17 -12
View File
@@ -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<PlayerId | null>(null);
/** Set while the device should be handed to the named player. */
handoffTo = $state<PlayerId | null>(null);
log = $state<string[]>([]);
log = $state<LogLine[]>([]);
/** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; 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") {