The clockwork learns numbers, summons, creatures — and temperament
The automaton now plays number cards where they matter: attached to value-scaled attacks (waterbolt, powerthrust) choosing the biggest for damage, and played for movement when the goal is just out of stride. It raises its monsters when no wizard is in spell range and commands them every turn — creatures march at enemies by the same hazard-shy BFS and maul whoever shares their square. Targets are picked by lowest life. And it has moods. The HUNTER plays the classic game: gold first, violence when convenient. The BERSERKER hunts wizards over treasure and finishes games by last-wizard-standing. The WORRIER paths around enemies, counters at a lower threshold, shields against chaos, and never brawls. Hosts pick the temperament when seating one (or the workshop assigns at random); it persists with the seat, shows in the roster, and the tournament harness proves every pairing finishes. Across ten seeds of berserker versus hunter: six treasure wins to four kills — the moods play genuinely different games. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
01101ca24a
commit
5a407a1e4c
@@ -2,30 +2,29 @@
|
||||
// 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.
|
||||
//
|
||||
// Temperaments color its judgment: the HUNTER marches for treasure, the
|
||||
// BERSERKER for blood, the WORRIER for the shadows between the two.
|
||||
|
||||
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,
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
||||
|
||||
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
|
||||
const ATTACKS: Record<string, { base: number; perNumber: boolean }> = {
|
||||
fireball: { base: 5, perNumber: false },
|
||||
"sudden-death": { base: 10, perNumber: false },
|
||||
powerthrust: { base: 2, perNumber: true },
|
||||
waterbolt: { base: 0, perNumber: true },
|
||||
dagger: { base: 1, perNumber: false },
|
||||
"large-rock": { base: 2, perNumber: false },
|
||||
};
|
||||
|
||||
/** 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
|
||||
}
|
||||
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]);
|
||||
|
||||
function me(view: GameView) {
|
||||
return view.players.find((p) => p.id === view.you)!;
|
||||
@@ -35,14 +34,45 @@ 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 {
|
||||
function numbersInHand(view: GameView): CardInstance[] {
|
||||
return view.yourHand
|
||||
.filter((c) => cardDef(c.cardId).cardType === "number")
|
||||
.sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0));
|
||||
}
|
||||
|
||||
/** Cards the automaton happily discards to churn its hand, worst first. */
|
||||
function discardValue(c: CardInstance): number {
|
||||
const def = cardDef(c.cardId);
|
||||
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
|
||||
if (SUMMONS.has(c.cardId)) return 8;
|
||||
if (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
|
||||
}
|
||||
|
||||
interface PathResult {
|
||||
dir: Side;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** BFS over walkable steps toward the nearest goal; hazard-shy, enemy-shy optional. */
|
||||
function pathToward(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
goals: Set<string>,
|
||||
avoidNearEnemies: boolean,
|
||||
): PathResult | null {
|
||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||
const nearEnemy = (c: Cell) =>
|
||||
enemyCells.some((e) => Math.abs(e.x - c.x) + Math.abs(e.y - c.y) <= 2);
|
||||
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++) {
|
||||
let depth = 0;
|
||||
for (; depth < 60 && frontier.length > 0 && !found; depth++) {
|
||||
const next: Cell[] = [];
|
||||
for (const c of frontier) {
|
||||
for (const dir of SIDES) {
|
||||
@@ -54,11 +84,10 @@ function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side |
|
||||
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;
|
||||
if (avoidNearEnemies && nearEnemy(t.to)) continue;
|
||||
next.push(t.to);
|
||||
}
|
||||
if (found) break;
|
||||
@@ -69,12 +98,12 @@ function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side |
|
||||
let cursor = found;
|
||||
for (;;) {
|
||||
const hop = cameBy.get(cursor)!;
|
||||
if (hop.prev === cellKey(from)) return hop.dir;
|
||||
if (hop.prev === cellKey(from)) return { dir: hop.dir, distance: depth };
|
||||
cursor = hop.prev;
|
||||
}
|
||||
}
|
||||
|
||||
/** The treasure squares worth marching for, best first. */
|
||||
/** The treasure squares worth marching for. */
|
||||
function treasureGoals(view: GameView): Set<string> {
|
||||
const self = me(view);
|
||||
const goals = new Set<string>();
|
||||
@@ -85,8 +114,6 @@ function treasureGoals(view: GameView): Set<string> {
|
||||
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));
|
||||
}
|
||||
@@ -94,8 +121,7 @@ function treasureGoals(view: GameView): Set<string> {
|
||||
}
|
||||
|
||||
function overLimit(view: GameView): number {
|
||||
const limit = 7;
|
||||
return Math.max(0, view.yourHand.length - limit);
|
||||
return Math.max(0, view.yourHand.length - 7);
|
||||
}
|
||||
|
||||
function worstCards(view: GameView, n: number): string[] {
|
||||
@@ -105,38 +131,88 @@ function worstCards(view: GameView, n: number): string[] {
|
||||
.map((c) => c.instanceId);
|
||||
}
|
||||
|
||||
/** What the automaton does when the maze wants a response from it. */
|
||||
function respond(view: GameView): Command {
|
||||
/** Counteraction judgment; the worrier flinches at less. */
|
||||
function respond(view: GameView, style: AutomatonStyle): 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 flinch = style === "worrier" ? 1 : 0;
|
||||
const atk = stack.attackCard ? ATTACKS[stack.attackCard.cardId] : null;
|
||||
const incoming = stack.attackCard
|
||||
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 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 };
|
||||
if (shield && incoming >= 3 - flinch) return { type: "counteract", instanceId: shield.instanceId };
|
||||
const reflect = find("full-reflection");
|
||||
if (reflect && incoming >= 4) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
}
|
||||
const blunt = find("blunt");
|
||||
if (blunt && incoming >= 2) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
/** The best attack available against a visible target, numbers included. */
|
||||
function bestAttack(view: GameView, targetId: PlayerId): Command | null {
|
||||
const numbers = numbersInHand(view);
|
||||
const biggest = numbers[numbers.length - 1];
|
||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||
let best: { cmd: Command; damage: number } | null = null;
|
||||
for (const c of view.yourHand) {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) continue;
|
||||
const withNumber = atk.perNumber && biggest;
|
||||
const damage = atk.base + (withNumber ? biggestValue : 0);
|
||||
if (damage <= 0) continue;
|
||||
if (!best || damage > best.damage) {
|
||||
best = {
|
||||
damage,
|
||||
cmd: {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return best?.cmd ?? null;
|
||||
}
|
||||
|
||||
/** An empty, sighted square near the target for a summoned creature. */
|
||||
function summonSpot(view: GameView, near: Cell): Cell | null {
|
||||
const sighted = sightedCellsFor(view);
|
||||
let best: { cell: Cell; d: number } | null = null;
|
||||
for (const k of sighted) {
|
||||
if (view.squareContents[k]) continue;
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (view.creatures.some((c) => c.position.x === x && c.position.y === y)) continue;
|
||||
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
|
||||
const d = Math.abs(x - near.x) + Math.abs(y - near.y);
|
||||
if (!best || d < best.d) best = { cell: { x, y }, d };
|
||||
}
|
||||
return best?.cell ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One decision from the automaton's seat, or null when the maze is not
|
||||
* asking it anything.
|
||||
*/
|
||||
export function automatonCommand(view: GameView): Command | null {
|
||||
export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter"): 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.chaosPending && view.chaosPending.queue[0] === you) {
|
||||
const shield = view.yourHand.find((c) => c.cardId === "full-shield");
|
||||
return style === "worrier" && shield
|
||||
? { type: "counteract", instanceId: shield.instanceId }
|
||||
: { type: "pass" };
|
||||
}
|
||||
if (view.stack) {
|
||||
return view.stack.waitingOn === you ? respond(view) : null;
|
||||
return view.stack.waitingOn === you ? respond(view, style) : null;
|
||||
}
|
||||
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
|
||||
if (view.activePlayerId !== you) return null;
|
||||
@@ -156,34 +232,70 @@ export function automatonCommand(view: GameView): Command | null {
|
||||
if (prize) return { type: "pickUpTreasure" };
|
||||
}
|
||||
|
||||
// One attack per turn: the nearest visible enemy eats the best simple spell.
|
||||
// Command the menagerie: creatures march and maul before the wizard moves.
|
||||
for (const c of view.creatures) {
|
||||
if (c.controllerId !== you || c.justCreated) continue;
|
||||
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
|
||||
if (enemyHere && !c.attackUsed &&
|
||||
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
||||
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
||||
}
|
||||
if (c.movementUsed < c.movesPerTurn) {
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
const hunt = pathToward(view, c.position, enemyCells, false);
|
||||
if (hunt) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
|
||||
}
|
||||
}
|
||||
|
||||
// One attack per turn.
|
||||
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 },
|
||||
};
|
||||
const target = visible.sort((a, b) => a.life - b.life)[0]!;
|
||||
const spell = bestAttack(view, target.id);
|
||||
if (spell) return spell;
|
||||
// The berserker summons help where the hunter saves the card.
|
||||
if (cellKey(target.position) === here && style !== "worrier") {
|
||||
return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
|
||||
const spot = summonSpot(view, near);
|
||||
if (spot) {
|
||||
return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } };
|
||||
}
|
||||
if (cellKey(target.position) === here) return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
|
||||
// March toward the objective.
|
||||
// March. The berserker hunts wizards over gold; the worrier keeps its
|
||||
// distance; the hunter goes where the treasure is.
|
||||
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 gold = treasureGoals(view);
|
||||
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 };
|
||||
const objectives = style === "berserker" && !me(view).carriedTreasureId
|
||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||
: gold;
|
||||
const path = pathToward(view, self.position, objectives, style === "worrier");
|
||||
const fallbackPath = path ?? pathToward(view, self.position, objectives, false) ??
|
||||
pathToward(view, self.position, enemyCells, false);
|
||||
if (fallbackPath) {
|
||||
// A number card closes the gap when the goal is just out of stride.
|
||||
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
if (!view.turn.numberPlayedForMovement && fallbackPath.distance > movesLeft) {
|
||||
const numbers = numbersInHand(view);
|
||||
const helper = numbers.find(
|
||||
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= fallbackPath.distance,
|
||||
) ?? (fallbackPath.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined);
|
||||
if (helper) {
|
||||
return { type: "playNumberForMovement", instanceId: helper.instanceId };
|
||||
}
|
||||
}
|
||||
return { type: "move", direction: fallbackPath.dir };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "endTurn", draw: 2 };
|
||||
|
||||
Reference in New Issue
Block a user