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:
co-authored by
Claude Fable 5.1
parent
c4a555f8df
commit
66b5c06f17
+37
-39
@@ -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} · dazed {p.lostTurns}{/if}{#if treasuresHomeOf(p) > 0} · <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`} ·
|
||||
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if home > 0} · <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`} ·
|
||||
<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; }
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { RULEBOOK_COPYRIGHT } from "./rulebook";
|
||||
import { helpState, type HelpTab } from "./help-state";
|
||||
import {
|
||||
REFERENCE, SOURCE_LABELS, searchReference, renderInline,
|
||||
REFERENCE, SOURCE_LABELS, searchReference, renderInline, type RefSection,
|
||||
CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel,
|
||||
HOUSE_RULINGS, rulingsFor, searchRulings,
|
||||
type RefSource,
|
||||
@@ -129,6 +129,16 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet section(s: RefSection, withSource: boolean)}
|
||||
<h3 id={s.id} class="linked">
|
||||
{s.title}
|
||||
{#if withSource}<span class="source">{SOURCE_LABELS[s.source]}</span>{/if}
|
||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||
{/snippet}
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
{#if entryDef}
|
||||
@@ -277,7 +287,7 @@
|
||||
<a class="tile" href="/?fpv">
|
||||
<img src="/hero.jpg" alt="" />
|
||||
<span class="tile-name">The first-person workshop</span>
|
||||
<span class="tile-note">the maze through a wizard's own eyes — or <span class="tile-link">watch the demo reel</span></span>
|
||||
<span class="tile-note">the maze through a wizard's own eyes</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
|
||||
@@ -358,13 +368,7 @@
|
||||
{#if rulesSearch.trim()}
|
||||
<p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
|
||||
{#each refResults as s (s.id)}
|
||||
<h3 id={s.id} class="linked">
|
||||
{s.title}
|
||||
<span class="source">{SOURCE_LABELS[s.source]}</span>
|
||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||
{@render section(s, true)}
|
||||
{/each}
|
||||
{:else}
|
||||
<nav class="contents" aria-label="contents">
|
||||
@@ -387,12 +391,7 @@
|
||||
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
|
||||
{/if}
|
||||
{#each group.sections as s (s.id)}
|
||||
<h3 id={s.id} class="linked">
|
||||
{s.title}
|
||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||
{@render section(s, false)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -622,7 +621,6 @@
|
||||
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
|
||||
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.tile-note { font-size: 0.85rem; color: #6b5a41; }
|
||||
.tile-link { text-decoration: underline; text-decoration-style: dotted; }
|
||||
|
||||
.search-row { margin-bottom: 0.8rem; }
|
||||
.search-row input {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { ReplayStep } from "./catchup";
|
||||
import { motion } from "./motion";
|
||||
import Board from "./Board.svelte";
|
||||
import { wizardColor } from "./colors";
|
||||
@@ -22,7 +23,7 @@
|
||||
endLabel = null,
|
||||
startAt = 0,
|
||||
}: {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||
steps: ReplayStep[];
|
||||
onclose: () => void;
|
||||
/** An instant replay of one turn: open straight into first person,
|
||||
* through the eyes of the wizard whose turn it is. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { ReplayStep } from "./catchup";
|
||||
// The public share page (/watch/<id>): one turn of one game, watchable
|
||||
// by anyone, spectator-redacted — the rest of the game stays private.
|
||||
// The reel opens in first person through the turn-owner's eyes, framed
|
||||
@@ -11,7 +12,7 @@
|
||||
const API = location.port === "5173" ? `http://${location.hostname}:8787` : "";
|
||||
|
||||
interface ShareSteps {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
||||
steps: ReplayStep[];
|
||||
actor: string;
|
||||
round: number;
|
||||
whole?: boolean;
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
// 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";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
import { cardName } from "./reference";
|
||||
|
||||
export interface MissedStep {
|
||||
/** One step of a replayed stretch: who acted, what happened, the table
|
||||
* after, and any talk that landed alongside. */
|
||||
export interface ReplayStep {
|
||||
seq: number;
|
||||
actor: string;
|
||||
events: GameEvent[];
|
||||
@@ -19,8 +22,7 @@ export interface MissedFact {
|
||||
}
|
||||
|
||||
function spell(id: string | null): string {
|
||||
if (!id) return "a punch";
|
||||
try { return cardDef(id).name; } catch { return id; }
|
||||
return id ? cardName(id) : "a punch";
|
||||
}
|
||||
|
||||
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
|
||||
@@ -31,7 +33,7 @@ function nearHome(view: GameView, you: string, cell: { x: number; y: number }):
|
||||
|
||||
/** 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[] {
|
||||
export function summarizeMissed(steps: readonly ReplayStep[], you: string, now: GameView | null): MissedFact[] {
|
||||
const facts: MissedFact[] = [];
|
||||
let lifeLost = 0;
|
||||
let lifeLostStep = -1;
|
||||
@@ -77,6 +79,7 @@ export function summarizeMissed(steps: readonly MissedStep[], you: string, now:
|
||||
}
|
||||
}
|
||||
});
|
||||
// Life lost reads second: the first fact names who came for you.
|
||||
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.
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import type { CardQuery } from "./reference";
|
||||
|
||||
export type HelpTab = "play" | "rules" | "cards" | "rulings" | "about" | "tally";
|
||||
export const HELP_TABS = ["play", "rules", "cards", "rulings", "about", "tally"] as const;
|
||||
export type HelpTab = (typeof HELP_TABS)[number];
|
||||
|
||||
export const helpState: {
|
||||
tab: HelpTab;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Websocket client + reactive session state (Svelte 5 runes).
|
||||
|
||||
import type { ReplayStep } from "./catchup";
|
||||
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
||||
import { receiptFor } from "./receipt";
|
||||
@@ -301,7 +302,6 @@ export interface FeedbackReportView {
|
||||
happened: string;
|
||||
expected: string;
|
||||
player?: string;
|
||||
reply?: { at: string; text: string; status: string };
|
||||
/** The exchange in order: the desk's replies and the player's answers. */
|
||||
thread?: { at: string; text: string; from: "desk" | "player"; status?: string }[];
|
||||
image?: string;
|
||||
@@ -381,8 +381,8 @@ class Net {
|
||||
expected = $state<string[]>([]);
|
||||
/** The table called the keeper of this site, and by whom. */
|
||||
challenge = $state<{ by: string; at: string } | null>(null);
|
||||
/** The keeper's name, as the server knows it. */
|
||||
keeper = $state("Kestrel");
|
||||
/** The keeper's name, once the room has said it. */
|
||||
keeper = $state<string | null>(null);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<LogLine[]>([]);
|
||||
error = $state<string | null>(null);
|
||||
@@ -410,15 +410,15 @@ class Net {
|
||||
/** Board flourishes: the app hooks in to animate live event batches. */
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
/** 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<ReplayStep[] | 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);
|
||||
missedSteps = $state<ReplayStep[] | 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
|
||||
* 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: ReplayStep[]; owner: string } | null>(null);
|
||||
/** Turns witnessed so far: counts the same boundary events the server
|
||||
* counts, so a chronicle line can name the turn it belongs to. */
|
||||
private turnCounter = -1;
|
||||
@@ -648,9 +648,7 @@ class Net {
|
||||
break;
|
||||
case "rematched":
|
||||
// The caller's own move: the seat and room for the new table follow.
|
||||
this.resetChronicle();
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.leaveTable();
|
||||
this.roomId = null;
|
||||
this.roomIdPending = msg.roomId;
|
||||
break;
|
||||
@@ -693,8 +691,8 @@ class Net {
|
||||
// stays — a restarting server, a stale restore, or the wrong
|
||||
// backend all answer with ignorance, and ignorance is not
|
||||
// deletion. The wallet is capped by age instead of by trust.
|
||||
// And a seat is dropped only on the SECOND refusal in a row: one
|
||||
// refusal around a restart or a stale answer must not cost a seat.
|
||||
// A seat goes only on the SECOND refusal in a row: one refusal
|
||||
// around a restart or a stale answer must not cost a seat.
|
||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||
const doubted = new Set<string>();
|
||||
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
||||
@@ -852,9 +850,7 @@ class Net {
|
||||
/** Take the seat kept for you at the rematch table. */
|
||||
acceptRematch(roomId: string): void {
|
||||
if (!this.you) return;
|
||||
this.resetChronicle();
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.leaveTable();
|
||||
this.join(roomId, this.you);
|
||||
}
|
||||
|
||||
@@ -996,6 +992,13 @@ class Net {
|
||||
}
|
||||
|
||||
/** The chronicle and its turn count reset together, always. */
|
||||
/** Rise from a finished table: the chronicle, the view, and the game go. */
|
||||
private leaveTable(): void {
|
||||
this.resetChronicle();
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
private resetChronicle(): void {
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// engine's own events, so it explains what happened rather than what the
|
||||
// cards promise.
|
||||
|
||||
import { cardDef, isPermanentDuration, type GameEvent } from "@wizwar/engine";
|
||||
import { isPermanentDuration, type GameEvent } from "@wizwar/engine";
|
||||
import { cardName } from "./reference";
|
||||
|
||||
export interface Receipt {
|
||||
title: string;
|
||||
@@ -11,8 +12,7 @@ export interface Receipt {
|
||||
}
|
||||
|
||||
function nameOf(id: string | null): string {
|
||||
if (!id) return "The punch";
|
||||
try { return cardDef(id).name; } catch { return id; }
|
||||
return id ? cardName(id) : "The punch";
|
||||
}
|
||||
|
||||
/** The receipt for a resolution event, or null when there is nothing to
|
||||
|
||||
@@ -8,6 +8,15 @@ import { RULES_SECTIONS } from "./rules";
|
||||
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
|
||||
import { FAQ_GENERAL } from "./faq-general";
|
||||
|
||||
/** A card's printed name, or the id itself for one the deck does not know. */
|
||||
export function cardName(id: string): string {
|
||||
try { return cardDef(id).name; } catch { return id; }
|
||||
}
|
||||
|
||||
function escapeRe(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
|
||||
export function slug(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
@@ -132,13 +141,13 @@ export function mentionedCards(cardId: string): CardDef[] {
|
||||
const def = cardDef(cardId);
|
||||
let text = def.text ?? "";
|
||||
if (!text) return [];
|
||||
text = text.replace(new RegExp(`\\b${def.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ");
|
||||
text = text.replace(new RegExp(`\\b${escapeRe(def.name.toUpperCase())}\\b`, "g"), " ");
|
||||
const found: CardDef[] = [];
|
||||
const candidates = CARD_POOL
|
||||
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
|
||||
.sort((a, b) => b.name.length - a.name.length);
|
||||
for (const d of candidates) {
|
||||
const re = new RegExp(`\\b${d.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const re = new RegExp(`\\b${escapeRe(d.name.toUpperCase())}\\b`);
|
||||
if (re.test(text)) {
|
||||
found.push(d);
|
||||
text = text.replace(new RegExp(re.source, "g"), " ");
|
||||
@@ -282,10 +291,6 @@ export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
|
||||
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
|
||||
},
|
||||
{
|
||||
id: "lifesaver", title: "Lifesaver", cards: ["lifesaver"],
|
||||
body: ["A wizard holding LIFESAVER is not eliminated for losing both treasures, exactly as the card promises."],
|
||||
},
|
||||
{
|
||||
id: "force-field", title: "Force Field", cards: ["force-field"],
|
||||
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
|
||||
@@ -302,7 +307,7 @@ export function searchRulings(query: string): HouseRuling[] {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return HOUSE_RULINGS;
|
||||
return HOUSE_RULINGS.filter((r) => {
|
||||
const names = r.cards.map((id) => { try { return cardDef(id).name; } catch { return id; } });
|
||||
const names = r.cards.map(cardName);
|
||||
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user