Credibility pass: comments say what the code can't, and the seams are gone

Engine: pit-rim exits live in one helper (pitRimExits) shared by the
resolver and the automaton; chargeReach names the six-stride cap; dust
sight is inDust + dustAtEnd; PushPending is exported once; the force-field
counter builds its events in one list; drawUnderSlowDeath says what it
draws under. Test groups are named for the rule they pin, not the
revision that introduced it.

Client: one smoothstep; one edgeScar per battle-scarred edge; one
board-zone rule (which also seats the caption strip on phones); the
compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery
replaces two hand-rolled matchMedia listeners; sprites carry their sizes
as plain numbers and their CSS in one rule each; canvas helpers are
formatted like the rest of the tree. Comments that told how a cel or a
fallback used to look now say what it draws. The actions-over notice no
longer blames a pickup when slime ended the turn.

Server and ops: dataRoot() is the one data directory; headlineOf builds
its deeds without a cast; the rollup and skills read the same way the
code behaves.

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-16 00:21:34 -04:00
co-authored by Claude Fable 5.1
parent 85690f0e23
commit 6e49fc9020
40 changed files with 331 additions and 293 deletions
+27 -30
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { motion } from "./motion";
import { attentionLabel, net, spellName } from "./net.svelte";
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
import { MediaQuery } from "svelte/reactivity";
import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte";
@@ -16,7 +17,7 @@
import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect();
@@ -305,7 +306,6 @@
let rotateCW = $state(true);
/** pick-lock / master-key: prop the door for others once it opens. */
let holdDoor = $state(false);
/** mega-monster: which stat the chosen monster doubles. */
/** MEGA-MONSTER aimed at a monster: which of its two numbers to double. */
let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null);
function castMega(boost: "life" | "movement") {
@@ -345,8 +345,9 @@
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
/** Picking up an object ends the turn's actions: the hand goes quiet until
* the draw — but never while a counter, shield, or discard is owed. */
/** Picking up an object or sliding into slime ends the turn's actions: the
* hand goes quiet until the draw — but never while a counter, shield, or
* discard is owed. */
const actionsSpent = $derived(
view != null && isYourTurn && view.turn.actionsEnded &&
!youMustRespond && !youMustShield && !youMustDiscard,
@@ -465,16 +466,15 @@
* the spell being built (or answered), controls and all — table talk
* returns the moment the cast resolves. Phones keep the under-board
* strips; a rail below the fold is no place to aim from. */
const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)");
let wideScreen = $state(wideQuery?.matches ?? true);
wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches));
const wideQuery = new MediaQuery("(min-width: 901px)", true);
const wideScreen = $derived(wideQuery.current);
/** A wide, short screen — a laptop in landscape — stands the hand beside
* the board unless the player has said otherwise: a tall maze under a
* bottom hand leaves a postage stamp, and a first turn deserves better. */
const shortWideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 1180px) and (max-height: 820px)");
let shortWide = $state(shortWideQuery?.matches ?? false);
shortWideQuery?.addEventListener("change", (e) => (shortWide = e.matches));
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWide);
* bottom hand leaves a postage stamp, and a first turn deserves better.
* (The narrower "short window" rule in the stylesheet only shrinks the
* cards; this one needs room for a card column, hence the wider floor.) */
const shortWideQuery = new MediaQuery("(min-width: 1180px) and (max-height: 820px)", false);
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWideQuery.current);
const castPanelOn = $derived(wideScreen && !local.active &&
(selectedCard != null || (youMustRespond && !!view?.stack)));
@@ -588,14 +588,14 @@
}
}
/** Attachment plumbing for a cast command: the chosen mods and the
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
/** Your ALTER EGO, when it is the selected token: casts leave from its square. */
const castingDouble = $derived(
selectedCreature && view
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
: null,
);
/** Attachment plumbing for a cast command: the chosen mods, the attached
* NUMBER (an explicit numberInstanceIds wins), and the double it casts through. */
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd);
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
@@ -669,7 +669,7 @@
discardSelection = next;
return;
}
if (actionsSpent) return; // picking up an object ended the turn's actions
if (actionsSpent) return;
if (!isYourTurn && !yourMoment) {
// Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending).
@@ -945,8 +945,7 @@
// A card in hand is aimed, not walked with: a tap on a bare square
// while holding one is a miss, never a stride into whatever is there.
if (selectedCard) {
net.error = `${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`;
setTimeout(() => { if (net.error?.startsWith(cardDef(selectedCard!.cardId).name)) net.error = null; }, 5000);
net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`);
return;
}
// A cell click is a move if the cell is one legal step away (the server
@@ -981,7 +980,7 @@
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue;
for (const out of SIDES) {
if (out === (side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E")) continue;
if (out === opposite(side)) continue;
const foot = stepTarget(view.board, pit, out);
if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; }
}
@@ -1533,10 +1532,10 @@
// answer to "how can he even see me?" when sight ran through a warp mouth.
const sightTrace = $derived(view ? stackSightTrace(view) : null);
/** The step light: with no card in hand, your square and the squares a
* stride reaches are lit and the rest of the maze falls into shadow. */
/** 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
* stride reaches are lit and the rest of the maze falls into shadow. */
const stepLight = $derived(
!!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
@@ -1651,7 +1650,7 @@
<span class="mast-sub">{fpvWorkshop ? "the eyes workshop" : tokenWorkshop ? "the token workshop" : "the flourish workshop"}</span>
<a class="mast-leave" href="/">← back to the table</a>
<a class="mast-leave" href="/clips">the clips</a>
<a class="mast-leave mast-help-solo" href="/?tokens">tokens</a>
<a class="mast-leave mast-right" href="/?tokens">tokens</a>
<a class="mast-leave" href="/?fx">flourishes</a>
<a class="mast-leave" href="/?fpv">eyes</a>
</header>
@@ -1813,7 +1812,7 @@
<div class="attack-actions">
{#each view.pushPending.exits as d (d)}
<button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: d })}>
{d === "N" ? "North" : d === "S" ? "South" : d === "E" ? "East" : "West"}
{SIDE_NAMES[d]}
</button>
{/each}
</div>
@@ -2803,10 +2802,10 @@
{/if}
{#if view.turn.actionsEnded}
{#if me?.carriedTreasureId}
picking up ended your actions for this turn, and the treasure comes with you —
your actions for this turn are over, and the treasure comes with you —
<strong>End turn</strong> now, then carry it home to score.
{:else}
picking up ended your actions for this turn — <strong>End turn</strong> when ready.
your actions for this turn are over — <strong>End turn</strong> when ready.
{/if}
{:else}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
@@ -3372,7 +3371,7 @@
.workshop-mast { margin: 0 1rem 0; }
.mast-home { text-decoration: none; }
.mast-home:hover { color: #fff; }
.mast-help-solo { margin-left: auto; }
.mast-help-solo, .mast-right { margin-left: auto; }
.hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
@@ -3500,7 +3499,6 @@
margin: 0.2rem 0 0.1rem;
}
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
.board-zone { position: relative; flex-direction: column; align-items: stretch; }
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
.step-caption {
align-self: flex-start;
@@ -3816,7 +3814,7 @@
flex: 1;
min-height: 0;
}
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
.board-zone { position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0; }
/* Hand at the left: the cards stand in their own column beside the
* board while the hint strips and action buttons stay beneath it —
* the strip dissolves into the grid so its children place themselves.
@@ -3831,7 +3829,7 @@
grid-template-rows: minmax(0, 1fr) auto;
}
.paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; }
.board-zone { flex-direction: column; align-items: stretch; min-height: 0; }
.board-zone { min-height: 0; }
.table-stack { flex: 1 1 0; min-height: 0; }
.board-frame { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
.board-viewport { height: auto; flex: 1 1 0; min-height: 0; }
@@ -3979,7 +3977,6 @@
padding-left: 0.3rem;
border-radius: 2px;
}
.score-row.active .score-name { font-weight: 700; }
.score-temper {
font-family: "Caveat", cursive;
font-size: 0.85rem;