What you missed, in facts; a timeline on the reel; room for table talk

A returning player's slip now lists what happened while they were away
— "Automaton took your treasure.", "You lost 3 life.", "It's your
turn." — each fact opening the catch-up reel at its moment, read from
the missed steps as the server redacted them for that seat; when
nothing touched them it says what each wizard was up to instead. The
reel gains a timeline: a tick per move in the mover's color, taller
where a turn begins, a blow lands, or gold changes hands, and any tick
jumps the reel there.

The chronicle takes a filter — All, Game, Table talk — with an unread
count on the talk that arrived while the casting panel covered it (a
wizard's own words never count), a peek at the latest line above the
composer while the panel is up, and the composer itself no longer
leaves when a response is owed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-17 00:22:54 -04:00
co-authored by Claude Fable 5.1
parent 80dd7a7865
commit 8582b7feb7
4 changed files with 262 additions and 13 deletions
+109
View File
@@ -0,0 +1,109 @@
// What happened while you were away, as a few facts before the reel:
// read from the missed steps as the server redacted them for you, so it
// tells you only what you would have seen at the table.
import { cardDef, type GameEvent, type GameView } from "@wizwar/engine";
export interface MissedStep {
seq: number;
actor: string;
events: GameEvent[];
view: GameView;
chat?: { player: string; text: string }[];
}
export interface MissedFact {
text: string;
/** The step of the reel this fact belongs to. */
step: number;
}
function spell(id: string | null): string {
if (!id) return "a punch";
try { return cardDef(id).name; } catch { return id; }
}
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
const me = view.players.find((p) => p.id === you);
if (!me) return false;
return Math.abs(me.home.x - cell.x) + Math.abs(me.home.y - cell.y) <= 2;
}
/** The facts of the missed steps that touch you, in order, capped so the
* slip stays a slip. Each fact points at its step for the reel. */
export function summarizeMissed(steps: readonly MissedStep[], you: string, now: GameView | null): MissedFact[] {
const facts: MissedFact[] = [];
let lifeLost = 0;
let lifeLostStep = -1;
let talk = 0;
steps.forEach((s, i) => {
talk += s.chat?.length ?? 0;
for (const e of s.events) {
switch (e.type) {
case "treasurePickedUp":
if (e.owner === you && e.player !== you) facts.push({ text: `${e.player} took your treasure.`, step: i });
else if (e.player !== you && e.owner !== e.player) facts.push({ text: `${e.player} picked up ${e.owner}'s treasure.`, step: i });
break;
case "treasureDropped": {
const owner = s.view.treasures.find((t) => t.id === e.treasureId)?.owner;
if (e.onHomeOf != null && owner && e.onHomeOf !== owner) {
facts.push({ text: owner === you ? `Your treasure was carried home by ${e.player}.` : `${e.player} carried ${owner}'s treasure home.`, step: i });
}
break;
}
case "damaged":
if (e.player === you && e.amount > 0) { lifeLost += e.amount; if (lifeLostStep < 0) lifeLostStep = i; }
break;
case "spellCast":
if (e.target === you) facts.push({ text: `${e.caster} cast ${spell(e.cardId)} at you.`, step: i });
break;
case "punched":
if (e.target === you) facts.push({ text: `${e.attacker} punched you.`, step: i });
break;
case "wallDestroyed":
if (nearHome(s.view, you, e.edge.cell)) facts.push({ text: "A wall near your home was destroyed.", step: i });
break;
case "playerEliminated":
facts.push({ text: e.player === you ? "You were eliminated." : `${e.player} is out of the game.`, step: i });
break;
case "gameWon":
facts.push({ text: e.player === you ? "You won." : `${e.player} won the game.`, step: i });
break;
case "tableTalk":
talk++;
break;
default:
break;
}
}
});
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
if (facts.length === 0) {
// Nothing touched you: say what each wizard was up to, all the same.
const doings = new Map<string, { moves: number; casts: number; first: number }>();
steps.forEach((s, i) => {
if (s.actor === you) return;
const d = doings.get(s.actor) ?? { moves: 0, casts: 0, first: i };
for (const e of s.events) {
if (e.type === "moved" && e.player === s.actor) d.moves++;
if (e.type === "spellCast" && e.caster === s.actor) d.casts++;
}
doings.set(s.actor, d);
});
for (const [who, d] of doings) {
const bits = [d.moves > 0 ? `walked ${d.moves} square${d.moves === 1 ? "" : "s"}` : "", d.casts > 0 ? `cast ${d.casts} spell${d.casts === 1 ? "" : "s"}` : ""].filter(Boolean);
facts.push({ text: `${who} ${bits.length ? bits.join(" and ") : "passed the time"}; nothing touched you.`, step: d.first });
}
}
if (talk > 0) facts.push({ text: `${talk} line${talk === 1 ? "" : "s"} of table talk.`, step: -1 });
const out = facts.slice(0, 6);
if (facts.length > 6) out.push({ text: `…and ${facts.length - 6} more.`, step: -1 });
if (now) {
const me = now.players.find((p) => p.id === you);
if (now.phase === "playing" && me?.alive) {
const mine = now.activePlayerId === you || now.stack?.waitingOn === you || now.pendingDiscard === you;
out.push({ text: mine ? "It's your turn." : `It's ${now.activePlayerId}'s turn.`, step: -1 });
}
}
return out;
}