The automatons learn Eric's tactics: roadwork, denial, bursts, breathing room
Per the owner's briefing on how these cards are actually played: - Roadwork on the march: DISPEL CREATION un-creates conjured walls and filled squares; STONE TO WATER melts walls and stone blocks (cast only from outside its own wave); CREATE DOOR plus a lock-opener turns a wall into a doorway; DIMENSIONAL WARP folds a 6+-step detour into one step, and the bot steps through its tokens when the far side is closer. - Path denial grows: JAM LOCK seals doors on a threat's road, ILLUSION WALL stands in for CREATE WALL, the full nuisance shelf (pit, slime, ooze, rosebush, tacks, dust cloud) joins stone and thornbush, and a BOOBYTRAP lands mid-path when nothing can reroute the thief. - Bursts: ADD joins a second number to the march; MAD DASH doubles the sprint to gold (never while carrying); POWER RUN buys the last spaces of a winning delivery with blood. - Breathing room when pressed (worrier, treasure-carrier, or bleeding): FEAR at three paces, UGLY when they crowd in, BUDDY to pacify the hound. - AROUND THE CORNER: an enemy one bend out of sight can be attacked; new bentSightFor mirrors the engine's bentLos from the viewer's knowledge. - GameView gains createdEdges (public — the table watched them conjured). All newly playable cards leave the bottom discard tier via USEFUL_NEUTRALS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
4e14926c0c
commit
4bda6087b1
@@ -8,7 +8,7 @@
|
||||
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { sightedCellsFor, type GameView } from "./view";
|
||||
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
@@ -94,6 +94,22 @@ const STONES = new Set([
|
||||
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]);
|
||||
const UNLOCKS = ["master-key", "pick-lock", "remove-lock"];
|
||||
|
||||
/** Neutrals the brain has a play for — the shed and forced discards spare
|
||||
* them. Everything here is reachable from some rung of the ladder. */
|
||||
const USEFUL_NEUTRALS = new Set([
|
||||
"speed", "interrupt", "opportunity-fire", "ward", "drop-object",
|
||||
"gift-from-above", "deja-vu", "amplify", "safe", "glue",
|
||||
// roadwork
|
||||
"destroy-wall", "pass-through-wall", "dispel-creation", "stone-to-water",
|
||||
"create-door", "dimensional-warp",
|
||||
// path denial
|
||||
"create-wall", "illusion-wall", "jam-lock", "boobytrap",
|
||||
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
|
||||
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
|
||||
// bursts and breathing room
|
||||
"mad-dash", "power-run", "add", "around-the-corner", "fear", "ugly", "buddy",
|
||||
]);
|
||||
|
||||
function me(view: GameView) {
|
||||
return view.players.find((p) => p.id === view.you)!;
|
||||
}
|
||||
@@ -122,12 +138,7 @@ function discardValue(c: CardInstance, view: GameView, style?: AutomatonStyle):
|
||||
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
|
||||
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null ||
|
||||
c.cardId === "stone-dead") return 7;
|
||||
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
|
||||
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
|
||||
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
|
||||
c.cardId === "glue" || c.cardId === "destroy-wall" ||
|
||||
c.cardId === "pass-through-wall" || c.cardId === "create-wall" ||
|
||||
c.cardId === "fill-square-with-stone" || c.cardId === "thornbush") return 6;
|
||||
if (USEFUL_NEUTRALS.has(c.cardId)) return 6;
|
||||
if (c.cardId === "lifesaver" && view.players.length > 2) return 6;
|
||||
if (c.cardId === "big-man" && style === "berserker") return 6;
|
||||
if ((c.cardId === "mist-body" || c.cardId === "shrink") && style === "worrier") return 6;
|
||||
@@ -159,11 +170,12 @@ function walkStep(
|
||||
return { to: n, viaDoor: !alreadyOpen };
|
||||
}
|
||||
|
||||
/** Walking distance from any of `starts` to every reachable cell. `avoid`
|
||||
* lets a planner ask "and if this edge or square were blocked?". */
|
||||
/** Walking distance from any of `starts` to every reachable cell. `mod`
|
||||
* lets a planner ask "and if this edge or square were blocked?" (avoid...)
|
||||
* or "and if this wall or filled square were gone?" (open...). */
|
||||
function distancesFrom(
|
||||
view: GameView, starts: Cell[], canUnlock: boolean,
|
||||
avoid?: { edge?: string; cell?: string },
|
||||
mod?: { avoidEdge?: string; avoidCell?: string; openEdge?: string; openCell?: string },
|
||||
): Map<string, number> {
|
||||
const dist = new Map<string, number>();
|
||||
let frontier: Cell[] = [];
|
||||
@@ -176,17 +188,22 @@ function distancesFrom(
|
||||
const next: Cell[] = [];
|
||||
for (const c of frontier) {
|
||||
for (const dir of SIDES) {
|
||||
if (avoid?.edge && edgeKey(c, dir) === avoid.edge) continue;
|
||||
const step = walkStep(view, c, dir, canUnlock);
|
||||
if (mod?.avoidEdge && edgeKey(c, dir) === mod.avoidEdge) continue;
|
||||
let step = walkStep(view, c, dir, canUnlock);
|
||||
if (!step && mod?.openEdge === edgeKey(c, dir)) {
|
||||
const n = neighbor(c, dir);
|
||||
if (view.board.cells[cellKey(n)]) step = { to: n, viaDoor: false };
|
||||
}
|
||||
if (!step) continue;
|
||||
const k = cellKey(step.to);
|
||||
if (avoid?.cell === k) continue;
|
||||
if (mod?.avoidCell === k) continue;
|
||||
if (dist.has(k)) continue;
|
||||
if (view.squareContents[k]?.kind === "stone") continue;
|
||||
const opened = mod?.openCell === k;
|
||||
if (view.squareContents[k]?.kind === "stone" && !opened) continue;
|
||||
dist.set(k, depth);
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime") continue;
|
||||
if (!opened && (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime")) continue;
|
||||
next.push(step.to);
|
||||
}
|
||||
}
|
||||
@@ -246,11 +263,18 @@ 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");
|
||||
const stone = inHand(view, "fill-square-with-stone");
|
||||
const bush = inHand(view, "thornbush");
|
||||
if (!wall && !stone && !bush) return null;
|
||||
const wall = inHand(view, "create-wall") ?? inHand(view, "illusion-wall");
|
||||
const jam = inHand(view, "jam-lock");
|
||||
const filler = DENIAL_FILLERS.map((id) => inHand(view, id)).find(Boolean);
|
||||
const trap = inHand(view, "boobytrap");
|
||||
if (!wall && !jam && !filler && !trap) return null;
|
||||
|
||||
// Threats, most urgent first: my treasure being carried to an enemy home,
|
||||
// then an enemy within reach of my treasure on the floor.
|
||||
@@ -305,8 +329,8 @@ function pathDenial(view: GameView): Command | null {
|
||||
if (cellKey(path[path.length - 1]!) !== cellKey(enemy)) continue;
|
||||
|
||||
let best: { cmd: Command; gain: number } | null = null;
|
||||
const consider = (cmd: Command, avoid: { edge?: string; cell?: string }) => {
|
||||
const after = distancesFrom(view, [enemy], true, avoid).get(cellKey(goal)) ?? Infinity;
|
||||
const consider = (cmd: Command, mod: { avoidEdge?: string; avoidCell?: string }) => {
|
||||
const after = distancesFrom(view, [enemy], true, mod).get(cellKey(goal)) ?? Infinity;
|
||||
const gain = after - oldDist;
|
||||
if (gain >= 3 && (best === null || gain > best.gain)) best = { cmd, gain };
|
||||
};
|
||||
@@ -314,31 +338,182 @@ function pathDenial(view: GameView): Command | null {
|
||||
// path runs goal -> enemy; block between consecutive squares.
|
||||
const a = path[i + 1]!, b = path[i]!;
|
||||
if (Math.abs(a.x - b.x) + Math.abs(a.y - b.y) !== 1) continue; // a warp hop, not a corridor
|
||||
if (wall) {
|
||||
const side: Side = b.x > a.x ? "E" : b.x < a.x ? "W" : b.y > a.y ? "S" : "N";
|
||||
const k = edgeKey(a, side);
|
||||
if ((view.board.edges[k] ?? "open") === "open" &&
|
||||
(sighted.has(cellKey(a)) || sighted.has(cellKey(b)))) {
|
||||
consider(
|
||||
{ type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: a, side } },
|
||||
{ edge: k },
|
||||
);
|
||||
}
|
||||
const side: Side = b.x > a.x ? "E" : b.x < a.x ? "W" : b.y > a.y ? "S" : "N";
|
||||
const k = edgeKey(a, side);
|
||||
const edgeSighted = sighted.has(cellKey(a)) || sighted.has(cellKey(b));
|
||||
if (wall && (view.board.edges[k] ?? "open") === "open" && edgeSighted) {
|
||||
consider(
|
||||
{ type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: a, side } },
|
||||
{ avoidEdge: k },
|
||||
);
|
||||
}
|
||||
// A door on their road jams shut for good — cheaper than a wall.
|
||||
if (jam && (view.board.edges[k] ?? "open") === "door" && edgeSighted &&
|
||||
view.doorStates[k] !== "jammed" && view.doorStates[k] !== "removed") {
|
||||
consider(
|
||||
{ type: "cast", instanceId: jam.instanceId, target: { kind: "edge", cell: a, side } },
|
||||
{ avoidEdge: k },
|
||||
);
|
||||
}
|
||||
const filler = stone ?? bush;
|
||||
// Never brick the square the treasure needs to stay reachable on.
|
||||
if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
|
||||
consider(
|
||||
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
|
||||
{ cell: cellKey(b) },
|
||||
{ avoidCell: cellKey(b) },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (best !== null) return (best as { cmd: Command; gain: number }).cmd;
|
||||
|
||||
// Nothing reroutes them? A BOOBYTRAP mid-path punishes the march instead:
|
||||
// the real token on their road, three bluffs beside it.
|
||||
if (trap && path.length >= 3 && view.boobytraps.every((b) => b.casterId !== view.you)) {
|
||||
const mid = path[Math.floor(path.length / 2)]!;
|
||||
const spots: Cell[] = [mid];
|
||||
for (const dir of SIDES) {
|
||||
if (spots.length === 4) break;
|
||||
const n = neighbor(mid, dir);
|
||||
const nk = cellKey(n);
|
||||
if (view.board.cells[nk] && view.squareContents[nk]?.kind !== "stone" &&
|
||||
!spots.some((s) => cellKey(s) === nk)) {
|
||||
spots.push(n);
|
||||
}
|
||||
}
|
||||
if (spots.length === 4) {
|
||||
return { type: "cast", instanceId: trap.instanceId, params: { cells: spots } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beyond DESTROY WALL, the hand holds other roadwork:
|
||||
* - DISPEL CREATION un-creates a conjured wall or a filled square.
|
||||
* - STONE TO WATER melts any wall or stone block — cast from outside the
|
||||
* wave, which would otherwise wash the caster backward.
|
||||
* - CREATE DOOR plus a lock-opener turns a wall into a doorway.
|
||||
* - DIMENSIONAL WARP folds a long march into a single step.
|
||||
* Costs one card (two for the door), so only a real shortcut pays.
|
||||
*/
|
||||
function roadworkPlan(
|
||||
view: GameView, self: { position: Cell }, goals: Set<string>,
|
||||
canUnlock: boolean, normalDist: number,
|
||||
): Command | null {
|
||||
if (goals.size === 0) return null;
|
||||
const dispel = inHand(view, "dispel-creation");
|
||||
const s2w = inHand(view, "stone-to-water");
|
||||
const cdoor = canUnlock ? inHand(view, "create-door") : undefined;
|
||||
const dwarp = inHand(view, "dimensional-warp");
|
||||
if (!dispel && !s2w && !cdoor && !dwarp) return null;
|
||||
|
||||
const sighted = sightedCellsFor(view);
|
||||
const dHere = distancesFrom(view, [self.position], canUnlock);
|
||||
const goalCells = [...goals].map((k) => {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
return { x, y };
|
||||
});
|
||||
const dGoal = distancesFrom(view, goalCells, canUnlock);
|
||||
let best: { cmd: Command; total: number } | null = null;
|
||||
const offer = (cmd: Command, total: number, minGain: number) => {
|
||||
const gain = normalDist - total;
|
||||
if (gain >= minGain && (best === null || total < best.total)) best = { cmd, total };
|
||||
};
|
||||
|
||||
// Wall edges: dispellable if conjured; meltable by STONE TO WATER always.
|
||||
const created = new Set(view.createdEdges);
|
||||
for (const [key, state] of Object.entries(view.board.edges)) {
|
||||
if (state !== "wall" && state !== "firewall") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
const side: Side = kind === "V" ? "E" : "S";
|
||||
const beyond = neighbor(cell, side);
|
||||
if (!view.board.cells[cellKey(beyond)]) continue;
|
||||
if (!sighted.has(cellKey(cell)) && !sighted.has(cellKey(beyond))) continue;
|
||||
let total = Infinity;
|
||||
for (const [a, b] of [[cell, beyond], [beyond, cell]] as [Cell, Cell][]) {
|
||||
const da = dHere.get(cellKey(a));
|
||||
const db = dGoal.get(cellKey(b));
|
||||
if (da !== undefined && db !== undefined) total = Math.min(total, da + 1 + db);
|
||||
}
|
||||
if (total === Infinity) continue;
|
||||
const target = { kind: "edge" as const, cell, side };
|
||||
if (dispel && created.has(key)) {
|
||||
offer({ type: "cast", instanceId: dispel.instanceId, target }, total, 4);
|
||||
}
|
||||
if (s2w && state === "wall") {
|
||||
// The collapsing wave covers two cells each side of the wall — melt
|
||||
// it only from outside its reach.
|
||||
const away = opposite(side);
|
||||
const wet = [cell, neighbor(cell, away), beyond, neighbor(beyond, side)];
|
||||
if (!wet.some((w) => cellKey(w) === cellKey(self.position))) {
|
||||
offer({ type: "cast", instanceId: s2w.instanceId, target }, total, 4);
|
||||
}
|
||||
}
|
||||
if (cdoor && state === "wall") {
|
||||
// Two cards (door + key), so the shortcut must earn more.
|
||||
offer({ type: "cast", instanceId: cdoor.instanceId, target }, total, 6);
|
||||
}
|
||||
}
|
||||
|
||||
// Filled squares: melting or dispelling one joins its open neighbors.
|
||||
for (const [k, content] of Object.entries(view.squareContents)) {
|
||||
if (!sighted.has(k)) continue;
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
const c = { x, y };
|
||||
const openNeighbors = SIDES
|
||||
.map((dir) => ({ dir, n: neighbor(c, dir) }))
|
||||
.filter(({ dir, n }) =>
|
||||
view.board.cells[cellKey(n)] &&
|
||||
(view.board.edges[edgeKey(c, dir)] ?? "open") === "open");
|
||||
let total = Infinity;
|
||||
for (const { n: n1 } of openNeighbors) {
|
||||
for (const { n: n2 } of openNeighbors) {
|
||||
if (cellKey(n1) === cellKey(n2)) continue;
|
||||
const da = dHere.get(cellKey(n1));
|
||||
const db = dGoal.get(cellKey(n2));
|
||||
if (da !== undefined && db !== undefined) total = Math.min(total, da + 2 + db);
|
||||
}
|
||||
}
|
||||
if (total === Infinity) continue;
|
||||
const target = { kind: "cell" as const, cell: c };
|
||||
if (dispel) offer({ type: "cast", instanceId: dispel.instanceId, target }, total, 4);
|
||||
if (s2w && content.kind === "stone") {
|
||||
// The melted block bursts a range-4 wave down all four corridors.
|
||||
const inWave = (self.position.x === x || self.position.y === y) &&
|
||||
Math.abs(self.position.x - x) + Math.abs(self.position.y - y) <= 4;
|
||||
if (!inWave) offer({ type: "cast", instanceId: s2w.instanceId, target }, total, 4);
|
||||
}
|
||||
}
|
||||
|
||||
if (best === null && dwarp) {
|
||||
// A long march folds through a warp pair: one token underfoot, one by
|
||||
// the goal. Tokens shun home bases and solid stone.
|
||||
const legal = (c: Cell) =>
|
||||
view.board.cells[cellKey(c)] !== undefined &&
|
||||
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
|
||||
view.squareContents[cellKey(c)]?.kind !== "stone" &&
|
||||
!view.dimWarps.some((w) => cellKey(w.a) === cellKey(c) || cellKey(w.b) === cellKey(c));
|
||||
if (legal(self.position)) {
|
||||
let far: { cell: Cell; d: number } | null = null;
|
||||
for (const [k, d] of dGoal) {
|
||||
if (d > 2) continue;
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (!legal({ x, y })) continue;
|
||||
if (far === null || d < far.d) far = { cell: { x, y }, d };
|
||||
}
|
||||
if (far && normalDist - (1 + far.d) >= 6) {
|
||||
return {
|
||||
type: "cast", instanceId: dwarp.instanceId,
|
||||
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return best !== null ? (best as { cmd: Command; total: number }).cmd : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall worth a DESTROY WALL on the march: the one whose removal most
|
||||
* shortens the road to the objectives. The card wants line of sight to the
|
||||
@@ -756,6 +931,43 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
}
|
||||
}
|
||||
}
|
||||
// Breathing room for anyone pressed: the worrier by temperament, any
|
||||
// wizard hauling gold, anyone bleeding out.
|
||||
const pressed = enemyNear &&
|
||||
(style === "worrier" || self.carriedTreasureId != null || self.life <= 5);
|
||||
if (pressed && style !== "berserker") {
|
||||
// FEAR keeps every pursuer three spaces off.
|
||||
const scare = inHand(view, "fear");
|
||||
if (scare && !view.sustained.some((s) => s.cardId === "fear" && s.targetId === view.you)) {
|
||||
return {
|
||||
type: "cast", instanceId: scare.instanceId,
|
||||
...(mid ? { numberInstanceIds: [mid.instanceId] } : {}),
|
||||
};
|
||||
}
|
||||
// UGLY shoves everyone in sight away — best when they are on top of you.
|
||||
const hideous = inHand(view, "ugly");
|
||||
if (hideous) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const crowded = livingEnemies(view).some((p) =>
|
||||
sighted.has(cellKey(p.position)) &&
|
||||
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 2);
|
||||
if (crowded) return { type: "cast", instanceId: hideous.instanceId };
|
||||
}
|
||||
// BUDDY pacifies the nearest hound: they cannot strike first.
|
||||
const pact = inHand(view, "buddy");
|
||||
if (pact) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const hound = livingEnemies(view)
|
||||
.filter((p) => sighted.has(cellKey(p.position)) &&
|
||||
!view.sustained.some((s) => s.cardId === "buddy" && s.casterId === view.you && s.targetId === p.id))
|
||||
.sort((a, b) =>
|
||||
(Math.abs(a.position.x - self.position.x) + Math.abs(a.position.y - self.position.y)) -
|
||||
(Math.abs(b.position.x - self.position.x) + Math.abs(b.position.y - self.position.y)))[0];
|
||||
if (hound) {
|
||||
return { type: "cast", instanceId: pact.instanceId, target: { kind: "player", playerId: hound.id } };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
|
||||
const myFloorTreasure = tier.guardGold
|
||||
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||
@@ -900,6 +1112,19 @@ export function automatonCommand(
|
||||
return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
// AROUND THE CORNER: an enemy tucked one bend out of sight is not safe.
|
||||
if (visible.length === 0 && tier.buffs) {
|
||||
const corner = inHand(view, "around-the-corner");
|
||||
if (corner) {
|
||||
for (const p of livingEnemies(view)) {
|
||||
if (!bentSightFor(view, self.position, p.position)) continue;
|
||||
const spell = bestAttack(view, p.id, tier);
|
||||
if (spell && spell.type === "cast") {
|
||||
return { ...spell, aroundCornerInstanceId: corner.instanceId };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No shot at a wizard: raise a creature to do the walking.
|
||||
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
|
||||
if (summon && livingEnemies(view).length > 0) {
|
||||
@@ -957,6 +1182,22 @@ export function automatonCommand(
|
||||
return { type: "cast", instanceId: ptw.instanceId };
|
||||
}
|
||||
}
|
||||
// The rest of the toolbox: dispel, melt, door-and-key, warp tokens.
|
||||
if (!view.turn.actionsEnded) {
|
||||
const work = roadworkPlan(view, self, objectives, canUnlock, path?.distance ?? Infinity);
|
||||
if (work) return work;
|
||||
}
|
||||
// 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;
|
||||
const dFar = distancesFrom(view, [dest], canUnlock);
|
||||
const farBest = Math.min(...[...objectives].map((g) => dFar.get(g) ?? Infinity));
|
||||
if (farBest + 1 < (path?.distance ?? Infinity)) return { type: "warpStep" };
|
||||
}
|
||||
}
|
||||
if (path) {
|
||||
// A locked door on the very next step: use the key first.
|
||||
if (path.doorAhead) {
|
||||
@@ -971,18 +1212,18 @@ export function automatonCommand(
|
||||
}
|
||||
} else {
|
||||
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
const numbers = numbersInHand(view);
|
||||
// The war chest: when an attack in hand wants a number, the
|
||||
// biggest one is reserved — a waterbolt unfired outbids a longer
|
||||
// march, and walking is free next turn.
|
||||
const wantsNumber = view.yourHand.some((c) => {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) return false;
|
||||
return atk.perNumber || atk.needsNumber === true ||
|
||||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
|
||||
});
|
||||
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
|
||||
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) {
|
||||
const numbers = numbersInHand(view);
|
||||
// The war chest: when an attack in hand wants a number, the
|
||||
// biggest one is reserved — a waterbolt unfired outbids a longer
|
||||
// march, and walking is free next turn.
|
||||
const wantsNumber = view.yourHand.some((c) => {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) return false;
|
||||
return atk.perNumber || atk.needsNumber === true ||
|
||||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
|
||||
});
|
||||
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
|
||||
const helper = spendable.find(
|
||||
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
|
||||
) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined);
|
||||
@@ -990,6 +1231,43 @@ export function automatonCommand(
|
||||
return { type: "playNumberForMovement", instanceId: helper.instanceId };
|
||||
}
|
||||
}
|
||||
// ADD joins a second number when the first still leaves the goal shy.
|
||||
if (view.turn.numberPlayedForMovement && !view.turn.movementAddUsed &&
|
||||
path.distance > movesLeft && spendable.length > 0) {
|
||||
const add = inHand(view, "add");
|
||||
if (add) {
|
||||
const second = spendable.find(
|
||||
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
|
||||
) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined);
|
||||
if (second) {
|
||||
return {
|
||||
type: "playNumberForMovement",
|
||||
instanceId: second.instanceId, addInstanceId: add.instanceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// MAD DASH doubles what the legs already have — for the sprint to
|
||||
// gold that would otherwise take another turn. Not while carrying.
|
||||
if (path.distance > movesLeft && path.distance >= movesLeft + 3 &&
|
||||
path.distance <= movesLeft * 2 && !self.carriedTreasureId &&
|
||||
!view.turn.actionsEnded) {
|
||||
const dash = inHand(view, "mad-dash");
|
||||
if (dash) return { type: "cast", instanceId: dash.instanceId };
|
||||
}
|
||||
// POWER RUN buys the last spaces of a winning delivery with blood.
|
||||
if (path.distance > movesLeft && self.carriedTreasureId && !view.turn.actionsEnded) {
|
||||
const carried = view.treasures.find((t) => t.id === self.carriedTreasureId);
|
||||
const stolenAtHome = view.treasures.filter(
|
||||
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home),
|
||||
).length;
|
||||
const points = path.distance - movesLeft;
|
||||
if (carried && carried.owner !== you && stolenAtHome >= 1 &&
|
||||
points <= 4 && self.life - points >= 5) {
|
||||
const run = inHand(view, "power-run");
|
||||
if (run) return { type: "cast", instanceId: run.instanceId, params: { points } };
|
||||
}
|
||||
}
|
||||
return { type: "move", direction: path.dir };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface GameView {
|
||||
openDoorEdges: string[];
|
||||
/** Door edges held open by a standing wizard. */
|
||||
heldDoorEdges: string[];
|
||||
/** Edges conjured into being (walls, doors, firewalls) - dispellable. */
|
||||
createdEdges: string[];
|
||||
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
||||
knownIllusionEdges: string[];
|
||||
creatures: CreatureState[];
|
||||
@@ -143,6 +145,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
wallDamage: { ...state.wallDamage },
|
||||
openDoorEdges: [...state.openDoorEdges],
|
||||
heldDoorEdges: state.heldDoors.map((h) => h.key),
|
||||
createdEdges: Object.keys(state.createdEdges),
|
||||
knownIllusionEdges,
|
||||
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
||||
wandCharges: { ...state.wandCharges },
|
||||
@@ -215,6 +218,25 @@ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace
|
||||
return traceSight(board, from, to, blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
|
||||
* sees a middle square which sees the target — mirroring the engine's
|
||||
* bentLos so a client can predict whether the modifier will land.
|
||||
*/
|
||||
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
if (blockers[key]) continue;
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if (sightBetween(board, from, mid, blockers) && sightBetween(board, mid, to, blockers)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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:
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("a clogged hand gets shed, not hoarded", () => {
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
// Seven situational neutrals the brain has no play for: a dead hand.
|
||||
bot.hand = Array.from({ length: 7 }, (_, i) => (
|
||||
{ instanceId: `illusion-wall#${i}`, cardId: "illusion-wall" }
|
||||
{ instanceId: `rotate-sector#${i}`, cardId: "rotate-sector" }
|
||||
));
|
||||
// Walk the bot's turn until it wants to end: it must shed before drawing.
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
@@ -244,9 +244,9 @@ describe("the clockwork wields destroy wall", () => {
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand = [
|
||||
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
|
||||
{ instanceId: "buddy#T", cardId: "buddy" },
|
||||
{ instanceId: "ugly#T", cardId: "ugly" },
|
||||
{ instanceId: "fear#T", cardId: "fear" },
|
||||
{ instanceId: "trader#T", cardId: "trader" },
|
||||
{ instanceId: "strength#T", cardId: "strength" },
|
||||
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
|
||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||
{ instanceId: "fireball#T", cardId: "fireball" },
|
||||
{ instanceId: "number-3#T", cardId: "number-3" },
|
||||
@@ -330,3 +330,47 @@ describe("the clockwork denies the road", () => {
|
||||
expect(target.cell.x).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widened spellbook", () => {
|
||||
it("dispels a conjured wall standing between it and the only road", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { x: 4, y: 4 };
|
||||
for (const side of ["N", "S", "E", "W"] as const) {
|
||||
const k = edgeKey(bot.position, side);
|
||||
state.edgeOverrides[k] = "wall";
|
||||
state.createdEdges[k] = true;
|
||||
}
|
||||
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
|
||||
bot.hand = [{ instanceId: "dispel-creation#T", cardId: "dispel-creation" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "dispel-creation#T" });
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
});
|
||||
|
||||
it("offers a buddy pact to the hound at its heels", () => {
|
||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
||||
while (actingSeat(state) !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const hound = state.players.find((p) => p.id === "other")!;
|
||||
hound.position = { ...bot.position };
|
||||
bot.hand = [{ instanceId: "buddy#T", cardId: "buddy" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "worrier", "archmage");
|
||||
expect(cmd).toEqual({
|
||||
type: "cast", instanceId: "buddy#T",
|
||||
target: { kind: "player", playerId: "other" },
|
||||
});
|
||||
const r = applyCommand(state, "bot", cmd!);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user