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:
Eric Wagoner
2026-09-22 09:56:49 -04:00
co-authored by Claude Fable 5.1
parent c4a555f8df
commit 66b5c06f17
21 changed files with 245 additions and 239 deletions
+9 -10
View File
@@ -733,7 +733,7 @@ function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTrea
/** Treasures a wizard has delivered: the scoreboard the table reads. */
function bankedBy(view: GameView, p: { id: PlayerId; home: Cell }): number {
return view.treasures.filter(
(t) => t.owner !== p.id && t.position && !t.carriedBy && cellKey(t.position) === cellKey(p.home)).length;
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home)).length;
}
/** The enemy whose delivery in progress would end the game, if any. */
@@ -769,8 +769,8 @@ export function interceptCell(
if (theirs == null || toHome == null || mine == null) continue;
if (theirs + toHome !== road || mine > theirs) continue;
if (!best || mine < best.mine || (mine === best.mine && toHome < best.toHome)) {
const [x, y] = k.split(",").map(Number);
best = { cell: { x: x!, y: y! }, mine, toHome };
const [x, y] = k.split(",").map(Number) as [number, number];
best = { cell: { x, y }, mine, toHome };
}
}
return best?.cell ?? carrier.home;
@@ -1509,19 +1509,19 @@ export function automatonCommand(
// would not have died anyway.
const weakest = [...visible].sort((a, b) => a.life - b.life)[0]!;
const finisher = bestAttack(view, weakest.id, tier);
const thiefInSight = thief && visible.some((p) => p.id === thief.id) ? thief : null;
const runner = visible.find((p) => deliveryWins(view, p));
const myBank = bankedBy(view, self);
const leader = [...visible]
.filter((p) => bankedBy(view, p) > myBank)
.sort((a, b) => bankedBy(view, b) - bankedBy(view, a) || a.life - b.life)[0];
const target = thief && visible.some((p) => p.id === thief.id) ? thief
: finisher && finisher.damage >= weakest.life ? weakest
: runner ?? leader ?? weakest;
const target = thiefInSight
?? (finisher && finisher.damage >= weakest.life ? weakest : null)
?? runner ?? leader ?? weakest;
// DROP OBJECT shakes the treasure out of the hands that matter:
// the thief of my gold, or any carrier whose delivery wins.
{
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
: visible.find((p) => deliveryWins(view, p));
const mark = thiefInSight ?? runner;
const dob = mark ? inHand(view, "drop-object") : undefined;
if (mark && dob) {
return {
@@ -1749,8 +1749,7 @@ export function automatonCommand(
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
// anyone may snatch it off the floor. A raider nearer my bank than I
// am, with the bank stocked, outranks the next grab — run home.
const bankedCount = view.treasures.filter(
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
const bankedCount = bankedBy(view, self);
// The predicate reads only ENEMY positions — my own steps must not
// flip it mid-march or the walker shuttles between goals.
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
+48 -33
View File
@@ -613,9 +613,6 @@ function castSightEdge(
return false;
}
/** The attacks that burn: what the FAQ lets hurt a KILLER OOZE. */
const FIRE_ATTACKS = new Set(["fireball"]);
function inThornbush(state: GameState, p: PlayerState): boolean {
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
}
@@ -3826,6 +3823,20 @@ export function edgeReentry(board: AssembledBoard, cur: Cell, side: Side): Cell
* `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any
* wall: a line leaving the maze re-enters at the opposite edge, one space
* on. Without it, only the lettered openings carry a teleporter across. */
/**
* One teleporter's step from `cur` toward `side`: the neighbouring
* square, the far mouth of a warp (one step, like any doorway), or — with
* `wrapEdges` — the square the board's opposite edge lets back in. Null
* where the maze ends.
*/
export function teleportStep(board: AssembledBoard, cur: Cell, side: Side, wrapEdges: boolean): Cell | null {
const n = neighbor(cur, side);
if (board.cells[cellKey(n)]) return n;
const w = board.warps.find((w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side);
if (w) return w.to.cell;
return wrapEdges ? edgeReentry(board, cur, side) : null;
}
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number {
if (cellKey(from) === cellKey(to)) return 0;
const seen = new Map<string, number>([[cellKey(from), 0]]);
@@ -3835,20 +3846,8 @@ export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell
const d = seen.get(cellKey(cur))!;
if (d >= 8) break; // teleport range is 4; stop early
for (const side of SIDES) {
let n = neighbor(cur, side);
if (!board.cells[cellKey(n)]) {
// The maze wraps for walkers; it wraps for teleporters too —
// a warp mouth is one step, same as any doorway.
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
);
if (w) n = w.to.cell;
else if (wrapEdges) {
const back = edgeReentry(board, cur, side);
if (!back) continue;
n = back;
} else continue;
}
const n = teleportStep(board, cur, side, wrapEdges);
if (!n) continue;
if (seen.has(cellKey(n))) continue;
seen.set(cellKey(n), d + 1);
if (cellKey(n) === cellKey(to)) return d + 1;
@@ -5538,26 +5537,27 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const cell = cmd.target.cell;
const content = state.squareContents[cellKey(cell)];
if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) {
const what = content.kind === "ooze" ? "ooze" : content.kind;
const what = content.kind;
if (effect.sameSquare) {
const dx = cell.x - origin.x, dy = cell.y - origin.y;
const side: Side | null = dx === 1 && dy === 0 ? "E" : dx === -1 && dy === 0 ? "W" : dy === 1 && dx === 0 ? "S" : dy === -1 && dx === 0 ? "N" : null;
const side = SIDES.find((s) => cellKey(neighbor(origin, s)) === cellKey(cell));
if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") {
return err(`you must stand beside the ${what} to use that`);
}
} else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
return err(`no line of sight to the ${what}`);
}
if (content.kind === "ooze" && !FIRE_ATTACKS.has(inHand.cardId)) {
// The FAQ lets only fire hurt a KILLER OOZE, and FIREBALL is the
// deck's fire.
if (content.kind === "ooze" && inHand.cardId !== "fireball") {
return err("only fire hurts the ooze");
}
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
@@ -6402,24 +6402,25 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield");
}
}
// Each counter's step is weighed once the next begins, or the loop
// ends: what it left of the blow.
let mark: CounterStep | null = null;
const weigh = () => { if (mark) { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; } };
for (const counter of stack.counters) {
trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration });
const mark = trail[trail.length - 1]!;
const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; };
mark = { cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration };
trail.push(mark);
if (counter.nullified) continue;
if (isNumberCard(counter.card.cardId)) {
// SHIELDSTONE number counter: reduce point AND duration effects.
const v = numberValue(counter.card.cardId);
pipe.damage = Math.max(0, pipe.damage - v);
pipe.duration = Math.max(0, pipe.duration - v);
weigh();
continue;
}
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
// Fire meets water, whichever was thrown first: entirely stopped.
pipe.damage = 0;
pipe.fullyStopped = true;
weigh();
continue;
}
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
@@ -6429,7 +6430,6 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
pipe.damage = 0;
pipe.duration = 0;
pipe.fullyStopped = true;
weigh();
continue;
}
if (stack.creatureId &&
@@ -6442,13 +6442,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
} else {
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
}
weigh();
continue;
}
const ce = CARD_EFFECTS[counter.card.cardId];
if (ce && ce.kind === "counter") ce.apply(pipe);
weigh();
}
weigh();
// A surviving teleport counter whisks the defender away before anything lands.
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
@@ -6617,7 +6617,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
// REFLECTION's split to halve, so the curse is laid on the caster as
// well, as if the defender had cast it back.
const curseShared = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 24 &&
!pipe.redirected && !pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive &&
!pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive &&
stack.counters.some((c) => !c.nullified && c.card.cardId === "reflection");
if (effect?.onResolved && curseShared) {
effect.onResolved({
@@ -6628,7 +6628,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
stack,
});
}
if (effect?.onResolved && !pipe.redirected) {
if (effect?.onResolved) {
effect.onResolved({
state,
events,
@@ -7384,3 +7384,18 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
impCheck(state, events, state.players[next]!.id);
return { ok: true, state, events };
}
/** Whose input the maze wants right now: a hanging window first, then
* the stack, then the discard, then the turn's holder. */
export function actingSeat(state: GameState): PlayerId {
return (
state.wardPending?.ownerId ??
state.pushPending?.pusheeId ??
state.slowDeathPending?.playerId ??
state.stack?.waitingOn ??
state.pendingDiscard ??
state.chaosPending?.queue[0] ??
state.outOfTurnWindow?.playerId ??
state.players[state.turn.activeIndex]!.id
);
}
+13 -25
View File
@@ -20,6 +20,7 @@ import {
type TreasureState,
type TurnState,
dreadDistance,
teleportStep,
} from "./game";
export interface PlayerPublicView {
@@ -367,13 +368,6 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
return false;
}
/**
* The sight line behind the attack currently on the stack — the board's
* answer to "how can he even see me?". Null when nothing should draw:
* no stack, a creature's or physical attack, a non-LOS card, attacker and
* defender sharing a square, or no sight under this viewer's knowledge
* (a believed illusion wall can honestly hide the line).
*/
/** A sight line as the board draws it: the legs, the corner bend, and the
* one wall a VISIONSTONE dissolved. */
export interface SightLine {
@@ -429,7 +423,13 @@ export function sightTraceBetween(
return null;
}
/** The sight line of the attack on the stack, for the board to draw. */
/**
* The sight line behind the attack currently on the stack — the board's
* answer to "how can he even see me?". Null when nothing should draw:
* no stack, a creature's or physical attack, a non-LOS card, attacker and
* defender sharing a square, or no sight under this viewer's knowledge
* (a believed illusion wall can honestly hide the line).
*/
export function stackSightTrace(view: GameView): SightLine | null {
const stack = view.stack;
if (!stack || stack.creatureId) return null;
@@ -453,9 +453,7 @@ export function castSightTrace(
events: readonly GameEvent[],
afterTheFact = false,
): SightLine | null {
let def;
try { def = cardDef(cast.cardId); } catch { return null; }
if (def.los !== true) return null;
if (cardDef(cast.cardId).los !== true) return null;
const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
if (!to) return null;
const caster = view.players.find((p) => p.id === cast.caster);
@@ -517,9 +515,8 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
if (cardId === "teleport") {
// Up to four spaces, walls and objects ignored; not into solid stone.
// BFS over existing cells, matching the engine's wallIgnoringDistance —
// the maze wraps for teleporters as it does for walkers, a warp mouth
// being one step like any doorway.
// The maze wraps for teleporters as it does for walkers, and from
// rev 22 the outer edge is no more than a wall to them.
const out = new Set<string>();
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
const queue = [me.position];
@@ -528,17 +525,8 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
const d = dist.get(key(cur.x, cur.y))!;
if (d >= 4) continue;
for (const side of SIDES) {
let n = neighbor(cur, side);
if (!view.board.cells[key(n.x, n.y)]) {
const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side);
if (w) n = w.to.cell;
else if (view.deckRev >= 22) {
// Rev 22: the outer edge is no more than a wall to a teleporter.
const back = edgeReentry(view.board, cur, side);
if (!back) continue;
n = back;
} else continue;
}
const n = teleportStep(view.board, cur, side, view.deckRev >= 22);
if (!n) continue;
const nk = key(n.x, n.y);
if (dist.has(nk)) continue;
dist.set(nk, d + 1);