The automatons awaken (branch only — not for the public droplet yet)
Phase 4 begins. The automaton is a pure function in the engine — automatonCommand(view) — playing from its own redacted GameView, the same information a human seat receives: hidden hands stay hidden from the clockwork. It ranks simple damage spells, counters what hurts (full shield at 3+, reflection at 4+, blunt at 2+), discards its worst cards by a value order, BFS-pathfinds to enemy treasures and home again, refuses to path through hazards, brawls when there is nothing to steal, and always has a safe fallback; the server's drive loop steps any bot-held seat through the same runCommand path as humans, so bot commands log, persist, replay, and broadcast like anyone's. Hosts seat them pre-start with "⚙ seat an automaton" (Automaton, Automaton II, ... V); bot seats persist as tokenless join lines and restore on boot. Proven three ways: bot-vs-bot engine games conclude across seeds in 8-14 rounds (~100 commands — human-scale), a full four-automaton table finishes, and a live websocket game of human vs. automaton ended with the clockwork carrying two treasures home through a do-nothing opponent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5802e1df05
commit
ab52628d2f
@@ -0,0 +1,200 @@
|
||||
// The automaton: a clockwork wizard. It plays from its own redacted view —
|
||||
// the same information a human seat receives — and returns one command per
|
||||
// consultation; the server keeps consulting until the maze stops asking.
|
||||
// It never throws: when in doubt it passes, discards, or ends its turn.
|
||||
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import { cellKey, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { sightedCellsFor, type GameView } from "./view";
|
||||
import type { Command, PlayerId } from "./game";
|
||||
|
||||
/** Damage attacks simple enough for clockwork: id -> flat damage dealt. */
|
||||
const SIMPLE_ATTACKS: Record<string, number> = {
|
||||
fireball: 5,
|
||||
"sudden-death": 10,
|
||||
powerthrust: 2,
|
||||
dagger: 1,
|
||||
"large-rock": 2,
|
||||
};
|
||||
|
||||
/** Cards the automaton happily discards to churn its hand. */
|
||||
function discardValue(c: CardInstance): number {
|
||||
const def = cardDef(c.cardId);
|
||||
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 8;
|
||||
if (SIMPLE_ATTACKS[c.cardId] != null) return 7;
|
||||
if (def.cardType === "number") return 4 + (def.value ?? 0);
|
||||
if (def.cardType === "attack") return 3;
|
||||
return 2; // situational neutrals go first
|
||||
}
|
||||
|
||||
function me(view: GameView) {
|
||||
return view.players.find((p) => p.id === view.you)!;
|
||||
}
|
||||
|
||||
function livingEnemies(view: GameView) {
|
||||
return view.players.filter((p) => p.alive && p.id !== view.you);
|
||||
}
|
||||
|
||||
/** BFS over walkable steps; returns the first direction of a shortest path. */
|
||||
function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side | null {
|
||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||
const cameBy = new Map<string, { prev: string; dir: Side }>();
|
||||
const seen = new Set<string>([cellKey(from)]);
|
||||
let frontier: Cell[] = [from];
|
||||
let found: string | null = null;
|
||||
for (let depth = 0; depth < 60 && frontier.length > 0 && !found; depth++) {
|
||||
const next: Cell[] = [];
|
||||
for (const c of frontier) {
|
||||
for (const dir of SIDES) {
|
||||
const t = stepTarget(view.board, c, dir);
|
||||
if (t.kind === "blocked") continue;
|
||||
const k = cellKey(t.to);
|
||||
if (seen.has(k)) continue;
|
||||
if (view.squareContents[k]?.kind === "stone") continue;
|
||||
seen.add(k);
|
||||
cameBy.set(k, { prev: cellKey(c), dir });
|
||||
if (goals.has(k)) { found = k; break; }
|
||||
// The automaton refuses to path THROUGH hazards, but will end on one
|
||||
// only if it is the goal itself.
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime") continue;
|
||||
next.push(t.to);
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
if (!found) return null;
|
||||
let cursor = found;
|
||||
for (;;) {
|
||||
const hop = cameBy.get(cursor)!;
|
||||
if (hop.prev === cellKey(from)) return hop.dir;
|
||||
cursor = hop.prev;
|
||||
}
|
||||
}
|
||||
|
||||
/** The treasure squares worth marching for, best first. */
|
||||
function treasureGoals(view: GameView): Set<string> {
|
||||
const self = me(view);
|
||||
const goals = new Set<string>();
|
||||
if (self.carriedTreasureId) {
|
||||
goals.add(cellKey(self.home));
|
||||
return goals;
|
||||
}
|
||||
for (const t of view.treasures) {
|
||||
if (!t.position || t.carriedBy) continue;
|
||||
if (t.owner === view.you) continue;
|
||||
// A treasure already delivered to one of MY... any floor treasure of an
|
||||
// enemy is worth taking; skip ones resting on my own home (already won).
|
||||
if (cellKey(t.position) === cellKey(self.home)) continue;
|
||||
goals.add(cellKey(t.position));
|
||||
}
|
||||
return goals;
|
||||
}
|
||||
|
||||
function overLimit(view: GameView): number {
|
||||
const limit = 7;
|
||||
return Math.max(0, view.yourHand.length - limit);
|
||||
}
|
||||
|
||||
function worstCards(view: GameView, n: number): string[] {
|
||||
return [...view.yourHand]
|
||||
.sort((a, b) => discardValue(a) - discardValue(b))
|
||||
.slice(0, n)
|
||||
.map((c) => c.instanceId);
|
||||
}
|
||||
|
||||
/** What the automaton does when the maze wants a response from it. */
|
||||
function respond(view: GameView): Command {
|
||||
const stack = view.stack!;
|
||||
if (stack.defenderId !== view.you) return { type: "pass" };
|
||||
const incoming = stack.attackCard ? (SIMPLE_ATTACKS[stack.attackCard.cardId] ?? 2) : 1;
|
||||
const hand = view.yourHand;
|
||||
const find = (id: string) => hand.find((c) => c.cardId === id);
|
||||
if (stack.kind === "spell") {
|
||||
const shield = find("full-shield");
|
||||
if (shield && incoming >= 3) return { type: "counteract", instanceId: shield.instanceId };
|
||||
const reflect = find("full-reflection");
|
||||
if (reflect && incoming >= 4) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
}
|
||||
const blunt = find("blunt");
|
||||
if (blunt && incoming >= 2) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
/**
|
||||
* One decision from the automaton's seat, or null when the maze is not
|
||||
* asking it anything.
|
||||
*/
|
||||
export function automatonCommand(view: GameView): Command | null {
|
||||
const you = view.you as PlayerId;
|
||||
if (view.phase !== "playing") return null;
|
||||
|
||||
if (view.pendingDiscard === you) {
|
||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
||||
}
|
||||
if (view.chaosPending && view.chaosPending.queue[0] === you) return { type: "pass" };
|
||||
if (view.stack) {
|
||||
return view.stack.waitingOn === you ? respond(view) : null;
|
||||
}
|
||||
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
|
||||
if (view.activePlayerId !== you) return null;
|
||||
|
||||
const self = me(view);
|
||||
const here = cellKey(self.position);
|
||||
|
||||
if (view.turn.actionsEnded) return { type: "endTurn", draw: 2 };
|
||||
|
||||
// Deliver or grab treasure underfoot.
|
||||
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),
|
||||
);
|
||||
if (prize) return { type: "pickUpTreasure" };
|
||||
}
|
||||
|
||||
// One attack per turn: the nearest visible enemy eats the best simple spell.
|
||||
if (!view.turn.attackUsed && view.turn.round > 1) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position)));
|
||||
if (visible.length > 0) {
|
||||
const target = visible[0]!;
|
||||
const spells = view.yourHand
|
||||
.filter((c) => SIMPLE_ATTACKS[c.cardId] != null)
|
||||
.sort((a, b) => SIMPLE_ATTACKS[b.cardId]! - SIMPLE_ATTACKS[a.cardId]!);
|
||||
if (spells.length > 0) {
|
||||
return {
|
||||
type: "cast", instanceId: spells[0]!.instanceId,
|
||||
target: { kind: "player", playerId: target.id },
|
||||
};
|
||||
}
|
||||
if (cellKey(target.position) === here) return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
|
||||
// March toward the objective.
|
||||
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
||||
const goals = treasureGoals(view);
|
||||
const dir = firstStepToward(view, self.position, goals);
|
||||
if (dir) return { type: "move", direction: dir };
|
||||
// No treasure path: close on the nearest enemy for a brawl.
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
const hunt = firstStepToward(view, self.position, enemyCells);
|
||||
if (hunt) return { type: "move", direction: hunt };
|
||||
}
|
||||
|
||||
return { type: "endTurn", draw: 2 };
|
||||
}
|
||||
|
||||
/** The safe fallback when the automaton's choice was refused. */
|
||||
export function automatonFallback(view: GameView): Command {
|
||||
const you = view.you as PlayerId;
|
||||
if (view.pendingDiscard === you) {
|
||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
||||
}
|
||||
if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" };
|
||||
return { type: "endTurn", draw: 2 };
|
||||
}
|
||||
Reference in New Issue
Block a user