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:
co-authored by
Claude Fable 5.1
parent
85690f0e23
commit
6e49fc9020
+38
-48
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user