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:
co-authored by
Claude Fable 5.1
parent
80dd7a7865
commit
8582b7feb7
@@ -10,6 +10,7 @@
|
|||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
import Help from "./Help.svelte";
|
import Help from "./Help.svelte";
|
||||||
import type { HelpTab } from "./help-state";
|
import type { HelpTab } from "./help-state";
|
||||||
|
import { summarizeMissed } from "./catchup";
|
||||||
import Replay from "./Replay.svelte";
|
import Replay from "./Replay.svelte";
|
||||||
import FxGallery from "./FxGallery.svelte";
|
import FxGallery from "./FxGallery.svelte";
|
||||||
import TokenGallery from "./TokenGallery.svelte";
|
import TokenGallery from "./TokenGallery.svelte";
|
||||||
@@ -1476,6 +1477,26 @@
|
|||||||
return view ? view.treasures.filter((t) => t.owner !== p.id && t.position &&
|
return view ? view.treasures.filter((t) => t.owner !== p.id && t.position &&
|
||||||
t.position.x === p.home.x && t.position.y === p.home.y).length : 0;
|
t.position.x === p.home.x && t.position.y === p.home.y).length : 0;
|
||||||
}
|
}
|
||||||
|
/** What the chronicle shows: everything, the game's own lines, or the talk. */
|
||||||
|
let logFilter = $state<"all" | "game" | "talk">("all");
|
||||||
|
const isTalk = (text: string) => text.startsWith("\u{1F4AC}") || text.startsWith("\u{1F3B2}");
|
||||||
|
function showLine(line: { text: string }): boolean {
|
||||||
|
return logFilter === "all" ? true : logFilter === "talk" ? isTalk(line.text) : !isTalk(line.text);
|
||||||
|
}
|
||||||
|
/** Talk that arrived while the chronicle was covered or filtered away. */
|
||||||
|
let seenTalk = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
const count = net.chatCount;
|
||||||
|
if ((!castPanelOn && logFilter !== "game") || net.lastTalkBy === net.you) seenTalk = count;
|
||||||
|
});
|
||||||
|
const unreadTalk = $derived(Math.max(0, net.chatCount - seenTalk));
|
||||||
|
const lastTalk = $derived.by(() => {
|
||||||
|
for (let i = net.log.length - 1; i >= 0; i--) {
|
||||||
|
const t = net.log[i]!.text;
|
||||||
|
if (t.startsWith("\u{1F4AC}")) return t.slice(2).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
/** The table is out of reach: play waits, and nothing here can be sent. */
|
/** The table is out of reach: play waits, and nothing here can be sent. */
|
||||||
const offline = $derived(!local.active && net.roomId != null && net.status !== "connected");
|
const offline = $derived(!local.active && net.roomId != null && net.status !== "connected");
|
||||||
// Talk that never arrived goes back into the composer, with a word.
|
// Talk that never arrived goes back into the composer, with a word.
|
||||||
@@ -2354,7 +2375,7 @@
|
|||||||
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
|
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
|
||||||
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
|
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
|
||||||
{:else if net.catchUp && net.catchUp.length > 0}
|
{:else if net.catchUp && net.catchUp.length > 0}
|
||||||
<Replay steps={net.catchUp}
|
<Replay steps={net.catchUp} startAt={net.catchUpStart}
|
||||||
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
|
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
|
||||||
onclose={() => net.closeCatchUp()} />
|
onclose={() => net.closeCatchUp()} />
|
||||||
{:else if local.replaySteps && local.replaySteps.length > 0}
|
{:else if local.replaySteps && local.replaySteps.length > 0}
|
||||||
@@ -2983,10 +3004,25 @@
|
|||||||
<aside class="paper-rail">
|
<aside class="paper-rail">
|
||||||
{#if !local.active && net.missedMoves > 0}
|
{#if !local.active && net.missedMoves > 0}
|
||||||
<div class="slip catchup">
|
<div class="slip catchup">
|
||||||
|
<div class="catchup-head">
|
||||||
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
|
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
|
||||||
<button class="stamp tiny" onclick={() => net.requestCatchUp()}>▶ Watch what happened</button>
|
<button class="stamp tiny" onclick={() => net.watchCatchUp(0)}>▶ Watch what happened</button>
|
||||||
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
|
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if net.missedSteps && view.you}
|
||||||
|
<ul class="catchup-facts">
|
||||||
|
{#each summarizeMissed(net.missedSteps, view.you, view) as f, i (i)}
|
||||||
|
<li>
|
||||||
|
{#if f.step >= 0}
|
||||||
|
<button class="fact-jump" onclick={() => net.watchCatchUp(f.step)} title="watch this moment">▶ {f.text}</button>
|
||||||
|
{:else}
|
||||||
|
<span>{f.text}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if view.phase === "finished"}
|
{#if view.phase === "finished"}
|
||||||
<div class="slip winner">🏆 {view.winner} wins!
|
<div class="slip winner">🏆 {view.winner} wins!
|
||||||
@@ -3273,6 +3309,16 @@
|
|||||||
{#if youMustRespond}{@render tableGuidance()}{/if}
|
{#if youMustRespond}{@render tableGuidance()}{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="log-filters" class:tucked={castPanelOn} role="group" aria-label="what the chronicle shows">
|
||||||
|
{#each [["all", "All"], ["game", "Game"], ["talk", "Table talk"]] as [k, label] (k)}
|
||||||
|
<button class:current={logFilter === k} onclick={() => (logFilter = k as typeof logFilter)}>
|
||||||
|
{label}{#if k === "talk" && unreadTalk > 0}<span class="talk-badge">{unreadTalk}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if castPanelOn && !local.active && unreadTalk > 0 && lastTalk}
|
||||||
|
<div class="talk-peek">💬 {lastTalk}</div>
|
||||||
|
{/if}
|
||||||
<div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl}
|
<div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl}
|
||||||
onscroll={onChronicleScroll}>
|
onscroll={onChronicleScroll}>
|
||||||
{#snippet receipt(line: { text: string; receipt?: string[] })}
|
{#snippet receipt(line: { text: string; receipt?: string[] })}
|
||||||
@@ -3284,13 +3330,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#if local.active}
|
{#if local.active}
|
||||||
{#each local.log as line, i (i)}
|
{#each local.log.filter(showLine) as line, i (i)}
|
||||||
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
|
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
|
||||||
{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
|
{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each net.log as line, i (i)}
|
{#each net.log.filter(showLine) as line, i (i)}
|
||||||
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
|
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
|
||||||
<span class="log-mark" class:blank={!line.actor} style:background={line.actor ? playerColor(line.actor) : "transparent"}></span>{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
|
<span class="log-mark" class:blank={!line.actor} style:background={line.actor ? playerColor(line.actor) : "transparent"}></span>{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
|
||||||
{#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating}
|
{#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating}
|
||||||
@@ -3307,7 +3353,7 @@
|
|||||||
onclick={() => local.rollTableDie()}>🎲 roll the die</button>
|
onclick={() => local.rollTableDie()}>🎲 roll the die</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !local.active && net.roomId && !net.spectating && !castPanelOn}
|
{#if !local.active && net.roomId && !net.spectating}
|
||||||
<form class="say-box" onsubmit={(e) => {
|
<form class="say-box" onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const t = chatDraft.trim();
|
const t = chatDraft.trim();
|
||||||
@@ -4331,7 +4377,29 @@
|
|||||||
.slip.coach .stamp { margin-left: 0.5rem; }
|
.slip.coach .stamp { margin-left: 0.5rem; }
|
||||||
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
|
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
|
||||||
.slip.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
|
.slip.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
|
||||||
.slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
.slip.catchup { border-left: 4px solid #5b3f9e; }
|
||||||
|
.catchup-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.catchup-facts { list-style: none; margin: 0.45rem 0 0; padding: 0; font-size: 0.9rem; }
|
||||||
|
.catchup-facts li { margin: 0.15rem 0; }
|
||||||
|
.fact-jump { background: none; border: 0; padding: 0; font: inherit; color: #43331f; cursor: pointer; text-align: left; }
|
||||||
|
.fact-jump:hover { text-decoration: underline; }
|
||||||
|
.log-filters { display: flex; gap: 0.25rem; margin-bottom: 0.3rem; }
|
||||||
|
.log-filters.tucked { display: none; }
|
||||||
|
.log-filters button {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(233, 225, 203, 0.35);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #c9bd9f;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-filters button.current { border-color: #e9e1cb; color: #e9e1cb; background: rgba(233, 225, 203, 0.14); }
|
||||||
|
.talk-badge { margin-left: 0.3rem; background: #b3372b; color: #fff; border-radius: 999px; padding: 0 0.35rem; font-size: 0.6rem; }
|
||||||
|
.talk-peek { font-family: "Courier Prime", monospace; font-size: 0.82rem; color: #e9e1cb; background: rgba(233, 225, 203, 0.12); border-left: 3px solid #b3372b; padding: 0.3rem 0.5rem; border-radius: 3px; }
|
||||||
/* The table out of reach: one plain notice, and the controls beneath it go quiet. */
|
/* The table out of reach: one plain notice, and the controls beneath it go quiet. */
|
||||||
.slip.offline-slip {
|
.slip.offline-slip {
|
||||||
border-left: 4px solid #b3372b;
|
border-left: 4px solid #b3372b;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { motion } from "./motion";
|
import { motion } from "./motion";
|
||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
|
import { wizardColor } from "./colors";
|
||||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { humanize, spellName } from "./net.svelte";
|
import { humanize, spellName } from "./net.svelte";
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
pov = null,
|
pov = null,
|
||||||
onshare = null,
|
onshare = null,
|
||||||
endLabel = null,
|
endLabel = null,
|
||||||
|
startAt = 0,
|
||||||
}: {
|
}: {
|
||||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
@@ -32,6 +34,8 @@
|
|||||||
onshare?: (() => Promise<string>) | null;
|
onshare?: (() => Promise<string>) | null;
|
||||||
/** What the leave button says once the reel has run out. */
|
/** What the leave button says once the reel has run out. */
|
||||||
endLabel?: string | null;
|
endLabel?: string | null;
|
||||||
|
/** The step the reel opens on. */
|
||||||
|
startAt?: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/** The share button's little life: offer, mint, report. */
|
/** The share button's little life: offer, mint, report. */
|
||||||
@@ -49,7 +53,20 @@
|
|||||||
setTimeout(() => (shareState = "idle"), 4000);
|
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 playing = $state(true);
|
||||||
let speed = $state(1);
|
let speed = $state(1);
|
||||||
/** Watch the board from above, or relive it through your own eyes. */
|
/** Watch the board from above, or relive it through your own eyes. */
|
||||||
@@ -748,6 +765,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="replay-controls">
|
||||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
<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"}>
|
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
|
||||||
@@ -931,6 +957,29 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
|
.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 {
|
.replay-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -363,6 +363,8 @@ class Net {
|
|||||||
chatPending = $state<string | null>(null);
|
chatPending = $state<string | null>(null);
|
||||||
/** Table talk that never arrived: handed back to the composer. */
|
/** Table talk that never arrived: handed back to the composer. */
|
||||||
chatUnsent = $state<string | null>(null);
|
chatUnsent = $state<string | null>(null);
|
||||||
|
/** Who spoke last at the table: your own words are never unread. */
|
||||||
|
lastTalkBy = $state<string | null>(null);
|
||||||
/** A finished table's call for a rematch: where it went, and who called. */
|
/** A finished table's call for a rematch: where it went, and who called. */
|
||||||
rematchCall = $state<{ roomId: string; by: string } | null>(null);
|
rematchCall = $state<{ roomId: string; by: string } | null>(null);
|
||||||
/** A rematch lobby: the last table's wizards not yet seated. */
|
/** A rematch lobby: the last table's wizards not yet seated. */
|
||||||
@@ -393,6 +395,11 @@ class Net {
|
|||||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||||
/** A catch-up reel delivered by the server. */
|
/** A catch-up reel delivered by the server. */
|
||||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||||
|
/** The missed steps, fetched for the summary slip before any reel is opened. */
|
||||||
|
missedSteps = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||||
|
/** Where the catch-up reel opens: the step a summary line pointed at. */
|
||||||
|
catchUpStart = $state(0);
|
||||||
|
private catchUpWanted: "summary" | "reel" = "reel";
|
||||||
/** One turn's reel, summoned from a chronicle line's instant-replay
|
/** One turn's reel, summoned from a chronicle line's instant-replay
|
||||||
* eye — steps plus the wizard whose eyes the camera wears. */
|
* eye — steps plus the wizard whose eyes the camera wears. */
|
||||||
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
|
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
|
||||||
@@ -549,13 +556,17 @@ class Net {
|
|||||||
const last = this.seen[this.roomId] ?? 0;
|
const last = this.seen[this.roomId] ?? 0;
|
||||||
this.missedMoves = Math.max(0, msg.seq - last);
|
this.missedMoves = Math.max(0, msg.seq - last);
|
||||||
if (this.missedMoves === 0) this.markSeen();
|
if (this.missedMoves === 0) this.markSeen();
|
||||||
|
// The missed steps come now, for the facts on the slip; the
|
||||||
|
// reel waits for a tap.
|
||||||
|
else if (!this.missedSteps) this.requestCatchUp(true);
|
||||||
}
|
}
|
||||||
if (document.visibilityState === "visible") this.watching = this.roomId;
|
if (document.visibilityState === "visible") this.watching = this.roomId;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "catchUp":
|
case "catchUp":
|
||||||
this.catchUp = msg.steps;
|
if (this.catchUpWanted === "summary") this.missedSteps = msg.steps;
|
||||||
|
else this.catchUp = msg.steps;
|
||||||
break;
|
break;
|
||||||
case "moment":
|
case "moment":
|
||||||
this.moment = { steps: msg.steps, owner: msg.owner };
|
this.moment = { steps: msg.steps, owner: msg.owner };
|
||||||
@@ -569,7 +580,7 @@ class Net {
|
|||||||
let talk = 0;
|
let talk = 0;
|
||||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||||
for (const e of msg.events as GameEvent[]) {
|
for (const e of msg.events as GameEvent[]) {
|
||||||
if (e.type === "tableTalk") talk++;
|
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
|
||||||
if (e.type === "gameStarted" && !msg.replayed) {
|
if (e.type === "gameStarted" && !msg.replayed) {
|
||||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||||
}
|
}
|
||||||
@@ -627,6 +638,7 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
case "chat": {
|
case "chat": {
|
||||||
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
|
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
|
||||||
|
this.lastTalkBy = msg.player;
|
||||||
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
|
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
|
||||||
this.chatCount += 1;
|
this.chatCount += 1;
|
||||||
if (this.roomId) this.markChatSeen();
|
if (this.roomId) this.markChatSeen();
|
||||||
@@ -866,6 +878,8 @@ class Net {
|
|||||||
* line, and wiping it would erase the only notice of why. */
|
* line, and wiping it would erase the only notice of why. */
|
||||||
leaveLocal(): void {
|
leaveLocal(): void {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
|
this.missedSteps = null;
|
||||||
|
this.catchUp = null;
|
||||||
this.pending = null;
|
this.pending = null;
|
||||||
this.unsent = null;
|
this.unsent = null;
|
||||||
this.unconfirmed = null;
|
this.unconfirmed = null;
|
||||||
@@ -904,18 +918,27 @@ class Net {
|
|||||||
this.send({ type: "catchUp", sinceSeq: 0, full: true });
|
this.send({ type: "catchUp", sinceSeq: 0, full: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ask for the reel of everything since we last watched. */
|
/** Ask for the steps since we last watched: for the summary slip, or for the reel. */
|
||||||
requestCatchUp(): void {
|
requestCatchUp(summaryOnly = false): void {
|
||||||
if (!this.roomId) return;
|
if (!this.roomId) return;
|
||||||
|
this.catchUpWanted = summaryOnly ? "summary" : "reel";
|
||||||
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
|
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the reel of the missed steps, at the step a fact pointed to. */
|
||||||
|
watchCatchUp(startAt = 0): void {
|
||||||
|
this.catchUpStart = Math.max(0, startAt);
|
||||||
|
if (this.missedSteps) this.catchUp = this.missedSteps;
|
||||||
|
else this.requestCatchUp(false);
|
||||||
|
}
|
||||||
|
|
||||||
/** All caught up: remember it and clear the banner. */
|
/** All caught up: remember it and clear the banner. */
|
||||||
markSeen(): void {
|
markSeen(): void {
|
||||||
if (!this.roomId) return;
|
if (!this.roomId) return;
|
||||||
this.seen[this.roomId] = this.currentSeq;
|
this.seen[this.roomId] = this.currentSeq;
|
||||||
localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen));
|
localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen));
|
||||||
this.missedMoves = 0;
|
this.missedMoves = 0;
|
||||||
|
this.missedSteps = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeCatchUp(): void {
|
closeCatchUp(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user