Round two of the owner's tactics: exile, wand-slip, finishers, boosts

- TELEPORT OPPONENT exiles a delivery in progress — the thief of the
  bot's gold, or any carrier within six of home — to the square farthest
  (by walking distance) from the victim's own home; a sealed pocket wins
  outright. No LOS needed for the destination, per the card.
- WARP WAND joins the roadwork: a wall slips open for a 4+-step shortcut
  when the crossing fits this turn's remaining legs (the wall returns at
  end of turn); charges itself with the smallest number on first use.
- ADRENALINE is the finisher: cast when no single attack in hand kills
  the target but the top two together do; the attack window now honors
  the second swing.
- STRENGTH doubles a thrown dagger or rock exactly when that turns a
  wound into a kill.
- EXTEND rides afflictions, doubling the misery's stay.
- MEGA-MONSTER doubles a pet's stride (movement over life) so the
  menagerie actually catches people.
All six leave the bottom discard tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-17 20:33:45 -04:00
co-authored by Claude Fable 5
parent 4bda6087b1
commit 3087f00bdc
2 changed files with 176 additions and 8 deletions
+123 -8
View File
@@ -108,6 +108,8 @@ const USEFUL_NEUTRALS = new Set([
"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",
// finishers and boosts
"adrenaline", "strength", "extend", "mega-monster", "warp-wand",
]);
function me(view: GameView) {
@@ -137,7 +139,7 @@ function discardValue(c: CardInstance, view: GameView, style?: AutomatonStyle):
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
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;
c.cardId === "stone-dead" || c.cardId === "teleport-opponent") return 7;
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;
@@ -398,14 +400,21 @@ function pathDenial(view: GameView): Command | null {
*/
function roadworkPlan(
view: GameView, self: { position: Cell }, goals: Set<string>,
canUnlock: boolean, normalDist: number,
canUnlock: boolean, normalDist: number, movesLeft: 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;
// WARP WAND holds a wall open only until the turn ends — the crossing
// must be reachable on this turn's legs, and the wand wants a charge.
const wandCard = inHand(view, "warp-wand");
const wandNumber = numbersInHand(view)[0];
const wand = wandCard && !view.turn.wandsUsed.includes(wandCard.instanceId) &&
(view.wandCharges[wandCard.instanceId] != null || wandNumber)
? wandCard : undefined;
if (!dispel && !s2w && !cdoor && !dwarp && !wand) return null;
const sighted = sightedCellsFor(view);
const dHere = distancesFrom(view, [self.position], canUnlock);
@@ -432,16 +441,27 @@ function roadworkPlan(
if (!view.board.cells[cellKey(beyond)]) continue;
if (!sighted.has(cellKey(cell)) && !sighted.has(cellKey(beyond))) continue;
let total = Infinity;
let nearSteps = 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 (da !== undefined && db !== undefined && da + 1 + db < total) {
total = da + 1 + db;
nearSteps = da;
}
}
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 (wand && state === "wall" && nearSteps + 1 <= movesLeft) {
const uncharged = view.wandCharges[wand.instanceId] == null;
offer({
type: "cast", instanceId: wand.instanceId, target,
...(uncharged && wandNumber ? { numberInstanceIds: [wandNumber.instanceId] } : {}),
}, total, 4);
}
if (s2w && state === "wall") {
// The collapsing wave covers two cells each side of the wall — melt
// it only from outside its reach.
@@ -832,10 +852,12 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Comma
return best?.cmd ?? null;
}
/** An affliction worth casting when no damage lands, mid number attached. */
/** An affliction worth casting when no damage lands, mid number attached.
* EXTEND doubles the misery's stay when the hand can spare it. */
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)];
const extend = inHand(view, "extend");
for (const c of view.yourHand) {
const aff = AFFLICTIONS[c.cardId];
if (!aff) continue;
@@ -845,11 +867,41 @@ function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): C
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}),
...(aff.withNumber && mid && extend ? { extendInstanceId: extend.instanceId } : {}),
};
}
return null;
}
/**
* The cruelest legal square for TELEPORT OPPONENT: farthest from the
* victim's home by walking distance — a sealed pocket, if one exists. The
* destination needs no line of sight, so the whole maze is on the table.
*/
function exileCell(view: GameView, victim: { home: Cell; position: Cell }): Cell | null {
const dHome = distancesFrom(view, [victim.home], true);
const self = me(view);
const UNREACHABLE = 1000;
let best: { cell: Cell; score: number; tie: number } | null = null;
for (const k of Object.keys(view.board.cells)) {
if (view.squareContents[k]?.kind === "stone") continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
if (view.treasures.some((t) => t.position && cellKey(t.position) === k)) continue;
if (view.players.some((p) => p.alive && cellKey(p.position) === k)) continue;
const d = dHome.get(k);
const score = d === undefined ? UNREACHABLE : d;
const tie = Math.abs(x - self.position.x) + Math.abs(y - self.position.y);
if (!best || score > best.score || (score === best.score && tie > best.tie)) {
best = { cell: { x, y }, score, tie };
}
}
// Only worth the turn's attack if it truly worsens their day.
const now = dHome.get(cellKey(victim.position)) ?? 0;
if (best && (best.score >= UNREACHABLE || best.score >= now + 5)) return best.cell;
return null;
}
/** An empty, sighted square near the target for a summoned creature. */
function summonSpot(view: GameView, near: Cell): Cell | null {
const sighted = sightedCellsFor(view);
@@ -1069,6 +1121,23 @@ export function automatonCommand(
const care = selfCare(view, style, tier);
if (care) return care;
// MEGA-MONSTER: a doubled stride turns a pet into a bloodhound.
if (tier.buffs && livingEnemies(view).length > 0) {
const mm = inHand(view, "mega-monster");
if (mm) {
const sighted = sightedCellsFor(view);
const pet = view.creatures.find((c) =>
c.controllerId === you && c.kind !== "shadow" && c.kind !== "alter-ego" &&
c.movesPerTurn <= 3 && sighted.has(cellKey(c.position)));
if (pet) {
return {
type: "cast", instanceId: mm.instanceId,
target: { kind: "creature", creatureId: pet.id }, params: { boost: "movement" },
};
}
}
}
// Command the menagerie: creatures march and maul before the wizard moves.
for (const c of view.creatures) {
if (c.controllerId !== you || c.justCreated) continue;
@@ -1087,7 +1156,10 @@ export function automatonCommand(
const thief = thiefOfMine(view);
// One attack per turn: the thief of my gold dies first, then the weakest.
if (!view.turn.attackUsed && view.turn.round > 1) {
// ADRENALINE stretches that to two while it lasts.
const adrenalized = view.sustained.some((s) => s.cardId === "adrenaline" && s.targetId === you);
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
view.turn.round > 1) {
const sighted = sightedCellsFor(view);
const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position)));
if (visible.length > 0) {
@@ -1104,6 +1176,48 @@ export function automatonCommand(
};
}
}
// TELEPORT OPPONENT exiles a delivery in progress: the thief of my
// gold, or any carrier closing on their home.
const exile = inHand(view, "teleport-opponent");
if (exile) {
const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true;
if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
});
const dest = carrier ? exileCell(view, carrier) : null;
if (carrier && dest) {
return {
type: "cast", instanceId: exile.instanceId,
target: { kind: "player", playerId: carrier.id }, params: { cell: dest },
};
}
}
if (tier.buffs && !view.turn.attackUsed) {
const smallest = numbersInHand(view)[0];
// ADRENALINE: when no single blow finishes the target but two would.
const adr = adrenalized ? undefined : inHand(view, "adrenaline");
if (adr && smallest) {
const bases = view.yourHand
.filter((c) => ATTACKS[c.cardId] && !ATTACKS[c.cardId]!.needsNumber && c.cardId !== "heave-ho")
.map((c) => ATTACKS[c.cardId]!.base)
.sort((a, b) => b - a);
if (bases.length >= 2 && bases[0]! < target.life && bases[0]! + bases[1]! >= target.life) {
return { type: "cast", instanceId: adr.instanceId, numberInstanceIds: [smallest.instanceId] };
}
}
// STRENGTH: doubled steel where it turns a wound into a finisher.
const strengthUp = view.sustained.some((s) => s.cardId === "strength" && s.targetId === you);
const str = strengthUp ? undefined : inHand(view, "strength");
if (str && smallest) {
const physBase = Math.max(0, ...view.yourHand
.filter((c) => c.cardId === "dagger" || c.cardId === "large-rock")
.map((c) => ATTACKS[c.cardId]!.base));
if (physBase > 0 && physBase < target.life && physBase * 2 >= target.life) {
return { type: "cast", instanceId: str.instanceId, numberInstanceIds: [smallest.instanceId] };
}
}
}
const spell = bestAttack(view, target.id, tier);
if (spell) return spell;
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
@@ -1182,9 +1296,10 @@ export function automatonCommand(
return { type: "cast", instanceId: ptw.instanceId };
}
}
// The rest of the toolbox: dispel, melt, door-and-key, warp tokens.
// The rest of the toolbox: dispel, melt, door-and-key, wand, warp tokens.
if (!view.turn.actionsEnded) {
const work = roadworkPlan(view, self, objectives, canUnlock, path?.distance ?? Infinity);
const work = roadworkPlan(view, self, objectives, canUnlock,
path?.distance ?? Infinity, view.turn.movementAllowance - view.turn.movementUsed);
if (work) return work;
}
// Standing on a warp token whose far side is closer to the goal: step in.
+53
View File
@@ -374,3 +374,56 @@ describe("the widened spellbook", () => {
if (!r.ok) throw new Error(r.error);
});
});
describe("round two of the owner's tactics", () => {
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
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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 thief = state.players.find((p) => p.id === "other")!;
const t = state.treasures.find((t) => t.owner === "bot")!;
t.position = null;
t.carriedBy = "other";
thief.carriedTreasureId = t.id;
// The thief is a step from delivering; the bot watches from beside them.
thief.position = { x: thief.home.x, y: thief.home.y === 0 ? 1 : thief.home.y - 1 };
bot.position = { ...thief.position };
bot.hand = [{ instanceId: "teleport-opponent#T", cardId: "teleport-opponent" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "teleport-opponent#T" });
const dest = (cmd as { params: { cell: { x: number; y: number } } }).params.cell;
// The chosen square is a long march from the thief's own home.
const d = Math.abs(dest.x - thief.home.x) + Math.abs(dest.y - thief.home.y);
expect(d).toBeGreaterThanOrEqual(5);
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
it("casts adrenaline when two blows finish what one cannot", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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 prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
prey.life = 7; // fireball (5) alone cannot; fireball + dagger (3) can
bot.hand = [
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "dagger#T", cardId: "dagger" },
{ instanceId: "number-2#T", cardId: "number-2" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "adrenaline#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
});