The full clockwork curriculum: keys, vengeance, spellcraft, and voice
Every play-strength item, in one education. DOORS: the pathfinder walks through doors it can open — already-opened ones freely, locked ones when holding Master Key or Pick Lock, casting the key at the door on the path before stepping through (and a loop where the clockwork re-keyed an already-open door forever is fixed: opened doors read as open). TREASURE DEFENSE: a wizard carrying the automaton's gold becomes the priority — chased over all objectives, shaken down with DROP OBJECT when held, and attacked first. WIDER SPELLCRAFT: blaster wands charged by number and fired; teleport as escape when wounded and hunted, and as a counteraction clear of big incoming spells; SPEED cast on sight; WARD armed to guard the gold; shieldstone displayed so spare numbers soak small hits; ANTI-ANTI pressed against counters (never against escapes); the berserker grows BIG with an enemy near, the worrier fades invisible or slams a CREATE WALL in its pursuer's face; ambushes armed from held interrupts — the worrier trapping its doorstep, the others trapping the treasure. Tournament suite green across all temperaments. And the clockwork speaks: sparing, temperament-voiced table talk on its own deeds — "ACQUISITION COMPLETE.", "SCHEDULED DEMISE: DELIVERED.", "good wall. safe wall." — through the same chat ledger as everyone else. The tally gains "games fought against the clockwork", and automatons no longer pollute the count of wizards seated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ca16faac73
commit
3eda686e29
@@ -7,9 +7,9 @@
|
||||
// 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 { cellKey, edgeKey, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { sightedCellsFor, type GameView } from "./view";
|
||||
import type { Command, PlayerId } from "./game";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
||||
@@ -22,9 +22,11 @@ const ATTACKS: Record<string, { base: number; perNumber: boolean }> = {
|
||||
waterbolt: { base: 0, perNumber: true },
|
||||
dagger: { base: 1, perNumber: false },
|
||||
"large-rock": { base: 2, perNumber: false },
|
||||
"blaster-wand": { base: 3, perNumber: false },
|
||||
};
|
||||
|
||||
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]);
|
||||
const UNLOCKS = ["master-key", "pick-lock"];
|
||||
|
||||
function me(view: GameView) {
|
||||
return view.players.find((p) => p.id === view.you)!;
|
||||
@@ -34,6 +36,10 @@ function livingEnemies(view: GameView) {
|
||||
return view.players.filter((p) => p.alive && p.id !== view.you);
|
||||
}
|
||||
|
||||
function inHand(view: GameView, cardId: string): CardInstance | undefined {
|
||||
return view.yourHand.find((c) => c.cardId === cardId);
|
||||
}
|
||||
|
||||
function numbersInHand(view: GameView): CardInstance[] {
|
||||
return view.yourHand
|
||||
.filter((c) => cardDef(c.cardId).cardType === "number")
|
||||
@@ -44,8 +50,10 @@ function numbersInHand(view: GameView): CardInstance[] {
|
||||
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 (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId)) return 8;
|
||||
if (ATTACKS[c.cardId] != null) return 7;
|
||||
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
|
||||
c.cardId === "ward" || c.cardId === "drop-object") return 6;
|
||||
if (def.cardType === "number") return 4 + (def.value ?? 0);
|
||||
if (def.cardType === "attack") return 3;
|
||||
return 2; // situational neutrals go first
|
||||
@@ -54,20 +62,26 @@ function discardValue(c: CardInstance): number {
|
||||
interface PathResult {
|
||||
dir: Side;
|
||||
distance: number;
|
||||
/** A locked door stands on the first step of this path. */
|
||||
doorAhead?: { cell: Cell; side: Side };
|
||||
}
|
||||
|
||||
/** BFS over walkable steps toward the nearest goal; hazard-shy, enemy-shy optional. */
|
||||
/**
|
||||
* BFS over walkable steps toward the nearest goal. Doors count as passable
|
||||
* when the clockwork can unlock them; the first such door is reported so the
|
||||
* key gets used before the boot.
|
||||
*/
|
||||
function pathToward(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
goals: Set<string>,
|
||||
avoidNearEnemies: boolean,
|
||||
opts: { avoidNearEnemies?: boolean; canUnlock?: 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 cameBy = new Map<string, { prev: string; dir: Side; viaDoor: boolean }>();
|
||||
const seen = new Set<string>([cellKey(from)]);
|
||||
let frontier: Cell[] = [from];
|
||||
let found: string | null = null;
|
||||
@@ -77,18 +91,33 @@ function pathToward(
|
||||
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);
|
||||
let to: Cell;
|
||||
let viaDoor = false;
|
||||
if (t.kind === "blocked") {
|
||||
if (t.by !== "door") continue;
|
||||
const k = edgeKey(c, dir);
|
||||
const alreadyOpen =
|
||||
view.openDoorEdges.includes(k) || view.doorStates[k] === "removed";
|
||||
if (!alreadyOpen && !opts.canUnlock) continue;
|
||||
const n = { x: c.x + (dir === "E" ? 1 : dir === "W" ? -1 : 0),
|
||||
y: c.y + (dir === "S" ? 1 : dir === "N" ? -1 : 0) };
|
||||
if (!view.board.cells[cellKey(n)]) continue;
|
||||
to = n;
|
||||
viaDoor = !alreadyOpen;
|
||||
} else {
|
||||
to = t.to;
|
||||
}
|
||||
const k = cellKey(to);
|
||||
if (seen.has(k)) continue;
|
||||
if (view.squareContents[k]?.kind === "stone") continue;
|
||||
seen.add(k);
|
||||
cameBy.set(k, { prev: cellKey(c), dir });
|
||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||
if (goals.has(k)) { found = k; break; }
|
||||
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 (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||
next.push(to);
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
@@ -98,7 +127,13 @@ function pathToward(
|
||||
let cursor = found;
|
||||
for (;;) {
|
||||
const hop = cameBy.get(cursor)!;
|
||||
if (hop.prev === cellKey(from)) return { dir: hop.dir, distance: depth };
|
||||
if (hop.prev === cellKey(from)) {
|
||||
return {
|
||||
dir: hop.dir,
|
||||
distance: depth,
|
||||
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
|
||||
};
|
||||
}
|
||||
cursor = hop.prev;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +155,15 @@ function treasureGoals(view: GameView): Set<string> {
|
||||
return goals;
|
||||
}
|
||||
|
||||
/** The wizard making off with MY gold, if any. */
|
||||
function thiefOfMine(view: GameView) {
|
||||
const carriers = new Set(
|
||||
view.treasures.filter((t) => t.owner === view.you && t.carriedBy && t.carriedBy !== view.you)
|
||||
.map((t) => t.carriedBy!),
|
||||
);
|
||||
return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null;
|
||||
}
|
||||
|
||||
function overLimit(view: GameView): number {
|
||||
return Math.max(0, view.yourHand.length - 7);
|
||||
}
|
||||
@@ -131,17 +175,45 @@ function worstCards(view: GameView, n: number): string[] {
|
||||
.map((c) => c.instanceId);
|
||||
}
|
||||
|
||||
/** A square up to four spaces away that maximizes distance from enemies. */
|
||||
function escapeCell(view: GameView, from: Cell): Cell | null {
|
||||
const enemies = livingEnemies(view).map((p) => p.position);
|
||||
let best: { cell: Cell; score: number } | null = null;
|
||||
for (const k of Object.keys(view.board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
const d = Math.abs(x - from.x) + Math.abs(y - from.y);
|
||||
if (d === 0 || d > 4) continue;
|
||||
if (view.squareContents[k]) continue;
|
||||
const score = Math.min(...enemies.map((e) => Math.abs(e.x - x) + Math.abs(e.y - y)), 99);
|
||||
if (!best || score > best.score) best = { cell: { x, y }, score };
|
||||
}
|
||||
return best?.cell ?? null;
|
||||
}
|
||||
|
||||
/** 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 you = view.you;
|
||||
const find = (id: string) => view.yourHand.find((c) => c.cardId === id);
|
||||
|
||||
if (stack.defenderId !== you) {
|
||||
// Pressing the attack: ANTI-ANTI burns the counter that would stop us
|
||||
// (the engine refuses it against escapes; the fallback pass covers that).
|
||||
if (stack.attackerId === you) {
|
||||
const last = [...stack.counters].reverse().find((c) => !c.nullified);
|
||||
const anti = find("anti-anti");
|
||||
if (anti && last && last.card.cardId !== "teleport") {
|
||||
return { type: "counteract", instanceId: anti.instanceId };
|
||||
}
|
||||
}
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
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 - flinch) return { type: "counteract", instanceId: shield.instanceId };
|
||||
@@ -150,6 +222,18 @@ function respond(view: GameView, style: AutomatonStyle): Command {
|
||||
}
|
||||
const blunt = find("blunt");
|
||||
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
// Nothing to block with: teleport clear of the big ones.
|
||||
const tp = find("teleport");
|
||||
if (tp && incoming >= 4 - flinch) {
|
||||
const out = escapeCell(view, me(view).position);
|
||||
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
||||
}
|
||||
// A displayed shieldstone lets numbers soak the small ones.
|
||||
const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone");
|
||||
if (stoneShown && incoming >= 2) {
|
||||
const num = numbersInHand(view)[0];
|
||||
if (num) return { type: "counteract", instanceId: num.instanceId };
|
||||
}
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
@@ -162,8 +246,12 @@ function bestAttack(view: GameView, targetId: PlayerId): Command | 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);
|
||||
// A wand needs its charges set by a number on first use.
|
||||
const isWand = c.cardId === "blaster-wand";
|
||||
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
||||
if (uncharged && !biggest) continue;
|
||||
const withNumber = (atk.perNumber || uncharged) && biggest;
|
||||
const damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||
if (damage <= 0) continue;
|
||||
if (!best || damage > best.damage) {
|
||||
best = {
|
||||
@@ -194,6 +282,69 @@ function summonSpot(view: GameView, near: Cell): Cell | null {
|
||||
return best?.cell ?? null;
|
||||
}
|
||||
|
||||
/** Self-buffs and housekeeping worth a neutral cast this turn. */
|
||||
function selfCare(view: GameView, style: AutomatonStyle): Command | null {
|
||||
const self = me(view);
|
||||
const numbers = numbersInHand(view);
|
||||
const mid = numbers[Math.floor(numbers.length / 2)];
|
||||
|
||||
// Extra turns are always good turns.
|
||||
const speed = inHand(view, "speed");
|
||||
if (speed && view.turn.round > 1 && !view.turn.actionsEnded) {
|
||||
return { type: "cast", instanceId: speed.instanceId };
|
||||
}
|
||||
// The ward guards the gold while its owner is away robbing yours.
|
||||
if (inHand(view, "ward") && !view.yourWardArmed) {
|
||||
return { type: "armWard", armed: true };
|
||||
}
|
||||
// A shieldstone on the table turns spare numbers into armor.
|
||||
const stone = inHand(view, "shieldstone");
|
||||
if (stone && !self.displayed.some((c) => c.cardId === "shieldstone")) {
|
||||
return { type: "cast", instanceId: stone.instanceId };
|
||||
}
|
||||
const enemyNear = livingEnemies(view).some(
|
||||
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
|
||||
);
|
||||
// The berserker grows; the worrier fades.
|
||||
if (style === "berserker" && enemyNear && mid) {
|
||||
const big = inHand(view, "big-man");
|
||||
if (big) {
|
||||
return { type: "cast", instanceId: big.instanceId, numberInstanceIds: [mid.instanceId] };
|
||||
}
|
||||
}
|
||||
if (style === "worrier" && enemyNear && mid) {
|
||||
for (const id of ["invisible", "mist-body", "shrink"]) {
|
||||
const buff = inHand(view, id);
|
||||
if (buff) {
|
||||
return { type: "cast", instanceId: buff.instanceId, numberInstanceIds: [mid.instanceId] };
|
||||
}
|
||||
}
|
||||
// Or slam a wall in the pursuer's face.
|
||||
const wall = inHand(view, "create-wall");
|
||||
if (wall) {
|
||||
const foe = livingEnemies(view).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]!;
|
||||
const dx = foe.position.x - self.position.x;
|
||||
const dy = foe.position.y - self.position.y;
|
||||
const side: Side = Math.abs(dx) >= Math.abs(dy) && dx !== 0
|
||||
? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
|
||||
if ((view.board.edges[edgeKey(self.position, side)] ?? "open") === "open") {
|
||||
return { type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: self.position, side } };
|
||||
}
|
||||
}
|
||||
}
|
||||
// An ambush costs nothing to hold and everything to walk into.
|
||||
const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire");
|
||||
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
|
||||
c.cardId !== "blaster-wand");
|
||||
if (via && spell && view.yourAmbushes.length === 0) {
|
||||
const trigger: AmbushTrigger = style === "worrier" ? { kind: "near" } : { kind: "treasure" };
|
||||
return { type: "setAmbush", instanceId: via.instanceId, trigger, spellInstanceId: spell.instanceId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One decision from the automaton's seat, or null when the maze is not
|
||||
* asking it anything.
|
||||
@@ -206,7 +357,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
|
||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
||||
}
|
||||
if (view.chaosPending && view.chaosPending.queue[0] === you) {
|
||||
const shield = view.yourHand.find((c) => c.cardId === "full-shield");
|
||||
const shield = inHand(view, "full-shield");
|
||||
return style === "worrier" && shield
|
||||
? { type: "counteract", instanceId: shield.instanceId }
|
||||
: { type: "pass" };
|
||||
@@ -232,6 +383,20 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
|
||||
if (prize) return { type: "pickUpTreasure" };
|
||||
}
|
||||
|
||||
// Wounded clockwork teleports clear of visible hunters.
|
||||
if (self.life <= 5) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position)));
|
||||
const tp = inHand(view, "teleport");
|
||||
if (hunted && tp) {
|
||||
const out = escapeCell(view, self.position);
|
||||
if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } };
|
||||
}
|
||||
}
|
||||
|
||||
const care = selfCare(view, style);
|
||||
if (care) return care;
|
||||
|
||||
// Command the menagerie: creatures march and maul before the wizard moves.
|
||||
for (const c of view.creatures) {
|
||||
if (c.controllerId !== you || c.justCreated) continue;
|
||||
@@ -242,20 +407,33 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
|
||||
}
|
||||
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 };
|
||||
const hunt = pathToward(view, c.position, enemyCells, {});
|
||||
if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
|
||||
}
|
||||
}
|
||||
|
||||
// One attack per turn.
|
||||
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) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position)));
|
||||
if (visible.length > 0) {
|
||||
const target = visible.sort((a, b) => a.life - b.life)[0]!;
|
||||
const target = thief && visible.some((p) => p.id === thief.id)
|
||||
? thief
|
||||
: visible.sort((a, b) => a.life - b.life)[0]!;
|
||||
// DROP OBJECT shakes my treasure out of the thief's hands.
|
||||
if (thief && target.id === thief.id) {
|
||||
const dob = inHand(view, "drop-object");
|
||||
if (dob) {
|
||||
return {
|
||||
type: "cast", instanceId: dob.instanceId,
|
||||
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
|
||||
};
|
||||
}
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@@ -271,30 +449,46 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
|
||||
}
|
||||
}
|
||||
|
||||
// March. The berserker hunts wizards over gold; the worrier keeps its
|
||||
// distance; the hunter goes where the treasure is.
|
||||
// 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) {
|
||||
const gold = treasureGoals(view);
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
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 };
|
||||
const canUnlock = UNLOCKS.some((id) => inHand(view, id));
|
||||
const objectives = thief
|
||||
? new Set([cellKey(thief.position)])
|
||||
: style === "berserker" && !self.carriedTreasureId
|
||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||
: gold;
|
||||
const path =
|
||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ??
|
||||
pathToward(view, self.position, objectives, { canUnlock }) ??
|
||||
pathToward(view, self.position, enemyCells, { canUnlock });
|
||||
if (path) {
|
||||
// A locked door on the very next step: use the key first.
|
||||
if (path.doorAhead) {
|
||||
for (const id of UNLOCKS) {
|
||||
const key = inHand(view, id);
|
||||
if (key) {
|
||||
return {
|
||||
type: "cast", instanceId: key.instanceId,
|
||||
target: { kind: "edge", cell: path.doorAhead.cell, side: path.doorAhead.side },
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) {
|
||||
const numbers = numbersInHand(view);
|
||||
const helper = numbers.find(
|
||||
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
|
||||
) ?? (path.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined);
|
||||
if (helper) {
|
||||
return { type: "playNumberForMovement", instanceId: helper.instanceId };
|
||||
}
|
||||
}
|
||||
return { type: "move", direction: path.dir };
|
||||
}
|
||||
return { type: "move", direction: fallbackPath.dir };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,52 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
||||
* broadcast as it lands, until the maze wants a human again.
|
||||
*/
|
||||
const BOT_STEP_MS = 1500;
|
||||
|
||||
/** The clockwork occasionally speaks. Sparingly — menace over chatter. */
|
||||
const BOT_LINES: Record<string, Record<string, string[]>> = {
|
||||
hunter: {
|
||||
treasurePickedUp: ["ACQUISITION COMPLETE.", "YOUR GOLD HAS BEEN REALLOCATED."],
|
||||
treasureDropped: ["DELIVERY CONFIRMED. PLEASURE DOING BUSINESS."],
|
||||
damaged: ["AN INEFFICIENT USE OF YOUR TURN."],
|
||||
died: ["INVENTORY TRANSFERRED. CONDOLENCES."],
|
||||
},
|
||||
berserker: {
|
||||
treasurePickedUp: ["GOLD IS MERELY BAIT."],
|
||||
damaged: ["YES. AGAIN.", "PAIN RECEIPT PRINTED."],
|
||||
died: ["SCHEDULED DEMISE: DELIVERED.", "NEXT."],
|
||||
creatureCreated: ["I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY."],
|
||||
},
|
||||
worrier: {
|
||||
treasurePickedUp: ["taking this. sorry. sorry."],
|
||||
damaged: ["ow. logged.", "unnecessary!"],
|
||||
wallCreated: ["good wall. safe wall."],
|
||||
died: ["oh no. oh no. it worked?"],
|
||||
},
|
||||
};
|
||||
|
||||
function botRemark(room: Room, seat: string, events: { type: string; [k: string]: unknown }[]): void {
|
||||
const bot = room.bots.get(seat);
|
||||
if (!bot) return;
|
||||
const lines = BOT_LINES[bot.style] ?? {};
|
||||
for (const e of events) {
|
||||
// Speak only about its own deeds, and rarely.
|
||||
const mine =
|
||||
(e.type === "treasurePickedUp" && e.player === seat) ||
|
||||
(e.type === "treasureDropped" && e.player === seat) ||
|
||||
(e.type === "damaged" && e.player !== seat) ||
|
||||
(e.type === "died" && e.killedBy === seat) ||
|
||||
(e.type === "creatureCreated" && e.controller === seat) ||
|
||||
(e.type === "wallCreated" && e.caster === seat);
|
||||
const pool = lines[e.type];
|
||||
if (!mine || !pool || Math.random() > 0.35) continue;
|
||||
const text = pool[Math.floor(Math.random() * pool.length)]!;
|
||||
const said = addChat(room, seat, text);
|
||||
if (!("error" in said)) {
|
||||
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
|
||||
}
|
||||
return; // one remark per step at most
|
||||
}
|
||||
}
|
||||
const pumping = new Set<string>();
|
||||
function runBots(room: Room): void {
|
||||
if (pumping.has(room.id)) return;
|
||||
@@ -203,6 +249,7 @@ function runBots(room: Room): void {
|
||||
}
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
botRemark(room, step.seat, step.events as { type: string }[]);
|
||||
setTimeout(tick, BOT_STEP_MS);
|
||||
};
|
||||
setTimeout(tick, 800); // a beat after the human's own action settles
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface EngagementStats {
|
||||
firstGameAt: string | null;
|
||||
/** How many of the games were hotseat tables reporting in anonymously. */
|
||||
hotseatGames: number;
|
||||
/** Games with at least one clockwork wizard at the table. */
|
||||
automatonGames: number;
|
||||
}
|
||||
|
||||
interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
|
||||
@@ -40,7 +42,7 @@ let data: StatsFile = {
|
||||
commandsPlayed: 0, minutesAtTable: 0,
|
||||
winsByTreasure: 0, winsByLastStanding: 0,
|
||||
longestGameCommands: 0, fullestTable: 0, firstGameAt: null,
|
||||
hotseatGames: 0, wizards: [], roomStages: {},
|
||||
hotseatGames: 0, automatonGames: 0, wizards: [], roomStages: {},
|
||||
};
|
||||
let wizardSet = new Set<string>();
|
||||
let loaded = false;
|
||||
@@ -84,6 +86,7 @@ export function recordRoom(room: Room): void {
|
||||
const stage = data.roomStages[room.id];
|
||||
|
||||
for (const p of room.players) {
|
||||
if (room.bots.has(p)) continue; // the clockwork are not wizards seated
|
||||
const key = p.toLowerCase();
|
||||
if (!wizardSet.has(key)) { wizardSet.add(key); dirty = true; }
|
||||
}
|
||||
@@ -95,6 +98,7 @@ export function recordRoom(room: Room): void {
|
||||
}
|
||||
if (room.state && data.roomStages[room.id] === "created") {
|
||||
data.gamesStarted++;
|
||||
if (room.bots.size > 0) data.automatonGames++;
|
||||
data.fullestTable = Math.max(data.fullestTable, room.players.length);
|
||||
data.roomStages[room.id] = "started";
|
||||
dirty = true;
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
|
||||
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
|
||||
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
|
||||
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
|
||||
</dl>
|
||||
{#if stats.firstGameAt}
|
||||
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
|
||||
|
||||
Reference in New Issue
Block a user