The clockwork learns three lessons from losing to Kestrel
Game six's table play, distilled into rungs. LOCK IN PLACE now roots
an enemy carrier closing on their home — the tempo theft that opened
game five, turned against the table. A pressed carrier of ANY
temperament casts MIST-BODY with its biggest number: an untouchable
delivery beats fear's radius, as the winning heist demonstrated. And
the pathfinder learns the thornbush is a jail, not a wade — entering
ends the turn, eats the next, and stings — so only a wizard with no
road at all dives in ("few will dive into one unless they are
desperate"). Also acquits Automaton II of game-six dithering: replay
showed its loop-de-loop was the true 13-step shortest path through a
walled quadrant.
Brains ship ungated; all 28 ledgers replay clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
3c70b9aedc
commit
cbd4d80dea
@@ -586,7 +586,7 @@ function pathToward(
|
|||||||
view: GameView,
|
view: GameView,
|
||||||
from: Cell,
|
from: Cell,
|
||||||
goals: Set<string>,
|
goals: Set<string>,
|
||||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {},
|
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean; throughJails?: boolean } = {},
|
||||||
): PathResult | null {
|
): PathResult | null {
|
||||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||||
@@ -611,9 +611,14 @@ function pathToward(
|
|||||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||||
if (goals.has(k)) { found = k; break; }
|
if (goals.has(k)) { found = k; break; }
|
||||||
// Hazards end a clean path but are waded through when asked to.
|
// Hazards end a clean path but are waded through when asked to.
|
||||||
|
// THORNBUSH is never a wade: entering ends the turn, eats the NEXT
|
||||||
|
// turn, and stings — a jail with foliage. Only a wizard with no
|
||||||
|
// road at all (throughJails) dives in. "Few will dive into one
|
||||||
|
// unless they are desperate."
|
||||||
const hazard = view.squareContents[k]?.kind;
|
const hazard = view.squareContents[k]?.kind;
|
||||||
|
if (hazard === "thornbush" && !opts.throughJails) continue;
|
||||||
if (!opts.throughHazards &&
|
if (!opts.throughHazards &&
|
||||||
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
(hazard === "pit" || hazard === "ooze" ||
|
||||||
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
||||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||||
next.push(to);
|
next.push(to);
|
||||||
@@ -1100,6 +1105,19 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
|||||||
// wizard hauling gold, anyone bleeding out.
|
// wizard hauling gold, anyone bleeding out.
|
||||||
const pressed = enemyNear &&
|
const pressed = enemyNear &&
|
||||||
(style === "worrier" || self.carriedTreasureId != null || self.life <= 5);
|
(style === "worrier" || self.carriedTreasureId != null || self.life <= 5);
|
||||||
|
// MIST-BODY turns a pressed carrier into an untouchable delivery — no
|
||||||
|
// attack, drop, exile, or thief finds fog. Any temperament takes this
|
||||||
|
// armor, and the biggest number rides it so the whole haul fits inside.
|
||||||
|
if (pressed && self.carriedTreasureId) {
|
||||||
|
const fog = inHand(view, "mist-body");
|
||||||
|
if (fog && !view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === view.you)) {
|
||||||
|
const biggest = numbersInHand(view).at(-1);
|
||||||
|
return {
|
||||||
|
type: "cast", instanceId: fog.instanceId,
|
||||||
|
...(biggest ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
if (pressed && style !== "berserker") {
|
if (pressed && style !== "berserker") {
|
||||||
// FEAR keeps every pursuer three spaces off.
|
// FEAR keeps every pursuer three spaces off.
|
||||||
const scare = inHand(view, "fear");
|
const scare = inHand(view, "fear");
|
||||||
@@ -1367,6 +1385,22 @@ export function automatonCommand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// LOCK IN PLACE roots a delivery mid-stride: a carrier closing on
|
||||||
|
// their home loses a whole turn of legs and warp-steps.
|
||||||
|
{
|
||||||
|
const root = inHand(view, "lock-in-place");
|
||||||
|
const runner = root ? visible.find((p) =>
|
||||||
|
p.carriedTreasureId != null &&
|
||||||
|
Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 7) : undefined;
|
||||||
|
if (root && runner) {
|
||||||
|
const smallest = numbersInHand(view)[0];
|
||||||
|
return {
|
||||||
|
type: "cast", instanceId: root.instanceId,
|
||||||
|
target: { kind: "player", playerId: runner.id },
|
||||||
|
...(smallest ? { numberInstanceIds: [smallest.instanceId] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
if (tier.buffs && !view.turn.attackUsed) {
|
if (tier.buffs && !view.turn.attackUsed) {
|
||||||
const smallest = numbersInHand(view)[0];
|
const smallest = numbersInHand(view)[0];
|
||||||
// ADRENALINE: when no single blow finishes the target but two would.
|
// ADRENALINE: when no single blow finishes the target but two would.
|
||||||
@@ -1575,7 +1609,10 @@ export function automatonCommand(
|
|||||||
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
||||||
// No clean road to anything: grit the teeth and wade the hazards,
|
// No clean road to anything: grit the teeth and wade the hazards,
|
||||||
// as any wizard would rather than stand in a pocket forever.
|
// as any wizard would rather than stand in a pocket forever.
|
||||||
pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true });
|
pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true }) ??
|
||||||
|
// And only when even the hazard roads are severed does desperation
|
||||||
|
// dive through a thornbush jail.
|
||||||
|
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true, throughJails: true });
|
||||||
// A shimmering illusion on the best crossing costs nothing to doubt:
|
// A shimmering illusion on the best crossing costs nothing to doubt:
|
||||||
// roll the free test before spending any card on that wall.
|
// roll the free test before spending any card on that wall.
|
||||||
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
||||||
|
|||||||
@@ -979,3 +979,86 @@ describe("the denial planner offers only castable blocks", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("game-six lessons from the table", () => {
|
||||||
|
function toBot(state: GameState): GameState {
|
||||||
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("roots a carrier closing on home with LOCK IN PLACE", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
state = toBot(state);
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
// The foe stands beside the bot, laden, a short walk from banking.
|
||||||
|
foe.position = { x: bot.position.x, y: bot.position.y };
|
||||||
|
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
||||||
|
gold.position = null;
|
||||||
|
gold.carriedBy = "foe";
|
||||||
|
foe.carriedTreasureId = gold.id;
|
||||||
|
foe.home = { ...bot.position };
|
||||||
|
bot.hand = [{ cardId: "lock-in-place", instanceId: "LK" }];
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "LK", target: { kind: "player", playerId: "foe" } });
|
||||||
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pressed carrier of any temperament turns to mist", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
state = toBot(state);
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
// Bot hauls the foe's gold mid-journey with the foe breathing down its neck.
|
||||||
|
const gold = state.treasures.find((t) => t.owner === "foe" && t.position)!;
|
||||||
|
gold.position = null;
|
||||||
|
gold.carriedBy = "bot";
|
||||||
|
bot.carriedTreasureId = gold.id;
|
||||||
|
bot.position = { ...foe.home };
|
||||||
|
foe.position = { ...bot.position };
|
||||||
|
bot.hand = [
|
||||||
|
{ cardId: "mist-body", instanceId: "MB" },
|
||||||
|
{ cardId: "number-3", instanceId: "N3" },
|
||||||
|
{ cardId: "number-5", instanceId: "N5" },
|
||||||
|
];
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
||||||
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "MB", numberInstanceIds: ["N5"] });
|
||||||
|
const r = applyCommand(state, "bot", cmd!);
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
expect(r.state.sustained.some((s) => s.cardId === "mist-body" && s.targetId === "bot")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detours around a thornbush jail rather than diving in", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||||
|
state = toBot(state);
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
bot.hand = [];
|
||||||
|
// Two exits from the bot's square: the short road east holds a
|
||||||
|
// thornbush, the long road stays clean. A jail is not a shortcut.
|
||||||
|
const sides = ["N", "S", "E", "W"] as const;
|
||||||
|
const open = sides.filter((s) => {
|
||||||
|
const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0),
|
||||||
|
y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) };
|
||||||
|
return state.board.cells[cellKey(n)] !== undefined;
|
||||||
|
});
|
||||||
|
expect(open.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const bushSide = open[0]!;
|
||||||
|
const freeSide = open[1]!;
|
||||||
|
for (const s2 of sides) {
|
||||||
|
state.edgeOverrides[edgeKey(bot.position, s2)] =
|
||||||
|
s2 === bushSide || s2 === freeSide ? "open" : "wall";
|
||||||
|
}
|
||||||
|
const bushCell = { x: bot.position.x + (bushSide === "E" ? 1 : bushSide === "W" ? -1 : 0),
|
||||||
|
y: bot.position.y + (bushSide === "S" ? 1 : bushSide === "N" ? -1 : 0) };
|
||||||
|
state.squareContents[cellKey(bushCell)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
// Whatever goal it picks, the first stride must not be into the bush.
|
||||||
|
expect(cmd).toMatchObject({ type: "move" });
|
||||||
|
expect((cmd as { direction: string }).direction).not.toBe(bushSide);
|
||||||
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user