diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index d626baa..394eb99 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -9,6 +9,7 @@ import { cardDef, type CardInstance } from "./cards"; import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; import { bentSightFor, sightedCellsFor, type GameView } from "./view"; +import { wallIgnoringDistance } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game"; export type AutomatonStyle = "hunter" | "berserker" | "worrier"; @@ -293,6 +294,17 @@ function pathDenial(view: GameView): Command | null { if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! }); } } + // Enemy gold banked at MY home: my score, snatchable by anyone. A + // raider closing on the bank gets the same road-lengthening treatment. + const me2 = me(view); + const banked = view.treasures.find( + (t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home)); + if (banked) { + for (const e of livingEnemies(view)) { + const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y); + if (d <= 6) threats.push({ enemy: e.position, goal: me2.home }); + } + } if (threats.length === 0) return null; const sighted = sightedCellsFor(view); @@ -630,7 +642,16 @@ function treasureGoals(view: GameView): Set { } for (const t of view.treasures) { if (!t.position || t.carriedBy) continue; - if (t.owner === view.you) continue; + if (t.owner === view.you) { + // REPOSSESSION: my own gold banked at an enemy's home is a point on + // THEIR scoreboard. Marching to take it back is worth the detour + // only when that enemy is one delivery from winning — constant + // re-stealing farms nothing and stalls the whole table. + const banker = view.players.find( + (p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!)); + if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position)); + continue; + } if (cellKey(t.position) === cellKey(self.home)) continue; goals.add(cellKey(t.position)); } @@ -731,7 +752,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma return { type: "pass" }; } - const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift; + // The LAST counter in hand is a treasure of its own: at a healthy life + // total it waits for something big rather than answering every jab — + // patience the clockwork's best opponent taught it, three counters at + // a time. (Lethal blows override this below.) + const counterCount = view.yourHand.filter((c) => { + const t = cardDef(c.cardId).cardType; + return t === "counteraction" || t === "neutral/counteraction"; + }).length; + const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0; + const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold; const attackId = stack.attackCard?.cardId ?? null; const atk = attackId ? ATTACKS[attackId] : null; const affliction = attackId ? AFFLICTIONS[attackId] != null : false; @@ -830,6 +860,24 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma /** The best attack available against a visible target, numbers and amplify included. */ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null { const numbers = numbersInHand(view); + // TURN THEFT: against a carrier one delivery from winning, a + // LIGHTNING BLAST's stolen turn outranks any point total — the number + // rides along so soaks cannot zero the damage and void the stun. + { + const carrier = view.players.find((p) => p.id === targetId); + const bolt = inHand(view, "lightning-blast"); + const biggestNum = numbers[numbers.length - 1]; + if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) { + return { + damage: cardDef(biggestNum.cardId).value ?? 1, + cmd: { + type: "cast", instanceId: bolt.instanceId, + target: { kind: "player", playerId: targetId }, + numberInstanceIds: [biggestNum.instanceId], + }, + }; + } + } const biggest = numbers[numbers.length - 1]; const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0; const target = view.players.find((p) => p.id === targetId)!; @@ -1078,6 +1126,43 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm } } } + // INFRASTRUCTURE: a wormhole anchored beside home turns every future + // delivery into a three-move stroll — the pattern that won its best + // opponent two games. Placed early, from home's doorstep, far mouth + // by the richest distant gold. + { + const dwarp = inHand(view, "dimensional-warp"); + const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1; + if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) { + 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)] && + !view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) && + (view.groundObjects[cellKey(c)] ?? []).length === 0; + const goals = treasureGoals(view); + if (legal(self.position) && goals.size > 0) { + const dHome = distancesFrom(view, [self.home], false); + const sighted = sightedCellsFor(view); + let far: { cell: Cell; worth: number } | null = null; + for (const g of goals) { + const [gx, gy] = g.split(",").map(Number) as [number, number]; + for (const side of SIDES) { + const c = neighbor({ x: gx, y: gy }, side); + if (!legal(c) || !sighted.has(cellKey(c))) continue; + const worth = dHome.get(cellKey(c)) ?? 0; + if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth }; + } + } + if (far) { + return { + type: "cast", instanceId: dwarp.instanceId, + params: { cell: self.position }, target: { kind: "cell", cell: far.cell }, + }; + } + } + } + } // 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) @@ -1170,10 +1255,13 @@ export function automatonCommand( if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; if (!self.carriedTreasureId) { const prize = view.treasures.find( - (t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here && - here !== cellKey(self.home), + (t) => t.position && !t.carriedBy && cellKey(t.position) === here && + here !== cellKey(self.home) && + (t.owner !== you || + // Repossessing my own gold off an enemy's home square. + view.players.some((p) => p.id !== you && cellKey(p.home) === here)), ); - if (prize) return { type: "pickUpTreasure" }; + if (prize) return { type: "pickUpTreasure", treasureId: prize.id }; } // Wounded clockwork teleports clear of visible hunters. @@ -1411,6 +1499,24 @@ export function automatonCommand( } } + // TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away + // but farther by boot — blink in and drop next command. The jump beats + // any ambush camped on the walking road. + if (self.carriedTreasureId && !view.turn.actionsEnded) { + const tpHome = inHand(view, "teleport"); + if (tpHome) { + const blink = wallIgnoringDistance(view.board, self.position, self.home); + const movesLeft = view.turn.movementAllowance - view.turn.movementUsed; + const dWalk = blink >= 1 && blink <= 4 + ? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id))) + .get(cellKey(self.home)) ?? Infinity + : 0; + if (blink <= 4 && blink >= 1 && dWalk > movesLeft) { + return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } }; + } + } + } + // March. The thief-chase outranks everything; the berserker hunts wizards // over gold; the worrier keeps its distance; the hunter goes to the gold. if (view.turn.movementUsed < view.turn.movementAllowance) { @@ -1430,13 +1536,26 @@ export function automatonCommand( const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden && !view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null); const chasing = thief !== null && strikeLive; + // 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; + // The predicate reads only ENEMY positions — my own steps must not + // flip it mid-march or the walker shuttles (the thief-chase lesson). + const bankThreatened = bankedCount > 0 && !self.carriedTreasureId && + livingEnemies(view).some((p) => + !p.carriedTreasureId && + Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2); const objectives = cursedIdiot && ownGoldCells.size > 0 ? ownGoldCells : chasing ? new Set([cellKey(thief.position)]) - : style === "berserker" && !self.carriedTreasureId - ? (enemyCells.size > 0 ? enemyCells : gold) - : gold; + : bankThreatened + ? new Set([cellKey(self.home)]) + : style === "berserker" && !self.carriedTreasureId + ? (enemyCells.size > 0 ? enemyCells : gold) + : gold; const path = pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a43b78d..c65dc80 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -3493,7 +3493,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number { } /** BFS steps between cells ignoring walls (teleport distance). */ -function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { +export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { if (cellKey(from) === cellKey(to)) return 0; const seen = new Map([[cellKey(from), 0]]); const queue: Cell[] = [from]; diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 3554d10..628bd6f 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -856,3 +856,69 @@ describe("the archmage's sharpened instincts", () => { throw new Error("the clockwork hoarded its number instead of winning"); }); }); + +describe("lessons from the human duels", () => { + function rig(players = ["foe", "bot"]) { + let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] }); + 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("a raider at the stocked bank's gates pulls the clockwork home", () => { + const state = rig(); + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + const gold = state.treasures.find((t) => t.owner === "foe")!; + gold.position = { ...bot.home }; + gold.carriedBy = null; + foe.position = { x: bot.home.x, y: bot.home.y + 1 }; + bot.position = { ...foe.home }; + bot.hand = []; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + // Whatever step it picks, the march must be TOWARD home, not the gold map. + expect(cmd?.type).toBe("move"); + }); + + it("carrying with home a blink away, it teleports the delivery", () => { + const state = rig(); + const bot = state.players.find((p) => p.id === "bot")!; + const prize = state.treasures.find((t) => t.owner === "foe")!; + prize.carriedBy = "bot"; + prize.position = null; + bot.carriedTreasureId = prize.id; + // Two squares from home as the spell flies, but walled off on foot. + bot.position = { x: bot.home.x, y: bot.home.y + 2 }; + for (const side of ["N", "S", "E", "W"] as const) { + state.edgeOverrides[edgeKey(bot.position, side)] = "wall"; + } + bot.hand = [{ cardId: "teleport", instanceId: "TP" } as never]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } }); + }); + + it("a would-win carrier eats the lightning, not the fireball", () => { + let state = rig(); + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + // Foe has one banked and carries the second: one delivery from winning. + const banked = state.treasures.find((t) => t.owner === "bot")!; + banked.position = { ...foe.home }; + const carried = state.treasures.filter((t) => t.owner === "bot")[1]; + if (!carried) return; + carried.carriedBy = "foe"; + carried.position = null; + foe.carriedTreasureId = carried.id; + foe.position = { ...bot.position }; + bot.hand = [ + { cardId: "fireball", instanceId: "FB" } as never, + { cardId: "lightning-blast", instanceId: "LB" } as never, + { cardId: "number-4", instanceId: "N4" } as never, + ]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" }); + }); +});