Merge branch 'automatons'

This commit is contained in:
Eric Wagoner
2026-08-16 18:34:43 -04:00
11 changed files with 1019 additions and 7 deletions
+1
View File
@@ -5,3 +5,4 @@ dist/
.DS_Store .DS_Store
data/ data/
.claude/ .claude/
.playwright-mcp/
+663
View File
@@ -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 };
}
+1
View File
@@ -9,3 +9,4 @@ export * from "./cards";
export * from "./setups"; export * from "./setups";
export * from "./game"; export * from "./game";
export * from "./view"; export * from "./view";
export * from "./automaton";
+116
View File
@@ -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);
}
});
});
+90
View File
@@ -35,7 +35,9 @@ import {
getRoom, getRoom,
joinRoom, joinRoom,
loadPersistedRooms, loadPersistedRooms,
addAutomaton,
addChat, addChat,
driveOneAutomaton,
makeTransferCode, makeTransferCode,
redactFor, redactFor,
roomCount, roomCount,
@@ -170,6 +172,9 @@ function roomInfo(room: Room) {
hostId: room.hostId, hostId: room.hostId,
started: room.state !== null, started: room.state !== null,
colors: Object.fromEntries(room.colorChoices), 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 { function broadcastRoomState(room: Room): void {
broadcast(room, () => roomInfo(room)); broadcast(room, () => roomInfo(room));
if (room.state) { if (room.state) {
@@ -250,6 +324,20 @@ wss.on("connection", (socket) => {
send(socket, { type: "events", events: redactFor(room.events, name) }); send(socket, { type: "events", events: redactFor(room.events, name) });
} }
broadcastRoomState(room); 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; break;
} }
case "start": { case "start": {
@@ -260,6 +348,7 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error }); 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: "events", events: redactFor(result.events, playerId) }));
broadcastRoomState(room); broadcastRoomState(room);
runBots(room);
break; break;
} }
case "command": { case "command": {
@@ -272,6 +361,7 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error }); 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: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
runBots(room);
break; break;
} }
case "rollDie": { case "rollDie": {
+87 -4
View File
@@ -17,6 +17,14 @@ import {
} from "@wizwar/engine"; } from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats"; import { recordRoom } from "./stats";
import {
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
type AutomatonStyle,
type AutomatonTier,
} from "@wizwar/engine";
export interface LoggedCommand { export interface LoggedCommand {
seq: number; seq: number;
@@ -41,6 +49,8 @@ export interface Room {
events: GameEvent[]; // full history (unredacted — redact per recipient) events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */ /** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[]; 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>(); const rooms = new Map<string, Room>();
@@ -98,6 +108,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
log: [], log: [],
events: [], events: [],
chat: [], chat: [],
bots: new Map(),
}; };
rooms.set(room.id, room); rooms.set(room.id, room);
recordRoom(room); recordRoom(room);
@@ -230,6 +241,68 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
return { text, at }; 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 { export interface GameSummary {
roomId: string; roomId: string;
name: PlayerId; name: PlayerId;
@@ -427,13 +500,23 @@ export function loadPersistedRooms(): void {
log: [], log: [],
events: [], events: [],
chat: [], chat: [],
bots: new Map(),
}; };
for (const line of lines.slice(1)) { for (const line of lines.slice(1)) {
if (line.kind === "join") { if (line.kind === "join") {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); if (line.bot) {
if (!joinHash) throw new Error("join line has no token"); room.players.push(line.name);
room.players.push(line.name); room.bots.set(line.name, {
room.tokens.set(line.name, joinHash); 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") { } else if (line.kind === "start") {
const r = startInMemory(room, line.expansion, line.colors, line.deckRev); const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`); if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
+5 -1
View File
@@ -24,6 +24,8 @@ export interface EngagementStats {
firstGameAt: string | null; firstGameAt: string | null;
/** How many of the games were hotseat tables reporting in anonymously. */ /** How many of the games were hotseat tables reporting in anonymously. */
hotseatGames: number; hotseatGames: number;
/** Games with at least one clockwork wizard at the table. */
automatonGames: number;
} }
interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> { interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
@@ -40,7 +42,7 @@ let data: StatsFile = {
commandsPlayed: 0, minutesAtTable: 0, commandsPlayed: 0, minutesAtTable: 0,
winsByTreasure: 0, winsByLastStanding: 0, winsByTreasure: 0, winsByLastStanding: 0,
longestGameCommands: 0, fullestTable: 0, firstGameAt: null, longestGameCommands: 0, fullestTable: 0, firstGameAt: null,
hotseatGames: 0, wizards: [], roomStages: {}, hotseatGames: 0, automatonGames: 0, wizards: [], roomStages: {},
}; };
let wizardSet = new Set<string>(); let wizardSet = new Set<string>();
let loaded = false; let loaded = false;
@@ -84,6 +86,7 @@ export function recordRoom(room: Room): void {
const stage = data.roomStages[room.id]; const stage = data.roomStages[room.id];
for (const p of room.players) { for (const p of room.players) {
if (room.bots.has(p)) continue; // the clockwork are not wizards seated
const key = p.toLowerCase(); const key = p.toLowerCase();
if (!wizardSet.has(key)) { wizardSet.add(key); dirty = true; } 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") { if (room.state && data.roomStages[room.id] === "created") {
data.gamesStarted++; data.gamesStarted++;
if (room.bots.size > 0) data.automatonGames++;
data.fullestTable = Math.max(data.fullestTable, room.players.length); data.fullestTable = Math.max(data.fullestTable, room.players.length);
data.roomStages[room.id] = "started"; data.roomStages[room.id] = "started";
dirty = true; dirty = true;
+8
View File
@@ -24,6 +24,14 @@ export interface JoinLine {
tokenHash?: string; tokenHash?: string;
/** Legacy plaintext token (pre-hashing files only). */ /** Legacy plaintext token (pre-hashing files only). */
token?: string; 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 { export interface StartLine {
+41 -2
View File
@@ -36,6 +36,7 @@
/** Leafing through the face-up discard pile. */ /** Leafing through the face-up discard pile. */
let showDiscards = $state(false); let showDiscards = $state(false);
let chatDraft = $state(""); let chatDraft = $state("");
let botTier = $state("adept");
/** Card whose official FAQ rulings are open. */ /** Card whose official FAQ rulings are open. */
let faqCardId = $state<string | null>(null); let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */ /** A discard-pile card enlarged above the pile. */
@@ -1028,7 +1029,7 @@
{:else} {:else}
<span class="dot" style:background="#b3a687"></span> <span class="dot" style:background="#b3a687"></span>
{/if} {/if}
{p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""} {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -1049,6 +1050,20 @@
{/each} {/each}
</div> </div>
{#if net.you === net.hostId} {#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"> <label class="check">
<input type="checkbox" bind:checked={withExpansion} /> <input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands Include Expansion Set #1 — monsters &amp; wands
@@ -1156,7 +1171,10 @@
{#each view.players as p (p.id)} {#each view.players as p (p.id)}
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}> <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="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-life">{p.life}</span>
<span class="score-marks"> <span class="score-marks">
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}&nbsp;· 💰{/if} {p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}&nbsp;· 💰{/if}
@@ -1833,6 +1851,12 @@
} }
.slip.creature-note { background: #e7edda; border-color: #7d8a5a; } .slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
.score-row.active .score-name { font-weight: 700; } .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 { .table-cards {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1994,6 +2018,21 @@
.big-peek :global(.card:hover) { transform: scale(2.1); } .big-peek :global(.card:hover) { transform: scale(2.1); }
.big-peek { display: flex; flex-direction: column; align-items: center; } .big-peek { display: flex; flex-direction: column; align-items: center; }
.big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; } .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 { .discard-link {
background: none; background: none;
border: none; border: none;
+1
View File
@@ -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.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.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.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> </dl>
{#if stats.firstGameAt} {#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> <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>
+6
View File
@@ -225,6 +225,7 @@ class Net {
started = $state(false); started = $state(false);
/** Lobby standee choices, by player name. */ /** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({}); roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({});
you = $state<string | null>(null); you = $state<string | null>(null);
view = $state<GameView | null>(null); view = $state<GameView | null>(null);
log = $state<string[]>([]); log = $state<string[]>([]);
@@ -295,6 +296,7 @@ class Net {
case "room": case "room":
this.roomId = msg.roomId; this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {}; this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
if (this.you && this.token) { if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -426,6 +428,10 @@ class Net {
} }
/** Ask the server how all our games are doing. */ /** 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 { rollTableDie(): void {
this.send({ type: "rollDie" }); this.send({ type: "rollDie" });
} }