Credibility pass: the accretion sanded smooth
Blind reviews over d2b40ec..HEAD (engine / web / server+deploy). Orphaned doc comments rejoined their functions; the swap-meet tradables collapsed from four pasted deriveds to one (killing the "the's treasure" label); FEAR's aura and banner now share the engine's own dreadDistance; the long-press peek got one home; rulebook.ts stopped wearing JSON quotes; process-named tests and war-story comments now state the constraints they pin; the protocol doc caught up to its handlers; verify-ledgers.sh can actually count failures; the runbook learned about the determinism gate and the nightly backups. No behavior changes — 255 tests and all 48 production ledgers replay identically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3445d12206
commit
6adcbe23b5
@@ -256,6 +256,12 @@ function bestWallCrossing(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Square-filling nuisances for path denial, best blocker first. */
|
||||
const DENIAL_FILLERS = [
|
||||
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
|
||||
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
|
||||
];
|
||||
|
||||
/**
|
||||
* Path denial: when an enemy's march threatens something dear — they carry
|
||||
* one of the clockwork's treasures home, or close on its gold lying on the
|
||||
@@ -265,12 +271,6 @@ function bestWallCrossing(
|
||||
* would accept are offered (sighted, empty, off homes and warp tokens), and
|
||||
* only when the detour costs the enemy 3+ extra steps.
|
||||
*/
|
||||
/** Square-filling nuisances for path denial, best blocker first. */
|
||||
const DENIAL_FILLERS = [
|
||||
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
|
||||
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
|
||||
];
|
||||
|
||||
function pathDenial(view: GameView): Command | null {
|
||||
const wall = inHand(view, "create-wall") ?? inHand(view, "illusion-wall");
|
||||
const jam = inHand(view, "jam-lock");
|
||||
@@ -531,7 +531,8 @@ function roadworkPlan(
|
||||
}
|
||||
}
|
||||
}
|
||||
return best !== null ? (best as { cmd: Command; total: number }).cmd : null;
|
||||
if (best !== null) return (best as { cmd: Command; total: number }).cmd;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1308,14 +1309,14 @@ export function automatonCommand(
|
||||
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) &&
|
||||
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3);
|
||||
for (const source of dreadful) {
|
||||
const here = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
|
||||
const distNow = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
|
||||
let best: { dir: Side; d: number } | null = null;
|
||||
for (const dir of SIDES) {
|
||||
const t = stepTarget(view.board, self.position, dir);
|
||||
if (t.kind === "blocked") continue;
|
||||
if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue;
|
||||
const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y);
|
||||
if (d > here && (best === null || d > best.d)) best = { dir, d };
|
||||
if (d > distNow && (best === null || d > best.d)) best = { dir, d };
|
||||
}
|
||||
if (best) return { type: "move", direction: best.dir };
|
||||
// Dead end: the card excuses what cannot be done; march on normally.
|
||||
@@ -1391,7 +1392,6 @@ export function automatonCommand(
|
||||
}
|
||||
// Standing on a warp token whose far side is closer to the goal: step in.
|
||||
{
|
||||
const here = cellKey(self.position);
|
||||
const pair = view.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
|
||||
if (pair) {
|
||||
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
|
||||
|
||||
+12
-12
@@ -958,7 +958,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||
const to = ctx.stack.params?.cell;
|
||||
if (!to) return; // an old ambush sprang it with no destination: fizzle
|
||||
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle
|
||||
const from = ctx.defender.position;
|
||||
ctx.defender.position = to;
|
||||
ctx.events.push({
|
||||
@@ -2077,7 +2077,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||
const to = ctx.stack.params?.cell;
|
||||
if (!to) return; // an old ambush sprang it with no destination: fizzle
|
||||
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle
|
||||
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
|
||||
const from = ctx.defender.position;
|
||||
ctx.defender.position = to;
|
||||
@@ -2586,9 +2586,8 @@ function waveForce(state: GameState, range: number, dist: number): number {
|
||||
|
||||
/** A collapsing waterwall wave from an edge: wash players back `range`. */
|
||||
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
|
||||
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
|
||||
const pushes: { start: Cell; dir: Side }[] = [
|
||||
{ start: cell, dir: away(side) },
|
||||
{ start: cell, dir: opposite(side) },
|
||||
{ start: neighbor(cell, side), dir: side },
|
||||
];
|
||||
for (const { start, dir } of pushes) {
|
||||
@@ -3734,23 +3733,24 @@ function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null {
|
||||
|
||||
// --- Movement ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* FEAR: "no player or monster will move to within 3 spaces of you." True
|
||||
* for every WILLING move — walking, warp steps, self-teleports, commanded
|
||||
* monsters (rules rev 32 widened it beyond walking wizards; forced movement
|
||||
* such as knockback or a wave is not willing and passes).
|
||||
*/
|
||||
/**
|
||||
* FEAR's measure — FAQ: "as a Wizard walks (if he could walk through
|
||||
* walls), not diagonally." Orthogonal steps, walls ignored — and the maze
|
||||
* wraps, so stepping off one rim continues from the opposite one.
|
||||
* Exported so the client draws the aura by the same yardstick.
|
||||
*/
|
||||
function dreadDistance(board: AssembledBoard, a: Cell, b: Cell): number {
|
||||
export function dreadDistance(board: AssembledBoard, a: Cell, b: Cell): number {
|
||||
const dx = Math.abs(a.x - b.x);
|
||||
const dy = Math.abs(a.y - b.y);
|
||||
return Math.min(dx, board.width - dx) + Math.min(dy, board.height - dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* FEAR: "no player or monster will move to within 3 spaces of you." True
|
||||
* for every WILLING move — walking, warp steps, self-teleports, commanded
|
||||
* monsters (rules rev 32 widened it beyond walking wizards; forced movement
|
||||
* such as knockback or a wave is not willing and passes).
|
||||
*/
|
||||
function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: Cell): boolean {
|
||||
// The wrap joined the measure at rev 32; older ledgers were recorded
|
||||
// under the flat diamond and replay so.
|
||||
@@ -4271,7 +4271,6 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
|
||||
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
|
||||
}
|
||||
|
||||
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
|
||||
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */
|
||||
function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
||||
const state = clone(prev);
|
||||
@@ -4293,6 +4292,7 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
|
||||
function doArmWard(prev: GameState, armed: boolean): CommandResult {
|
||||
// Rev 31 reads the card literally: the Ward is played in the moment of
|
||||
// the grab, never set ahead. Older games keep their arming and replay so.
|
||||
|
||||
@@ -118,36 +118,36 @@ describe("automaton vs automaton", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function underAttackShared(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||
// burn round 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
while (actingSeat(state) !== "human") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
const human = state.players.find((p) => p.id === "human")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { ...human.position };
|
||||
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
||||
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
||||
rig?.(state, "bot");
|
||||
const r = applyCommand(state, "human", {
|
||||
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
|
||||
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
|
||||
});
|
||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||
// burn round 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
return r.state;
|
||||
state = r.state;
|
||||
}
|
||||
while (actingSeat(state) !== "human") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
const human = state.players.find((p) => p.id === "human")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { ...human.position };
|
||||
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
||||
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
||||
rig?.(state, "bot");
|
||||
const r = applyCommand(state, "human", {
|
||||
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
|
||||
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
|
||||
});
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
return r.state;
|
||||
}
|
||||
|
||||
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
it("passes on drop-object rather than absorbing nothing", () => {
|
||||
const state = underAttackShared("drop-object", [
|
||||
const state = underAttack("drop-object", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
@@ -157,7 +157,7 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
});
|
||||
|
||||
it("shields a drop-object aimed at its carried treasure", () => {
|
||||
const state = underAttackShared("drop-object", [
|
||||
const state = underAttack("drop-object", [
|
||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
], (s, defender) => {
|
||||
@@ -171,8 +171,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
});
|
||||
|
||||
describe("the clockwork guards gold only within sight", () => {
|
||||
// Game X2XN: the bot blind-cast SAFE at a treasure it could not see, the
|
||||
// engine refused it, and the fallback burned the whole turn.
|
||||
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
|
||||
// so SAFE must only ever be offered where the engine's sight rule allows it.
|
||||
function goldOnTheFloor() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
@@ -260,7 +260,7 @@ describe("a clogged hand gets shed, not hoarded", () => {
|
||||
|
||||
describe("the clockwork honors absorb's fine print", () => {
|
||||
it("answers NO SPELL with blunt, never absorb — durations soak no points", () => {
|
||||
const state = underAttackShared("no-spell", [
|
||||
const state = underAttack("no-spell", [
|
||||
{ instanceId: "absorb#T", cardId: "absorb" },
|
||||
{ instanceId: "blunt#T", cardId: "blunt" },
|
||||
]);
|
||||
@@ -428,7 +428,7 @@ describe("the widened spellbook", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("round two of the owner's tactics", () => {
|
||||
describe("finishing tactics: exile and adrenaline", () => {
|
||||
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
||||
// burn round 1 and reach the bot's turn
|
||||
@@ -536,7 +536,7 @@ describe("a pact once signed is honored", () => {
|
||||
// The pact already stands; a fireball waits in hand as temptation.
|
||||
state.sustained.push({
|
||||
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
|
||||
turnsLeft: 1000, edge: undefined,
|
||||
remainingTurns: 1000, data: {},
|
||||
} as never);
|
||||
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
|
||||
@@ -1101,8 +1101,8 @@ describe("stone dead counts only the stones in play (rules rev 34)", () => {
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "pass" });
|
||||
// 3 x 1 displayed = 3 damage... minus the displayed bloodstone? It is
|
||||
// hidden, so no soak either: 15 - 3 = 12.
|
||||
// 3 x 1 displayed stone = 3; the bloodstone is hidden, so it soaks
|
||||
// nothing: 15 - 3 = 12.
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -529,7 +529,7 @@ describe("creatures walk open doorways", () => {
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "door") continue;
|
||||
const [kind, coords] = key.split(",").length ? key.split(":") as [string, string] : ["", ""];
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
state.doorStates[key] = "removed";
|
||||
|
||||
@@ -339,8 +339,9 @@ describe("an ambushed teleport carries its destination (rules rev 35)", () => {
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
state = applyCommand(state, "trapper", { type: "endTurn", draw: 0 }).ok
|
||||
? (applyCommand(state, "trapper", { type: "endTurn", draw: 0 }) as { state: typeof state }).state : state;
|
||||
r = applyCommand(state, "trapper", { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// The prey walks adjacent; the trap springs; the prey passes; they land
|
||||
// at dest. (Re-find both: applyCommand clones made the handles stale.)
|
||||
const trapperNow = state.players.find((p) => p.id === "trapper")!;
|
||||
|
||||
Reference in New Issue
Block a user