Credibility pass: one teleport step, one bell, one step type, and the comments that argued are gone

Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.

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-22 09:56:49 -04:00
co-authored by Claude Fable 5.1
parent c4a555f8df
commit 66b5c06f17
21 changed files with 245 additions and 239 deletions
+37 -39
View File
@@ -9,7 +9,7 @@
import Faq from "./Faq.svelte";
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import type { HelpTab } from "./help-state";
import { HELP_TABS, type HelpTab } from "./help-state";
import { summarizeMissed } from "./catchup";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
@@ -299,7 +299,7 @@
const q = new URLSearchParams(location.search);
const card = q.get("card");
const help = q.get("help");
const tabs: HelpTab[] = ["play", "rules", "cards", "rulings", "about", "tally"];
const tabs: readonly HelpTab[] = HELP_TABS;
if (card) { helpCard = card; showHelp = true; }
else if (help) {
const [t, ...rest] = help.split("/");
@@ -1720,9 +1720,6 @@
cellKey(p.position) === cellKey(me!.position))
: [],
);
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
// While an LOS attack sits on the stack, draw the line it traveled — the
// answer to "how can he even see me?" when sight ran through a warp mouth.
/** 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. */
@@ -1839,6 +1836,26 @@
}
</script>
{#snippet sayBox(extra: string, withDie: boolean)}
<form class="say-box {extra}" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (!t) return;
if (net.sendChat(t)) chatDraft = "";
else net.flash("Not sent — the table is out of reach; your words wait in the box");
}}>
{#if net.chatPending}<span class="sending">sending…</span>{/if}
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
{#if withDie}
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => net.rollTableDie()}>🎲</button>
{/if}
</form>
{/snippet}
<svelte:window onkeydown={onBoardKey} />
{#if fpvWorkshop || tokenWorkshop || fxWorkshop}
@@ -2581,23 +2598,24 @@
{#if net.feedbackReports.length > 0}
<div class="reports-head">your reports to the wizards</div>
{#each net.feedbackReports as r (r.id)}
{@const thread = r.thread ?? []}
<div class="report-row">
<span class="ledger-code">{r.roomId}</span>
<div class="report-body">
<div class="report-text">“{r.happened}”{#if r.image}<span class="report-pic" title="with your screenshot"> 📷</span>{/if}</div>
{#each r.thread ?? [] as line, j (j)}
{#each thread as line, j (j)}
{#if line.from === "player"}
<div class="report-reply yours">you — {line.text}</div>
{:else}
<div class="report-reply"><span class="report-status">{line.status}</span> — {line.text}</div>
{/if}
{/each}
{#if !(r.thread ?? []).length}
{#if !thread.length}
<div class="report-reply pending">the wizards are studying the moment…</div>
{:else if (r.thread ?? [])[(r.thread ?? []).length - 1]!.from === "player"}
{:else if thread[thread.length - 1]!.from === "player"}
<div class="report-reply pending">your word is with the wizards…</div>
{/if}
{#if (r.thread ?? []).some((l) => l.from === "desk")}
{#if thread.some((l) => l.from === "desk")}
<form class="report-answer" onsubmit={(e) => {
e.preventDefault();
const t = (reportAnswers[r.id] ?? "").trim();
@@ -2715,7 +2733,7 @@
<summary>the link itself, for pasting by hand</summary>
<code class="invite-link">{inviteLink}</code>
</details>
{#if !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6}
{#if net.keeper && !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6}
<div class="challenge-row">
<button class="stamp quiet-lid" onclick={() => net.challengeKeeper()}
title="a raven flies to {net.keeper}, who keeps this table">
@@ -2735,16 +2753,7 @@
{#each lobbyTalk as line, i (i)}
<div class="lobby-line">{line.text}</div>
{/each}
<form class="say-box lobby-say" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (!t) return;
if (net.sendChat(t)) chatDraft = "";
else net.flash("Not sent — the table is out of reach; your words wait in the box");
}}>
<input class="say-input" bind:value={chatDraft} maxlength="300" placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
</form>
{@render sayBox("lobby-say", false)}
</div>
{#if net.you === net.hostId && addingBot && net.players.length < 6}
<div class="bot-panel">
@@ -3283,6 +3292,7 @@
<div class="scoresheet">
<div class="scoresheet-head">life-points</div>
{#each view.players as p (p.id)}
{@const home = treasuresHomeOf(p)}
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
<span class="dot" style:background={playerColor(p.id)}></span>
<span class="score-name">
@@ -3291,7 +3301,7 @@
</span>
<span class="score-life">{p.life}</span>
<span class="score-marks">
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if treasuresHomeOf(p) > 0}&nbsp;· <span class="score-home" class:threat={treasuresHomeOf(p) === 1 && !!p.carriedTreasureId} title={treasuresHomeOf(p) === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {treasuresHomeOf(p)} home</span>{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`}&nbsp;·
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if home > 0}&nbsp;· <span class="score-home" class:threat={home === 1 && !!p.carriedTreasureId} title={home === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {home} home</span>{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`}&nbsp;·
<button class="score-chest-btn" aria-label="see the carried treasure"
onclick={() => (peekTreasure = { src: chestSrc, note: `${ct?.owner ?? "someone"}'s treasure — in ${p.id}'s arms` })}>
<img class="score-chest" alt="" src={chestSrc} /></button>{/if}
@@ -3420,20 +3430,7 @@
</div>
{/if}
{#if !local.active && net.roomId && !net.spectating}
<form class="say-box" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (!t) return;
if (net.sendChat(t)) chatDraft = "";
else net.flash("Not sent — the table is out of reach; your words wait in the box");
}}>
{#if net.chatPending}<span class="sending">sending…</span>{/if}
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => net.rollTableDie()}>🎲</button>
</form>
{@render sayBox("", true)}
{/if}
</aside>
@@ -3531,7 +3528,7 @@
</select>
</label>
{#if net.pending}
<span class="sending" aria-live="polite">{Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}…</span>
<span class="sending" aria-live="polite">sending {net.pending.label}…</span>
{:else if net.confirmed}
<span class="sending ok" aria-live="polite">✓ {net.confirmed} confirmed</span>
{/if}
@@ -3845,9 +3842,9 @@
background: #f4eede;
cursor: pointer;
overflow: hidden;
position: relative;
}
.standee img { width: 100%; height: 100%; object-fit: cover; display: block; }
.standee { position: relative; }
.standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); }
.standee.current::after {
content: "✓";
@@ -3945,7 +3942,7 @@
color: #3a2f1f;
margin: 0.2rem 0 0.1rem;
}
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
/* The board's toolbar: the eyes toggle and the zoom, one short row. */
.board-tools {
display: flex;
justify-content: space-between;
@@ -4380,7 +4377,8 @@
.fpv-primary .board-viewport { flex: 0 1 30dvh; min-height: 5rem; }
.fpv-primary .board-frame { min-height: 0; }
/* The pane keeps its aspect but never more height than the row can
spare above the board strip and the dock. */
spare above the board strip and the dock: the strip's 5rem floor,
the dock, the hand, and the toolbar come to 34.5rem. */
.fpv-primary :global(.live-fp) { flex: 0 1 auto; min-height: 0; }
.fpv-primary :global(.fpv-canvas) { width: auto; max-width: 100%; max-height: calc(100dvh - 34.5rem); margin: 0 auto; }
.game:not(.hand-left) .table-edge { margin-top: 0.5rem; }