The clockwork learns numbers, summons, creatures — and temperament
The automaton now plays number cards where they matter: attached to value-scaled attacks (waterbolt, powerthrust) choosing the biggest for damage, and played for movement when the goal is just out of stride. It raises its monsters when no wizard is in spell range and commands them every turn — creatures march at enemies by the same hazard-shy BFS and maul whoever shares their square. Targets are picked by lowest life. And it has moods. The HUNTER plays the classic game: gold first, violence when convenient. The BERSERKER hunts wizards over treasure and finishes games by last-wizard-standing. The WORRIER paths around enemies, counters at a lower threshold, shields against chaos, and never brawls. Hosts pick the temperament when seating one (or the workshop assigns at random); it persists with the seat, shows in the roster, and the tournament harness proves every pairing finishes. Across ten seeds of berserker versus hunter: six treasure wins to four kills — the moods play genuinely different games. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
01101ca24a
commit
5a407a1e4c
@@ -2,30 +2,29 @@
|
||||
// the same information a human seat receives — and returns one command per
|
||||
// consultation; the server keeps consulting until the maze stops asking.
|
||||
// It never throws: when in doubt it passes, discards, or ends its turn.
|
||||
//
|
||||
// Temperaments color its judgment: the HUNTER marches for treasure, the
|
||||
// BERSERKER for blood, the WORRIER for the shadows between the two.
|
||||
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import { cellKey, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { sightedCellsFor, type GameView } from "./view";
|
||||
import type { Command, PlayerId } from "./game";
|
||||
|
||||
/** Damage attacks simple enough for clockwork: id -> flat damage dealt. */
|
||||
const SIMPLE_ATTACKS: Record<string, number> = {
|
||||
fireball: 5,
|
||||
"sudden-death": 10,
|
||||
powerthrust: 2,
|
||||
dagger: 1,
|
||||
"large-rock": 2,
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
|
||||
|
||||
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
|
||||
const ATTACKS: Record<string, { base: number; perNumber: boolean }> = {
|
||||
fireball: { base: 5, perNumber: false },
|
||||
"sudden-death": { base: 10, perNumber: false },
|
||||
powerthrust: { base: 2, perNumber: true },
|
||||
waterbolt: { base: 0, perNumber: true },
|
||||
dagger: { base: 1, perNumber: false },
|
||||
"large-rock": { base: 2, perNumber: false },
|
||||
};
|
||||
|
||||
/** Cards the automaton happily discards to churn its hand. */
|
||||
function discardValue(c: CardInstance): number {
|
||||
const def = cardDef(c.cardId);
|
||||
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 8;
|
||||
if (SIMPLE_ATTACKS[c.cardId] != null) return 7;
|
||||
if (def.cardType === "number") return 4 + (def.value ?? 0);
|
||||
if (def.cardType === "attack") return 3;
|
||||
return 2; // situational neutrals go first
|
||||
}
|
||||
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow"]);
|
||||
|
||||
function me(view: GameView) {
|
||||
return view.players.find((p) => p.id === view.you)!;
|
||||
@@ -35,14 +34,45 @@ function livingEnemies(view: GameView) {
|
||||
return view.players.filter((p) => p.alive && p.id !== view.you);
|
||||
}
|
||||
|
||||
/** BFS over walkable steps; returns the first direction of a shortest path. */
|
||||
function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side | null {
|
||||
function numbersInHand(view: GameView): CardInstance[] {
|
||||
return view.yourHand
|
||||
.filter((c) => cardDef(c.cardId).cardType === "number")
|
||||
.sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0));
|
||||
}
|
||||
|
||||
/** Cards the automaton happily discards to churn its hand, worst first. */
|
||||
function discardValue(c: CardInstance): number {
|
||||
const def = cardDef(c.cardId);
|
||||
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
|
||||
if (SUMMONS.has(c.cardId)) return 8;
|
||||
if (ATTACKS[c.cardId] != null) return 7;
|
||||
if (def.cardType === "number") return 4 + (def.value ?? 0);
|
||||
if (def.cardType === "attack") return 3;
|
||||
return 2; // situational neutrals go first
|
||||
}
|
||||
|
||||
interface PathResult {
|
||||
dir: Side;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** BFS over walkable steps toward the nearest goal; hazard-shy, enemy-shy optional. */
|
||||
function pathToward(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
goals: Set<string>,
|
||||
avoidNearEnemies: boolean,
|
||||
): PathResult | null {
|
||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||
const nearEnemy = (c: Cell) =>
|
||||
enemyCells.some((e) => Math.abs(e.x - c.x) + Math.abs(e.y - c.y) <= 2);
|
||||
const cameBy = new Map<string, { prev: string; dir: Side }>();
|
||||
const seen = new Set<string>([cellKey(from)]);
|
||||
let frontier: Cell[] = [from];
|
||||
let found: string | null = null;
|
||||
for (let depth = 0; depth < 60 && frontier.length > 0 && !found; depth++) {
|
||||
let depth = 0;
|
||||
for (; depth < 60 && frontier.length > 0 && !found; depth++) {
|
||||
const next: Cell[] = [];
|
||||
for (const c of frontier) {
|
||||
for (const dir of SIDES) {
|
||||
@@ -54,11 +84,10 @@ function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side |
|
||||
seen.add(k);
|
||||
cameBy.set(k, { prev: cellKey(c), dir });
|
||||
if (goals.has(k)) { found = k; break; }
|
||||
// The automaton refuses to path THROUGH hazards, but will end on one
|
||||
// only if it is the goal itself.
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime") continue;
|
||||
if (avoidNearEnemies && nearEnemy(t.to)) continue;
|
||||
next.push(t.to);
|
||||
}
|
||||
if (found) break;
|
||||
@@ -69,12 +98,12 @@ function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side |
|
||||
let cursor = found;
|
||||
for (;;) {
|
||||
const hop = cameBy.get(cursor)!;
|
||||
if (hop.prev === cellKey(from)) return hop.dir;
|
||||
if (hop.prev === cellKey(from)) return { dir: hop.dir, distance: depth };
|
||||
cursor = hop.prev;
|
||||
}
|
||||
}
|
||||
|
||||
/** The treasure squares worth marching for, best first. */
|
||||
/** The treasure squares worth marching for. */
|
||||
function treasureGoals(view: GameView): Set<string> {
|
||||
const self = me(view);
|
||||
const goals = new Set<string>();
|
||||
@@ -85,8 +114,6 @@ function treasureGoals(view: GameView): Set<string> {
|
||||
for (const t of view.treasures) {
|
||||
if (!t.position || t.carriedBy) continue;
|
||||
if (t.owner === view.you) continue;
|
||||
// A treasure already delivered to one of MY... any floor treasure of an
|
||||
// enemy is worth taking; skip ones resting on my own home (already won).
|
||||
if (cellKey(t.position) === cellKey(self.home)) continue;
|
||||
goals.add(cellKey(t.position));
|
||||
}
|
||||
@@ -94,8 +121,7 @@ function treasureGoals(view: GameView): Set<string> {
|
||||
}
|
||||
|
||||
function overLimit(view: GameView): number {
|
||||
const limit = 7;
|
||||
return Math.max(0, view.yourHand.length - limit);
|
||||
return Math.max(0, view.yourHand.length - 7);
|
||||
}
|
||||
|
||||
function worstCards(view: GameView, n: number): string[] {
|
||||
@@ -105,38 +131,88 @@ function worstCards(view: GameView, n: number): string[] {
|
||||
.map((c) => c.instanceId);
|
||||
}
|
||||
|
||||
/** What the automaton does when the maze wants a response from it. */
|
||||
function respond(view: GameView): Command {
|
||||
/** Counteraction judgment; the worrier flinches at less. */
|
||||
function respond(view: GameView, style: AutomatonStyle): Command {
|
||||
const stack = view.stack!;
|
||||
if (stack.defenderId !== view.you) return { type: "pass" };
|
||||
const incoming = stack.attackCard ? (SIMPLE_ATTACKS[stack.attackCard.cardId] ?? 2) : 1;
|
||||
const flinch = style === "worrier" ? 1 : 0;
|
||||
const atk = stack.attackCard ? ATTACKS[stack.attackCard.cardId] : null;
|
||||
const incoming = stack.attackCard
|
||||
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||
: 1;
|
||||
const hand = view.yourHand;
|
||||
const find = (id: string) => hand.find((c) => c.cardId === id);
|
||||
if (stack.kind === "spell") {
|
||||
const shield = find("full-shield");
|
||||
if (shield && incoming >= 3) return { type: "counteract", instanceId: shield.instanceId };
|
||||
if (shield && incoming >= 3 - flinch) return { type: "counteract", instanceId: shield.instanceId };
|
||||
const reflect = find("full-reflection");
|
||||
if (reflect && incoming >= 4) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
}
|
||||
const blunt = find("blunt");
|
||||
if (blunt && incoming >= 2) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
/** The best attack available against a visible target, numbers included. */
|
||||
function bestAttack(view: GameView, targetId: PlayerId): Command | null {
|
||||
const numbers = numbersInHand(view);
|
||||
const biggest = numbers[numbers.length - 1];
|
||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||
let best: { cmd: Command; damage: number } | null = null;
|
||||
for (const c of view.yourHand) {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) continue;
|
||||
const withNumber = atk.perNumber && biggest;
|
||||
const damage = atk.base + (withNumber ? biggestValue : 0);
|
||||
if (damage <= 0) continue;
|
||||
if (!best || damage > best.damage) {
|
||||
best = {
|
||||
damage,
|
||||
cmd: {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return best?.cmd ?? null;
|
||||
}
|
||||
|
||||
/** An empty, sighted square near the target for a summoned creature. */
|
||||
function summonSpot(view: GameView, near: Cell): Cell | null {
|
||||
const sighted = sightedCellsFor(view);
|
||||
let best: { cell: Cell; d: number } | null = null;
|
||||
for (const k of sighted) {
|
||||
if (view.squareContents[k]) continue;
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (view.creatures.some((c) => c.position.x === x && c.position.y === y)) continue;
|
||||
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
|
||||
const d = Math.abs(x - near.x) + Math.abs(y - near.y);
|
||||
if (!best || d < best.d) best = { cell: { x, y }, d };
|
||||
}
|
||||
return best?.cell ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One decision from the automaton's seat, or null when the maze is not
|
||||
* asking it anything.
|
||||
*/
|
||||
export function automatonCommand(view: GameView): Command | null {
|
||||
export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter"): Command | null {
|
||||
const you = view.you as PlayerId;
|
||||
if (view.phase !== "playing") return null;
|
||||
|
||||
if (view.pendingDiscard === you) {
|
||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
||||
}
|
||||
if (view.chaosPending && view.chaosPending.queue[0] === you) return { type: "pass" };
|
||||
if (view.chaosPending && view.chaosPending.queue[0] === you) {
|
||||
const shield = view.yourHand.find((c) => c.cardId === "full-shield");
|
||||
return style === "worrier" && shield
|
||||
? { type: "counteract", instanceId: shield.instanceId }
|
||||
: { type: "pass" };
|
||||
}
|
||||
if (view.stack) {
|
||||
return view.stack.waitingOn === you ? respond(view) : null;
|
||||
return view.stack.waitingOn === you ? respond(view, style) : null;
|
||||
}
|
||||
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
|
||||
if (view.activePlayerId !== you) return null;
|
||||
@@ -156,34 +232,70 @@ export function automatonCommand(view: GameView): Command | null {
|
||||
if (prize) return { type: "pickUpTreasure" };
|
||||
}
|
||||
|
||||
// One attack per turn: the nearest visible enemy eats the best simple spell.
|
||||
// Command the menagerie: creatures march and maul before the wizard moves.
|
||||
for (const c of view.creatures) {
|
||||
if (c.controllerId !== you || c.justCreated) continue;
|
||||
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
|
||||
if (enemyHere && !c.attackUsed &&
|
||||
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
||||
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
||||
}
|
||||
if (c.movementUsed < c.movesPerTurn) {
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
const hunt = pathToward(view, c.position, enemyCells, false);
|
||||
if (hunt) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
|
||||
}
|
||||
}
|
||||
|
||||
// One attack per turn.
|
||||
if (!view.turn.attackUsed && view.turn.round > 1) {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position)));
|
||||
if (visible.length > 0) {
|
||||
const target = visible[0]!;
|
||||
const spells = view.yourHand
|
||||
.filter((c) => SIMPLE_ATTACKS[c.cardId] != null)
|
||||
.sort((a, b) => SIMPLE_ATTACKS[b.cardId]! - SIMPLE_ATTACKS[a.cardId]!);
|
||||
if (spells.length > 0) {
|
||||
return {
|
||||
type: "cast", instanceId: spells[0]!.instanceId,
|
||||
target: { kind: "player", playerId: target.id },
|
||||
};
|
||||
const target = visible.sort((a, b) => a.life - b.life)[0]!;
|
||||
const spell = bestAttack(view, target.id);
|
||||
if (spell) return spell;
|
||||
// The berserker summons help where the hunter saves the card.
|
||||
if (cellKey(target.position) === here && style !== "worrier") {
|
||||
return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
// No shot at a wizard: raise a creature to do the walking.
|
||||
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
|
||||
if (summon && livingEnemies(view).length > 0) {
|
||||
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
|
||||
const spot = summonSpot(view, near);
|
||||
if (spot) {
|
||||
return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } };
|
||||
}
|
||||
if (cellKey(target.position) === here) return { type: "punch", targetId: target.id };
|
||||
}
|
||||
}
|
||||
|
||||
// March toward the objective.
|
||||
// March. The berserker hunts wizards over gold; the worrier keeps its
|
||||
// distance; the hunter goes where the treasure is.
|
||||
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
||||
const goals = treasureGoals(view);
|
||||
const dir = firstStepToward(view, self.position, goals);
|
||||
if (dir) return { type: "move", direction: dir };
|
||||
// No treasure path: close on the nearest enemy for a brawl.
|
||||
const gold = treasureGoals(view);
|
||||
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
|
||||
const hunt = firstStepToward(view, self.position, enemyCells);
|
||||
if (hunt) return { type: "move", direction: hunt };
|
||||
const objectives = style === "berserker" && !me(view).carriedTreasureId
|
||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||
: gold;
|
||||
const path = pathToward(view, self.position, objectives, style === "worrier");
|
||||
const fallbackPath = path ?? pathToward(view, self.position, objectives, false) ??
|
||||
pathToward(view, self.position, enemyCells, false);
|
||||
if (fallbackPath) {
|
||||
// A number card closes the gap when the goal is just out of stride.
|
||||
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
if (!view.turn.numberPlayedForMovement && fallbackPath.distance > movesLeft) {
|
||||
const numbers = numbersInHand(view);
|
||||
const helper = numbers.find(
|
||||
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= fallbackPath.distance,
|
||||
) ?? (fallbackPath.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined);
|
||||
if (helper) {
|
||||
return { type: "playNumberForMovement", instanceId: helper.instanceId };
|
||||
}
|
||||
}
|
||||
return { type: "move", direction: fallbackPath.dir };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "endTurn", draw: 2 };
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback } from "../src/automaton";
|
||||
import { automatonCommand, automatonFallback, type AutomatonStyle } from "../src/automaton";
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(state: GameState): PlayerId {
|
||||
@@ -20,8 +20,9 @@ function actingSeat(state: GameState): PlayerId {
|
||||
}
|
||||
|
||||
/** Drive a full bot-vs-bot game; returns the final state and command count. */
|
||||
function playOut(seed: number, players: number, expansion = true) {
|
||||
function playOut(seed: number, players: number, expansion = true, styles: AutomatonStyle[] = []) {
|
||||
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
|
||||
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
|
||||
let { state } = createGame({
|
||||
playerIds: ids,
|
||||
seed,
|
||||
@@ -34,7 +35,7 @@ function playOut(seed: number, players: number, expansion = true) {
|
||||
while (state.phase === "playing" && commands < CAP) {
|
||||
const seat = actingSeat(state);
|
||||
const view = viewFor(state, seat);
|
||||
const cmd = automatonCommand(view) ?? automatonFallback(view);
|
||||
const cmd = automatonCommand(view, styleOf.get(seat)) ?? automatonFallback(view);
|
||||
let r = applyCommand(state, seat, cmd);
|
||||
if (!r.ok) {
|
||||
const fb = automatonFallback(view);
|
||||
@@ -83,4 +84,17 @@ describe("automaton vs automaton", () => {
|
||||
const { state } = playOut(7, 4);
|
||||
expect(state.phase).toBe("finished");
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ function roomInfo(room: Room) {
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: [...room.bots],
|
||||
bots: Object.fromEntries(room.bots),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ wss.on("connection", (socket) => {
|
||||
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);
|
||||
const result = addAutomaton(room, typeof msg.style === "string" ? msg.style : undefined);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
} from "@wizwar/engine";
|
||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { recordRoom } from "./stats";
|
||||
import { automatonCommand, automatonFallback } from "@wizwar/engine";
|
||||
import {
|
||||
automatonCommand,
|
||||
automatonFallback,
|
||||
AUTOMATON_STYLES,
|
||||
type AutomatonStyle,
|
||||
} from "@wizwar/engine";
|
||||
|
||||
export interface LoggedCommand {
|
||||
seq: number;
|
||||
@@ -42,8 +47,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. */
|
||||
bots: Set<PlayerId>;
|
||||
/** Seats the server itself plays, and each one's temperament. */
|
||||
bots: Map<PlayerId, AutomatonStyle>;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -101,7 +106,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Set(),
|
||||
bots: new Map(),
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -237,14 +242,17 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
|
||||
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): { name: PlayerId } | { error: string } {
|
||||
export function addAutomaton(room: Room, styleWanted?: 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 style: AutomatonStyle = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle)
|
||||
? (styleWanted as AutomatonStyle)
|
||||
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
|
||||
room.players.push(name);
|
||||
room.bots.add(name);
|
||||
appendLine(room.id, { kind: "join", name, bot: true });
|
||||
room.bots.set(name, style);
|
||||
appendLine(room.id, { kind: "join", name, bot: true, style });
|
||||
return { name };
|
||||
}
|
||||
|
||||
@@ -269,7 +277,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
|
||||
const seat = actingSeat(room);
|
||||
if (!seat || !room.bots.has(seat)) return null;
|
||||
const view = viewFor(room.state!, seat);
|
||||
const cmd = automatonCommand(view) ?? automatonFallback(view);
|
||||
const cmd = automatonCommand(view, room.bots.get(seat)) ?? automatonFallback(view);
|
||||
let r = runCommand(room, seat, cmd);
|
||||
if ("error" in r) {
|
||||
r = runCommand(room, seat, automatonFallback(view));
|
||||
@@ -480,13 +488,13 @@ export function loadPersistedRooms(): void {
|
||||
log: [],
|
||||
events: [],
|
||||
chat: [],
|
||||
bots: new Set(),
|
||||
bots: new Map(),
|
||||
};
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.kind === "join") {
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
room.bots.add(line.name);
|
||||
room.bots.set(line.name, (line.style as AutomatonStyle) ?? "hunter");
|
||||
} else {
|
||||
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
|
||||
if (!joinHash) throw new Error("join line has no token");
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface JoinLine {
|
||||
token?: string;
|
||||
/** An automaton seat: no token; the server plays it. */
|
||||
bot?: true;
|
||||
/** The automaton's temperament. */
|
||||
style?: string;
|
||||
}
|
||||
|
||||
export interface StartLine {
|
||||
|
||||
@@ -1028,7 +1028,7 @@
|
||||
{:else}
|
||||
<span class="dot" style:background="#b3a687"></span>
|
||||
{/if}
|
||||
{p}{p === net.hostId ? " — host" : ""}{net.roomBots.includes(p) ? " ⚙" : chosen === undefined ? " — choosing…" : ""}
|
||||
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -1050,7 +1050,12 @@
|
||||
</div>
|
||||
{#if net.you === net.hostId}
|
||||
{#if net.players.length < 6}
|
||||
<button class="stamp tiny" onclick={() => net.addBot()}>⚙ seat an automaton</button>
|
||||
<span class="bot-row">
|
||||
⚙ seat an automaton:
|
||||
<button class="stamp tiny" onclick={() => net.addBot("hunter")}>hunter</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("berserker")}>berserker</button>
|
||||
<button class="stamp tiny" onclick={() => net.addBot("worrier")}>worrier</button>
|
||||
</span>
|
||||
{/if}
|
||||
<label class="check">
|
||||
<input type="checkbox" bind:checked={withExpansion} />
|
||||
@@ -1995,6 +2000,13 @@
|
||||
.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; }
|
||||
.bot-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b5a41;
|
||||
}
|
||||
.discard-link {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -225,7 +225,7 @@ class Net {
|
||||
started = $state(false);
|
||||
/** Lobby standee choices, by player name. */
|
||||
roomColors = $state<Record<string, number>>({});
|
||||
roomBots = $state<string[]>([]);
|
||||
roomBots = $state<Record<string, string>>({});
|
||||
you = $state<string | null>(null);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
@@ -296,7 +296,7 @@ class Net {
|
||||
case "room":
|
||||
this.roomId = msg.roomId;
|
||||
this.roomColors = msg.colors ?? {};
|
||||
this.roomBots = msg.bots ?? [];
|
||||
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));
|
||||
@@ -428,8 +428,8 @@ class Net {
|
||||
}
|
||||
|
||||
/** Ask the server how all our games are doing. */
|
||||
addBot(): void {
|
||||
this.send({ type: "addBot" });
|
||||
addBot(style?: string): void {
|
||||
this.send({ type: "addBot", ...(style ? { style } : {}) });
|
||||
}
|
||||
|
||||
rollTableDie(): void {
|
||||
|
||||
Reference in New Issue
Block a user