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
+38 -48
View File
@@ -27,7 +27,7 @@ import {
bentSightThroughGap,
neighbor,
stepTarget,
opposite,
opposite, pitRimExits,
} from "./board";
import {
buildDeck,
@@ -80,6 +80,12 @@ export interface PlayerState {
}
/** A duration spell in play. Expires at the START of the caster's turns. */
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
export interface PushPending {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
}
export interface SustainedEffect {
id: string;
cardId: string;
@@ -298,11 +304,7 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
pushPending: {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
} | null;
pushPending: PushPending | null;
/** SLOW DEATH's bites hang while the victim weighs counteractions:
* the draw's total is one blow — ABSORB soaks up to three of it,
* BLUNT halves it rounding up (table ruling). */
@@ -2401,11 +2403,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed;
// Rev 16: the charge is measured as far as the legs reach. Before it,
// the search stopped at six steps and called anything past that
// unreachable — a goat with eight legs refused a seven-step ram.
const reach = (state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(state, caster.position, target.position, reach);
const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null;
},
@@ -2413,8 +2411,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
if (d === 0) return;
if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say):
@@ -2903,10 +2900,8 @@ function waveForce(range: number, dist: number): number {
/** One line of a wave's sweep: the squares it reaches, nearest first, with
* the force left at each. The victims are named before any is pushed
* (rev 20). Before it the sweep, walking the way it pushed, could find a
* victim it had just shoved and shove them again with the force left: a
* wizard washed one square into a wall was crushed twice. Older games
* replay that sweep. */
* (rev 20); older games sweep square by square, so a wizard shoved along
* the sweep's own line is caught and shoved again. */
function sweepWave(
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
): void {
@@ -3724,9 +3719,16 @@ function attachSustained(
});
}
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
/** Steps along real corridors from one cell to another, searched out to
* `limit` steps; beyond that the answer is Infinity. */
/** How far a BUTT-HEAD charge is measured: as far as the goat's legs
* reach (rev 16); older games search six steps and call the rest
* unreachable. */
function chargeReach(state: GameState, legs: number): number {
return (state.config.deckRev ?? 1) >= 16 ? legs : 6;
}
/** Steps along real corridors from one cell to another (MENTAL FORCE's
* three moved spaces, a goat's charge), searched out to `limit` steps;
* beyond that the answer is Infinity. */
export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state);
@@ -4254,6 +4256,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement."
// A resumed push carries the direction its pushee chose: no stagger.
if (isBlinded(state, p) && shove === undefined) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
}
@@ -4397,10 +4400,9 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// the other player gets to decide which direction to go." The
// pushed one leaves by any open side but the way the giant came;
// one open side is taken, several are theirs to choose — the stride
// hangs until they do. Older games shoved straight ahead only.
// A game dealt before rev 15 shoves straight ahead when it can, as it
// always did; the corner and the fork were refusals before, so every
// game may round a corner now.
// hangs until they do. Older games (deckRev < 15) shove straight
// ahead when that way is open; rounding a corner is allowed in every
// game.
const fork = (state.config.deckRev ?? 1) >= 15;
let landing: Cell | null = null;
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
@@ -4461,19 +4463,12 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
let landingWarp = false;
if (ledge) {
const pitCell = p.position;
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => {
const t = stepTarget(view, pitCell, d);
if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t;
};
const ways = SIDES.filter((d) => d !== opposite(direction));
const floor = ways.filter((d) => footing(d)?.kind === "step");
const mouths = ways.filter((d) => footing(d)?.kind === "warp");
const { floor, mouths } = pitRimExits(view, pitCell, direction,
(c) => state.squareContents[cellKey(c)]?.kind === "stone");
// A warp mouth on the rim is a way off it like any square, and may
// always be named. Only what a bare step does is a matter of
// revision: from rev 17 a square beside a mouth is a fork to name;
// before it the step took the square, and takes it still, so the
// recorded games replay as they were played.
// before it a bare step takes the square.
const exits = [...floor, ...mouths];
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) {
@@ -4489,7 +4484,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
}
const foot = footing(landing)!;
const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
landingCell = foot.to;
landingWarp = foot.kind === "warp";
}
@@ -6074,16 +6069,13 @@ function doCounteract(
// opponent from entering the space you occupy or casting spells on or
// past you. Lasts only until the end of the opponent's turn." The
// field faces every side, not one: this table's simplification.
const events: GameEvent[] = [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }];
if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) {
const against = state.players.findIndex((q) => q.id === stack.attackerId);
attachSustained(state, [], "force-field", playerId, playerId, PERMANENT_TURNS, { against });
const facing = state.players.findIndex((q) => q.id === stack.attackerId);
attachSustained(state, events, "force-field", playerId, playerId, PERMANENT_TURNS, { against: facing });
}
stack.waitingOn = stack.attackerId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
return { ok: true, state, events };
}
if (playerId === stack.attackerId) {
@@ -6844,8 +6836,8 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
});
// Rev 21: LIFESAVER — "immune to the effects of losing both of your
// treasures to other players' home bases." The card was cast and
// remembered but never asked here; older games eliminated its holder.
// treasures to other players' home bases." Older games eliminate its
// holder.
const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0;
if (lost && !saved) {
p.alive = false;
@@ -7178,10 +7170,8 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
state.openSafes = [];
// A FORCE FIELD raised against this wizard lasts only until their turn ends.
{
const idx = state.players.findIndex((q) => q.id === p.id);
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === idx));
}
const seat = state.players.indexOf(p);
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === seat));
events.push({ type: "turnEnded", player: p.id });
if (p.extraTurns > 0) {