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
+50 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { motion } from "./motion";
import Board from "./Board.svelte";
import { wizardColor } from "./colors";
import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte";
import { humanize, spellName } from "./net.svelte";
@@ -19,6 +20,7 @@
pov = null,
onshare = null,
endLabel = null,
startAt = 0,
}: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
onclose: () => void;
@@ -32,6 +34,8 @@
onshare?: (() => Promise<string>) | null;
/** What the leave button says once the reel has run out. */
endLabel?: string | null;
/** The step the reel opens on. */
startAt?: number;
} = $props();
/** The share button's little life: offer, mint, report. */
@@ -49,7 +53,20 @@
setTimeout(() => (shareState = "idle"), 4000);
}
let idx = $state(0);
let idx = $state(untrack(() => Math.min(Math.max(0, startAt), Math.max(0, steps.length - 1))));
/** The timeline: one tick a step, a turn's first step marked, and the
* steps where blows landed or gold changed hands flagged, so a reel of
* a hundred moves can be read at a glance and jumped into. */
const ticks = $derived(steps.map((s, i) => {
let flag: "turn" | "hit" | "gold" | "end" | null = null;
for (const e of s.events) {
if (e.type === "gameWon" || e.type === "playerEliminated") { flag = "end"; break; }
if (e.type === "treasurePickedUp" || (e.type === "treasureDropped" && e.onHomeOf != null)) flag = "gold";
else if (!flag && (e.type === "damaged" || e.type === "attackResolved" || e.type === "punched")) flag = "hit";
else if (!flag && e.type === "turnStarted") flag = "turn";
}
return { i, actor: s.actor, flag, color: wizardColor(s.view, s.actor) };
}));
let playing = $state(true);
let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */
@@ -748,6 +765,15 @@
</div>
{/if}
</div>
{#if steps.length > 3}
<div class="timeline" role="group" aria-label="the moves, a tick each">
{#each ticks as t (t.i)}
<button class="tick" class:current={t.i === idx} class:turn={t.flag === "turn"} class:hit={t.flag === "hit"} class:gold={t.flag === "gold"} class:end={t.flag === "end"}
style:--who={t.color} title={`move ${t.i + 1} — ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
{/each}
</div>
{/if}
<div class="replay-controls">
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
@@ -931,6 +957,29 @@
opacity: 0.7;
}
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
.timeline {
display: flex;
align-items: flex-end;
gap: 1px;
height: 14px;
margin: 0.4rem 0.2rem 0.2rem;
}
.tick {
flex: 1 1 0;
min-width: 2px;
height: 6px;
padding: 0;
border: 0;
border-radius: 1px;
background: var(--who, #8d8672);
opacity: 0.45;
cursor: pointer;
}
.tick.turn { height: 9px; opacity: 0.7; }
.tick.hit { height: 12px; opacity: 0.9; }
.tick.gold { height: 14px; opacity: 1; box-shadow: 0 0 0 1px #e0b34a; }
.tick.end { height: 14px; opacity: 1; background: #e9e1cb; }
.tick.current { opacity: 1; outline: 1px solid #fff; outline-offset: 1px; }
.replay-controls {
display: flex;
justify-content: center;