Credibility pass: the illusion tests roll both ways, and the seams from the last batch are sanded

Scope: 66b5c06..HEAD.

- The rev-27 illusion tests search seeds for a believed and a doubted
  roll and assert fixed life totals; seed 42 alone never believed, so
  the BLUNT pricing went unchecked.
- Three docblocks stranded above newly inserted functions are back on
  their own (openCreatureStack, resolvedBlow, castTrace).
- POWER ATTACK's life trade is one helper instead of three copies; the
  goat's charge distance is one helper for its validator, its ram and
  its pricing.
- The family list loads once into a shared store; the lobby and gallery
  share one talk filter; Board uses the engine's neighbor().
- A creature's blow shows the card that summoned it by the creature's
  kind, not by matching the blow's display name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-26 00:22:20 -04:00
co-authored by Claude Opus 5.5
parent e97deb8d6f
commit 3a6b0ff43e
8 changed files with 104 additions and 104 deletions
+24 -28
View File
@@ -10,7 +10,7 @@
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import { HELP_TABS, type HelpTab } from "./help-state";
import { loadFamily, type FamilyGame } from "./family";
import { family as kin } from "./family.svelte";
import { summarizeMissed } from "./catchup";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
@@ -28,14 +28,8 @@
let name = $state(prefs.wizardName);
/** The other games of Kestrel's Hall, for the front page. */
let family = $state<FamilyGame[]>([]);
let hall = $state("");
$effect(() => {
void loadFamily("wizwar").then((f) => {
family = f.games;
hall = f.hall;
});
});
const family = $derived(kin.games);
const hall = $derived(kin.hall);
let joinCode = $state("");
let claimPhrase = $state("");
/** A /join/<CODE> recruiting link. The visitor lands looking at the
@@ -274,13 +268,15 @@
let peekToken = $state<string | null>(null);
/** Ledger chest tapped: the treasure full-size, with whose and who-holds. */
let peekTreasure = $state<{ src: string; note: string } | null>(null);
/** Cardless blows that a card nonetheless made: the stack names the
* blow, and this names the card to show for it. */
const BLOW_CARD: Record<string, string> = {
"warded treasure": "ward",
"fire imp's scorch": "fire-imp", "troll's blow": "troll", "skeleton's blow": "skeleton",
"wraith's touch": "wraith", "democratic monster's claw": "democratic-monster",
};
/** Cardless blows that a card nonetheless made: a creature's blow shows
* the card that summoned it (its kind is the card's id), the ward's bite
* shows WARD. */
const blowCard = $derived.by(() => {
const stack = view?.stack;
if (!stack) return null;
const creature = stack.creatureId ? view!.creatures.find((c) => c.id === stack.creatureId) : undefined;
return creature?.kind ?? (stack.sourceName === "warded treasure" ? "ward" : null);
});
/** Bare-knuckle demolition: click a wall to punch it. */
let punchWallMode = $state(false);
@@ -1534,6 +1530,8 @@
}
/** What the chronicle shows: everything, the game's own lines, or the talk. */
let logFilter = $state<"all" | "game" | "talk">("all");
/** The last few lines of talk, for the lobby and the gallery before the boards flip. */
const lobbyTalk = $derived(net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6));
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);
@@ -1778,6 +1776,10 @@
/** 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. */
let castTrace = $state.raw<SightLine | null>(null);
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
/** The hour where the keeper lives, in words, kept current by the minute. */
let minuteTick = $state(0);
$effect(() => {
@@ -1795,10 +1797,6 @@
} catch { return null; }
});
let castTrace = $state.raw<SightLine | null>(null);
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
/** A first game's first turn lights itself; after that only by choice. */
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
/** The step light: with no card in hand, your square and the squares a
@@ -2463,8 +2461,8 @@
{:else if view.stack.defenderId === view.you && view.stack.sourceName}
<!-- A cardless bite with a name — the WARD springing on a grabbed
treasure. The card that did it is shown; nobody threw it. -->
{#if BLOW_CARD[view.stack.sourceName]}
<div class="attack-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: BLOW_CARD[view.stack.sourceName]! }} onfaq={(id) => (faqCardId = id)} /></div>
{#if blowCard}
<div class="attack-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: blowCard }} onfaq={(id) => (faqCardId = id)} /></div>
{/if}
<div class="attack-power">
{view.stack.sourceName.toUpperCase()} — {view.stack.params?.damage ?? 0} damage inbound unless you answer it
@@ -2848,7 +2846,6 @@
back, as long as nobody abandons it.</div>
{/if}
<!-- Talk before the boards flip: to say when, or to leave a word. -->
{@const lobbyTalk = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
<div class="lobby-talk">
{#each lobbyTalk as line, i (i)}
<div class="lobby-line">{line.text}</div>
@@ -2880,9 +2877,8 @@
</div>
{/if}
{:else}
{@const galleryTalkLines = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
<div class="lobby-talk">
{#each galleryTalkLines as line, i (i)}
<div class="lobby-talk">
{#each lobbyTalk as line, i (i)}
<div class="lobby-line">{line.text}</div>
{/each}
{#if net.galleryTalk}
@@ -3466,8 +3462,8 @@
{#if view.stack.attackCard}
<div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div>
{:else if view.stack.sourceName}
{#if BLOW_CARD[view.stack.sourceName]}
<div class="cast-panel-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: BLOW_CARD[view.stack.sourceName]! }} onfaq={(id) => (faqCardId = id)} /></div>
{#if blowCard}
<div class="cast-panel-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: blowCard }} onfaq={(id) => (faqCardId = id)} /></div>
{/if}
<div class="cast-panel-note">{defending ? "" : "your "}{view.stack.sourceName} — it bites for {view.stack.params?.damage ?? 0}</div>
{:else}
+2 -3
View File
@@ -2,7 +2,7 @@
import type { Component } from "svelte";
import type { GameView, SightTrace } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import { edgeKey, fearCells } from "@wizwar/engine";
import { edgeKey, fearCells, neighbor } from "@wizwar/engine";
import type { BoardFx } from "./fx";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
@@ -215,8 +215,7 @@
}
if (!punchFrom) return boxes;
const beside = (b: { cell: { x: number; y: number }; side: Side }) => {
const far = b.side === "E" ? { x: b.cell.x + 1, y: b.cell.y } : b.side === "W" ? { x: b.cell.x - 1, y: b.cell.y }
: b.side === "S" ? { x: b.cell.x, y: b.cell.y + 1 } : { x: b.cell.x, y: b.cell.y - 1 };
const far = neighbor(b.cell, b.side);
const at = (c: { x: number; y: number }) => c.x === punchFrom!.x && c.y === punchFrom!.y;
return at(b.cell) || at(far);
};
+3 -9
View File
@@ -3,7 +3,7 @@
// body, the card library with proper entries, the house rulings, the
// story of the table, and the tally. Every section and card has a link
// a player can send; the desk remembers where the reader was.
import { loadFamily, type FamilyGame } from "./family";
import { family as kin } from "./family.svelte";
import { cardDef } from "@wizwar/engine";
import { tick } from "svelte";
import Card from "./Card.svelte";
@@ -129,14 +129,8 @@
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
});
/** The other games of Kestrel's Hall, for the about page. */
let family = $state<FamilyGame[]>([]);
let hall = $state("");
$effect(() => {
void loadFamily("wizwar").then((f) => {
family = f.games;
hall = f.hall;
});
});
const family = $derived(kin.games);
const hall = $derived(kin.hall);
</script>
{#snippet section(s: RefSection, withSource: boolean)}
+28
View File
@@ -0,0 +1,28 @@
// The other games of Kestrel's Hall, from the family list the kit keeps
// (family.json, one copy shipped with every game), so a player who liked one
// table can find the next, and the hall's front door. This game is left out
// of its own list. Fetched once, shared by every page that shows it.
export interface FamilyGame {
slug: string;
name: string;
url: string;
pitch: string;
players: string;
origin: string;
}
/** The hall's front door and every game but this one; empty until the list arrives. */
export const family = $state<{ hall: string; games: FamilyGame[] }>({ hall: "", games: [] });
void (async () => {
try {
const res = await fetch("/family.json", { cache: "no-cache" });
if (!res.ok) return;
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
family.hall = data.hall ?? "";
family.games = (data.games ?? []).filter((g) => g.slug !== "wizwar");
} catch {
// No list, no neighbors shown.
}
})();
-30
View File
@@ -1,30 +0,0 @@
// The other games of Kestrel's Hall, from the family list the kit keeps
// (family.json, one copy shipped with every game), so a player who liked one
// table can find the next, and the hall's front door. This game is left out
// of its own list.
export interface FamilyGame {
slug: string;
name: string;
url: string;
pitch: string;
players: string;
origin: string;
}
/** The family list: the hall's front door and every game but this one. */
export interface Family {
hall: string;
games: FamilyGame[];
}
export async function loadFamily(self: string): Promise<Family> {
try {
const res = await fetch('/family.json', { cache: 'no-cache' });
if (!res.ok) return { hall: '', games: [] };
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
} catch {
return { hall: '', games: [] };
}
}