Merge branch 'automatons'
This commit is contained in:
@@ -5,3 +5,4 @@ dist/
|
||||
.DS_Store
|
||||
data/
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
// 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.
|
||||
//
|
||||
// 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, edgeKey, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { sightedCellsFor, type GameView } from "./view";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
||||
|
||||
/**
|
||||
* Difficulty degrades resources and repertoire, never judgment. The
|
||||
* apprentice draws one card a turn and knows a modest spellbook; the adept
|
||||
* draws two but keeps no ambushes or amplifies; the archmage knows all.
|
||||
* Every tier plays its cards correctly — none of them is ever stupid.
|
||||
*/
|
||||
export type AutomatonTier = "apprentice" | "adept" | "archmage";
|
||||
export const AUTOMATON_TIERS: AutomatonTier[] = ["apprentice", "adept", "archmage"];
|
||||
|
||||
interface TierTraits {
|
||||
draw: number;
|
||||
/** Added to every counteraction threshold: thrift, not blindness. */
|
||||
counterThrift: number;
|
||||
afflictions: boolean;
|
||||
amplify: boolean;
|
||||
ambush: boolean;
|
||||
guardGold: boolean;
|
||||
buffs: boolean;
|
||||
dejaVu: boolean;
|
||||
}
|
||||
const TIERS: Record<AutomatonTier, TierTraits> = {
|
||||
apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false },
|
||||
adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true },
|
||||
archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true },
|
||||
};
|
||||
|
||||
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
|
||||
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
|
||||
fireball: { base: 5, perNumber: false },
|
||||
"sudden-death": { base: 10, perNumber: false },
|
||||
powerthrust: { base: 2, perNumber: true },
|
||||
waterbolt: { base: 0, perNumber: true, needsNumber: true },
|
||||
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
|
||||
wizardblade: { base: 0, perNumber: true, needsNumber: true },
|
||||
"power-drain": { base: 0, perNumber: true, needsNumber: true },
|
||||
dagger: { base: 3, perNumber: false },
|
||||
"large-rock": { base: 2, perNumber: false },
|
||||
"blaster-wand": { base: 3, perNumber: false },
|
||||
disease: { base: 3, perNumber: false, sameSquare: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
|
||||
* number for the duration where one helps.
|
||||
*/
|
||||
const AFFLICTIONS: Record<string, { withNumber: boolean; vsCarrier?: boolean }> = {
|
||||
blind: { withNumber: true },
|
||||
slow: { withNumber: true },
|
||||
"no-spell": { withNumber: true },
|
||||
medusa: { withNumber: true },
|
||||
"lock-in-place": { withNumber: true },
|
||||
"walking-dead": { withNumber: false },
|
||||
"slow-death": { withNumber: false },
|
||||
idiot: { withNumber: false },
|
||||
weakness: { withNumber: false, vsCarrier: true },
|
||||
"go-away": { withNumber: true },
|
||||
"thought-steal": { withNumber: false },
|
||||
};
|
||||
|
||||
/** Every stone earns its place on the table the moment it is drawn. */
|
||||
const STONES = new Set([
|
||||
"bloodstone", "brainstone", "powerstone", "shadowstone",
|
||||
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||||
]);
|
||||
|
||||
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]);
|
||||
const UNLOCKS = ["master-key", "pick-lock", "remove-lock"];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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")
|
||||
.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) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
|
||||
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null) return 7;
|
||||
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
|
||||
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
|
||||
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
|
||||
c.cardId === "glue") return 6;
|
||||
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;
|
||||
/** A locked door stands on the first step of this path. */
|
||||
doorAhead?: { cell: Cell; side: Side };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
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; viaDoor: boolean }>();
|
||||
const seen = new Set<string>([cellKey(from)]);
|
||||
let frontier: Cell[] = [from];
|
||||
let found: string | null = null;
|
||||
let depth = 0;
|
||||
for (; 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);
|
||||
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, 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 (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||
next.push(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 {
|
||||
dir: hop.dir,
|
||||
distance: depth,
|
||||
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
|
||||
};
|
||||
}
|
||||
cursor = hop.prev;
|
||||
}
|
||||
}
|
||||
|
||||
/** The treasure squares worth marching for. */
|
||||
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;
|
||||
if (cellKey(t.position) === cellKey(self.home)) continue;
|
||||
goals.add(cellKey(t.position));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function worstCards(view: GameView, n: number): string[] {
|
||||
return [...view.yourHand]
|
||||
.sort((a, b) => discardValue(a) - discardValue(b))
|
||||
.slice(0, n)
|
||||
.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, tier: TierTraits): Command {
|
||||
const stack = view.stack!;
|
||||
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) - tier.counterThrift;
|
||||
const attackId = stack.attackCard?.cardId ?? null;
|
||||
const atk = attackId ? ATTACKS[attackId] : null;
|
||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||
const incoming = stack.attackCard
|
||||
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||
: 1;
|
||||
if (stack.kind === "spell") {
|
||||
// REVERSE turns the biggest blasts into a meal.
|
||||
const reverse = find("reverse");
|
||||
if (reverse && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||
// ABSORB SPELL steals the good ones for later.
|
||||
const absorbSpell = find("absorb-spell");
|
||||
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||
const shield = find("full-shield");
|
||||
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||
return { type: "counteract", instanceId: shield.instanceId };
|
||||
}
|
||||
const reflect = find("full-reflection");
|
||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
// A curse in flight is best refused at the door.
|
||||
if (affliction) {
|
||||
const cleanse = find("remove-curse");
|
||||
if (cleanse) return { type: "counteract", instanceId: cleanse.instanceId };
|
||||
}
|
||||
const half = find("reflection");
|
||||
if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId };
|
||||
}
|
||||
const absorb = find("absorb");
|
||||
if (absorb && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
|
||||
const blunt = find("blunt");
|
||||
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
// The berserker shares its pain out of spite.
|
||||
const empathy = find("empathy");
|
||||
if (empathy && style === "berserker" && incoming >= 2) {
|
||||
return { type: "counteract", instanceId: empathy.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" };
|
||||
}
|
||||
|
||||
/** The best attack available against a visible target, numbers and amplify included. */
|
||||
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Command | null {
|
||||
const numbers = numbersInHand(view);
|
||||
const biggest = numbers[numbers.length - 1];
|
||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||
const target = view.players.find((p) => p.id === targetId)!;
|
||||
const together = cellKey(target.position) === cellKey(me(view).position);
|
||||
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
|
||||
let best: { cmd: Command; damage: number } | null = null;
|
||||
for (const c of view.yourHand) {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) continue;
|
||||
if (atk.sameSquare && !together) continue;
|
||||
// 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 || atk.needsNumber) && !biggest) continue;
|
||||
const withNumber = (atk.perNumber || uncharged) && biggest;
|
||||
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||
if (damage <= 0) continue;
|
||||
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
|
||||
const amplified = amplify && damage >= 4 && !isWand &&
|
||||
c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||
if (amplified) damage *= 2;
|
||||
if (!best || damage > best.damage) {
|
||||
best = {
|
||||
damage,
|
||||
cmd: {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return best?.cmd ?? null;
|
||||
}
|
||||
|
||||
/** An affliction worth casting when no damage lands, mid number attached. */
|
||||
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
|
||||
const numbers = numbersInHand(view);
|
||||
const mid = numbers[Math.floor(numbers.length / 2)];
|
||||
for (const c of view.yourHand) {
|
||||
const aff = AFFLICTIONS[c.cardId];
|
||||
if (!aff) continue;
|
||||
if (aff.vsCarrier && !isThief) continue;
|
||||
if (aff.withNumber && !mid) continue;
|
||||
return {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}),
|
||||
};
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Self-buffs and housekeeping worth a neutral cast this turn. */
|
||||
function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): 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 };
|
||||
}
|
||||
// Free life is free.
|
||||
const gift = inHand(view, "gift-from-above");
|
||||
if (gift) return { type: "cast", instanceId: gift.instanceId };
|
||||
// Every stone goes on the table the turn it is drawn.
|
||||
for (const c of view.yourHand) {
|
||||
if (STONES.has(c.cardId) && !self.displayed.some((d) => d.instanceId === c.instanceId)) {
|
||||
return { type: "cast", instanceId: c.instanceId };
|
||||
}
|
||||
}
|
||||
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
||||
// A curse on the clockwork gets scrubbed off.
|
||||
const cursed = view.sustained.some(
|
||||
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
|
||||
);
|
||||
const cleanse = inHand(view, "remove-curse");
|
||||
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId };
|
||||
// Three or more wizards: LIFESAVER takes elimination off the table.
|
||||
const lifesaver = inHand(view, "lifesaver");
|
||||
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
||||
// The ward guards the gold while its owner is away robbing yours.
|
||||
if (inHand(view, "ward") && !view.yourWardArmed) {
|
||||
return { type: "armWard", armed: true };
|
||||
}
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
: undefined;
|
||||
if (myFloorTreasure && enemyNear) {
|
||||
const safe = inHand(view, "safe");
|
||||
if (safe) {
|
||||
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
|
||||
}
|
||||
const glue = inHand(view, "glue");
|
||||
const midN = numbers[Math.floor(numbers.length / 2)];
|
||||
if (glue && midN) {
|
||||
return {
|
||||
type: "cast", instanceId: glue.instanceId,
|
||||
target: { kind: "cell", cell: myFloorTreasure.position! },
|
||||
numberInstanceIds: [midN.instanceId],
|
||||
};
|
||||
}
|
||||
}
|
||||
// Empty of violence: DEJA-VU pulls the best attack back from the pile.
|
||||
const dv = tier.dejaVu ? inHand(view, "deja-vu") : undefined;
|
||||
if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) {
|
||||
const buried = [...view.discardPile].reverse().find(
|
||||
(c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber,
|
||||
);
|
||||
if (buried) {
|
||||
return { type: "cast", instanceId: dv.instanceId, params: { cardId: buried.cardId } };
|
||||
}
|
||||
}
|
||||
// An ambush costs nothing to hold and everything to walk into.
|
||||
const via = tier.ambush ? (inHand(view, "interrupt") ?? inHand(view, "opportunity-fire")) : undefined;
|
||||
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.
|
||||
*/
|
||||
export function automatonCommand(
|
||||
view: GameView,
|
||||
style: AutomatonStyle = "hunter",
|
||||
tierName: AutomatonTier = "archmage",
|
||||
): Command | null {
|
||||
const you = view.you as PlayerId;
|
||||
const tier = TIERS[tierName] ?? TIERS.archmage;
|
||||
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) {
|
||||
const shield = inHand(view, "full-shield");
|
||||
return style === "worrier" && shield
|
||||
? { type: "counteract", instanceId: shield.instanceId }
|
||||
: { type: "pass" };
|
||||
}
|
||||
if (view.stack) {
|
||||
return view.stack.waitingOn === you ? respond(view, style, tier) : 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: tier.draw };
|
||||
|
||||
// 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" };
|
||||
}
|
||||
|
||||
// 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, tier);
|
||||
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;
|
||||
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, {});
|
||||
if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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, tier);
|
||||
if (spell) return spell;
|
||||
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
|
||||
if (misery) return misery;
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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: "endTurn", draw: tier.draw };
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export * from "./cards";
|
||||
export * from "./setups";
|
||||
export * from "./game";
|
||||
export * from "./view";
|
||||
export * from "./automaton";
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCommand,
|
||||
createGame,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(state: GameState): PlayerId {
|
||||
return (
|
||||
state.stack?.waitingOn ??
|
||||
state.pendingDiscard ??
|
||||
state.chaosPending?.queue[0] ??
|
||||
state.outOfTurnWindow?.playerId ??
|
||||
state.players[state.turn.activeIndex]!.id
|
||||
);
|
||||
}
|
||||
|
||||
/** Drive a full bot-vs-bot game; returns the final state and command count. */
|
||||
function playOut(
|
||||
seed: number, players: number, expansion = true,
|
||||
styles: AutomatonStyle[] = [], tiers: AutomatonTier[] = [],
|
||||
) {
|
||||
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
|
||||
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
|
||||
const tierOf = new Map(ids.map((id, i) => [id, tiers[i] ?? "archmage"]));
|
||||
let { state } = createGame({
|
||||
playerIds: ids,
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
deckRev: 8,
|
||||
});
|
||||
let commands = 0;
|
||||
let stuck = 0;
|
||||
const CAP = 4000;
|
||||
while (state.phase === "playing" && commands < CAP) {
|
||||
const seat = actingSeat(state);
|
||||
const view = viewFor(state, seat);
|
||||
const cmd = automatonCommand(view, styleOf.get(seat), tierOf.get(seat)) ?? automatonFallback(view);
|
||||
let r = applyCommand(state, seat, cmd);
|
||||
if (!r.ok) {
|
||||
const fb = automatonFallback(view);
|
||||
r = applyCommand(state, seat, fb);
|
||||
if (!r.ok) {
|
||||
stuck++;
|
||||
if (stuck > 3) {
|
||||
throw new Error(
|
||||
`automaton stuck at seat ${seat} after ${commands} commands: ` +
|
||||
`${JSON.stringify(cmd)} -> ${JSON.stringify(fb)} both refused (${r.error})`,
|
||||
);
|
||||
}
|
||||
// Last-ditch: burn the turn structure forward.
|
||||
r = applyCommand(state, seat, { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) r = applyCommand(state, seat, { type: "pass" });
|
||||
if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`);
|
||||
}
|
||||
} else {
|
||||
stuck = 0;
|
||||
}
|
||||
state = r.state;
|
||||
commands++;
|
||||
}
|
||||
return { state, commands };
|
||||
}
|
||||
|
||||
describe("automaton vs automaton", () => {
|
||||
it("two clockwork wizards fight a game to its end", () => {
|
||||
const { state, commands } = playOut(11, 2);
|
||||
expect(state.phase).toBe("finished");
|
||||
expect(state.winner).not.toBeNull();
|
||||
expect(commands).toBeLessThan(4000);
|
||||
});
|
||||
|
||||
it("holds up across many seeds without wedging", () => {
|
||||
let finished = 0;
|
||||
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34]) {
|
||||
const { state } = playOut(seed, 2);
|
||||
if (state.phase === "finished") finished++;
|
||||
}
|
||||
// Cautious clockwork can stall a maze; most games must still conclude.
|
||||
expect(finished).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("a full table of four automatons concludes", () => {
|
||||
const { state } = playOut(7, 4);
|
||||
expect(state.phase).toBe("finished");
|
||||
});
|
||||
|
||||
it("the apprentice handicap bites where cards decide: combat mirrors", () => {
|
||||
// Deterministic across these seeds: same brains, same dice.
|
||||
let arch = 0, appr = 0;
|
||||
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233]) {
|
||||
const { state } = playOut(seed, 2, true,
|
||||
["berserker", "berserker"], ["archmage", "apprentice"]);
|
||||
if (state.winner === "bot1") arch++;
|
||||
if (state.winner === "bot2") appr++;
|
||||
}
|
||||
expect(arch).toBeGreaterThan(appr);
|
||||
});
|
||||
|
||||
it("every temperament finishes its wars", () => {
|
||||
for (const styles of [
|
||||
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],
|
||||
] as AutomatonStyle[][]) {
|
||||
let finished = 0;
|
||||
for (const seed of [3, 17, 29]) {
|
||||
const { state } = playOut(seed, 2, true, styles);
|
||||
if (state.phase === "finished") finished++;
|
||||
}
|
||||
expect(finished).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
getRoom,
|
||||
joinRoom,
|
||||
loadPersistedRooms,
|
||||
addAutomaton,
|
||||
addChat,
|
||||
driveOneAutomaton,
|
||||
makeTransferCode,
|
||||
redactFor,
|
||||
roomCount,
|
||||
@@ -170,6 +172,9 @@ function roomInfo(room: Room) {
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
[...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +186,75 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The clockwork plays at a watchable pace: one command every beat, each
|
||||
* 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.5) 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;
|
||||
pumping.add(room.id);
|
||||
const tick = () => {
|
||||
const step = driveOneAutomaton(room);
|
||||
if (!step) {
|
||||
pumping.delete(room.id);
|
||||
return;
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
function broadcastRoomState(room: Room): void {
|
||||
broadcast(room, () => roomInfo(room));
|
||||
if (room.state) {
|
||||
@@ -250,6 +324,20 @@ wss.on("connection", (socket) => {
|
||||
send(socket, { type: "events", events: redactFor(room.events, name) });
|
||||
}
|
||||
broadcastRoomState(room);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "addBot": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
|
||||
const result = addAutomaton(
|
||||
room,
|
||||
typeof msg.style === "string" ? msg.style : undefined,
|
||||
typeof msg.tier === "string" ? msg.tier : undefined,
|
||||
);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "start": {
|
||||
@@ -260,6 +348,7 @@ wss.on("connection", (socket) => {
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
||||
broadcastRoomState(room);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "command": {
|
||||
@@ -272,6 +361,7 @@ wss.on("connection", (socket) => {
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "rollDie": {
|
||||
|
||||
@@ -17,6 +17,14 @@ import {
|
||||
} from "@wizwar/engine";
|
||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { recordRoom } from "./stats";
|
||||
import {
|
||||
automatonCommand,
|
||||
automatonFallback,
|
||||
AUTOMATON_STYLES,
|
||||
AUTOMATON_TIERS,
|
||||
type AutomatonStyle,
|
||||
type AutomatonTier,
|
||||
} from "@wizwar/engine";
|
||||
|
||||
export interface LoggedCommand {
|
||||
seq: number;
|
||||
@@ -41,6 +49,8 @@ export interface Room {
|
||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||
/** Table talk, persisted with the room (public to all seats). */
|
||||
chat: { player: PlayerId; text: string; at: string }[];
|
||||
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -98,6 +108,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -230,6 +241,68 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
|
||||
return { text, at };
|
||||
}
|
||||
|
||||
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
|
||||
|
||||
/** Seat a clockwork wizard (host's choice, before the game starts). */
|
||||
export function addAutomaton(
|
||||
room: Room,
|
||||
styleWanted?: string,
|
||||
tierWanted?: string,
|
||||
): { name: PlayerId } | { error: string } {
|
||||
if (room.state) return { error: "the game has started" };
|
||||
if (room.players.length >= 6) return { error: "room is full" };
|
||||
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
|
||||
if (!name) return { error: "the workshop is empty" };
|
||||
const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle);
|
||||
const style: AutomatonStyle = known
|
||||
? (styleWanted as AutomatonStyle)
|
||||
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
|
||||
const secret = !known; // the mystery machine keeps its mood to itself
|
||||
const tier: AutomatonTier = AUTOMATON_TIERS.includes(tierWanted as AutomatonTier)
|
||||
? (tierWanted as AutomatonTier)
|
||||
: "adept";
|
||||
room.players.push(name);
|
||||
room.bots.set(name, { style, secret, tier });
|
||||
appendLine(room.id, { kind: "join", name, bot: true, style, tier, ...(secret ? { secret: true } : {}) });
|
||||
return { name };
|
||||
}
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(room: Room): PlayerId | null {
|
||||
const s = room.state;
|
||||
if (!s || s.phase !== "playing") return null;
|
||||
return (
|
||||
s.stack?.waitingOn ??
|
||||
s.pendingDiscard ??
|
||||
s.chaosPending?.queue[0] ??
|
||||
s.outOfTurnWindow?.playerId ??
|
||||
s.players[s.turn.activeIndex]!.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One automaton command, if the maze is waiting on clockwork. Null when a
|
||||
* human holds the floor (or the game is over, or the clockwork is wedged).
|
||||
*/
|
||||
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
|
||||
const seat = actingSeat(room);
|
||||
if (!seat || !room.bots.has(seat)) return null;
|
||||
const view = viewFor(room.state!, seat);
|
||||
const bot = room.bots.get(seat);
|
||||
const cmd = automatonCommand(view, bot?.style, bot?.tier) ?? automatonFallback(view);
|
||||
let r = runCommand(room, seat, cmd);
|
||||
if ("error" in r) {
|
||||
r = runCommand(room, seat, automatonFallback(view));
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
||||
if ("error" in r) {
|
||||
console.error(`automaton ${seat} wedged in ${room.id}: ${r.error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { seat, events: r.events };
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
roomId: string;
|
||||
name: PlayerId;
|
||||
@@ -427,13 +500,23 @@ export function loadPersistedRooms(): void {
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
};
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.kind === "join") {
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
room.bots.set(line.name, {
|
||||
style: (line.style as AutomatonStyle) ?? "hunter",
|
||||
secret: line.secret === true,
|
||||
tier: (line.tier as AutomatonTier) ?? "archmage",
|
||||
});
|
||||
} else {
|
||||
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
|
||||
if (!joinHash) throw new Error("join line has no token");
|
||||
room.players.push(line.name);
|
||||
room.tokens.set(line.name, joinHash);
|
||||
}
|
||||
} else if (line.kind === "start") {
|
||||
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,14 @@ export interface JoinLine {
|
||||
tokenHash?: string;
|
||||
/** Legacy plaintext token (pre-hashing files only). */
|
||||
token?: string;
|
||||
/** An automaton seat: no token; the server plays it. */
|
||||
bot?: true;
|
||||
/** The automaton's temperament. */
|
||||
style?: string;
|
||||
/** The automaton's difficulty tier. */
|
||||
tier?: string;
|
||||
/** A mystery machine: the temperament is not revealed to the table. */
|
||||
secret?: true;
|
||||
}
|
||||
|
||||
export interface StartLine {
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
/** Leafing through the face-up discard pile. */
|
||||
let showDiscards = $state(false);
|
||||
let chatDraft = $state("");
|
||||
let botTier = $state("adept");
|
||||
/** Card whose official FAQ rulings are open. */
|
||||
let faqCardId = $state<string | null>(null);
|
||||
/** A discard-pile card enlarged above the pile. */
|
||||
@@ -1028,7 +1029,7 @@
|
||||
{:else}
|
||||
<span class="dot" style:background="#b3a687"></span>
|
||||
{/if}
|
||||
{p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""}
|
||||
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -1049,6 +1050,20 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#if net.you === net.hostId}
|
||||
{#if net.players.length < 6}
|
||||
<span class="bot-row">
|
||||
⚙ seat a
|
||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
|
||||
<option value="apprentice">apprentice</option>
|
||||
<option value="adept">adept</option>
|
||||
<option value="archmage">archmage</option>
|
||||
</select>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("hunter", botTier)}>hunter</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("berserker", botTier)}>berserker</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("worrier", botTier)}>worrier</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot(undefined, botTier)}>mystery</button>
|
||||
</span>
|
||||
{/if}
|
||||
<label class="check">
|
||||
<input type="checkbox" bind:checked={withExpansion} />
|
||||
Include Expansion Set #1 — monsters & wands
|
||||
@@ -1156,7 +1171,10 @@
|
||||
{#each view.players as p (p.id)}
|
||||
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
|
||||
<span class="dot" style:background={playerColor(p.id)}></span>
|
||||
<span class="score-name">{p.id}{p.id === view.you ? " (you)" : ""}</span>
|
||||
<span class="score-name">
|
||||
{p.id}{p.id === view.you ? " (you)" : ""}{#if net.roomBots[p.id]}
|
||||
<span class="score-temper">⚙ {net.roomBots[p.id]}</span>{/if}
|
||||
</span>
|
||||
<span class="score-life">{p.life}</span>
|
||||
<span class="score-marks">
|
||||
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if p.carriedTreasureId} · 💰{/if}
|
||||
@@ -1833,6 +1851,12 @@
|
||||
}
|
||||
.slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
|
||||
.score-row.active .score-name { font-weight: 700; }
|
||||
.score-temper {
|
||||
font-family: "Caveat", cursive;
|
||||
font-size: 0.85rem;
|
||||
color: #8a7a5e;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.table-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1994,6 +2018,21 @@
|
||||
.big-peek :global(.card:hover) { transform: scale(2.1); }
|
||||
.big-peek { display: flex; flex-direction: column; align-items: center; }
|
||||
.big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; }
|
||||
.tier-pick {
|
||||
background: #f6f0df;
|
||||
border: 1px solid #b3a687;
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
color: #43331f;
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
.bot-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b5a41;
|
||||
}
|
||||
.discard-link {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -225,6 +225,7 @@ class Net {
|
||||
started = $state(false);
|
||||
/** Lobby standee choices, by player name. */
|
||||
roomColors = $state<Record<string, number>>({});
|
||||
roomBots = $state<Record<string, string>>({});
|
||||
you = $state<string | null>(null);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
@@ -295,6 +296,7 @@ class Net {
|
||||
case "room":
|
||||
this.roomId = msg.roomId;
|
||||
this.roomColors = msg.colors ?? {};
|
||||
this.roomBots = msg.bots ?? {};
|
||||
if (this.you && this.token) {
|
||||
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||
@@ -426,6 +428,10 @@ class Net {
|
||||
}
|
||||
|
||||
/** Ask the server how all our games are doing. */
|
||||
addBot(style?: string, tier?: string): void {
|
||||
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
|
||||
}
|
||||
|
||||
rollTableDie(): void {
|
||||
this.send({ type: "rollDie" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user