Difficulty without stupidity: apprentice, adept, archmage

Tiers degrade resources and repertoire, never judgment — the design
constraint was that no tier may ever look dumb. The APPRENTICE draws
one card a turn instead of two (a poorer wizard, not a worse one),
spends counters only on heavy hits (thrift, not blindness), and
carries a modest spellbook: damage, summons, stones, keys, and
treasure play, with no afflictions, amplifies, ambushes, guarding
tricks, or deja-vu. The ADEPT draws fully and knows everything except
ambushes and amplify. The ARCHMAGE is the full curriculum. Every card
any tier plays, it plays correctly.

The lobby workshop gains a tier picker beside the temperaments
(default adept); the tier persists with the seat, shows in roster and
scoresheet ("⚙ apprentice mystery"), and rides the drive loop.
Measured where it should matter: in berserker combat mirrors the
archmage beats the apprentice two to one, while pure treasure races
stay honest — the handicap lives in the card exchanges a human
actually feels, pinned deterministically in the tournament suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 17:59:11 -04:00
co-authored by Claude Fable 5
parent a6ede6ca1e
commit b9c784a10c
7 changed files with 115 additions and 36 deletions
+50 -18
View File
@@ -14,6 +14,32 @@ import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier"; export type AutomatonStyle = "hunter" | "berserker" | "worrier";
export const AUTOMATON_STYLES: 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. */ /** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = { const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
fireball: { base: 5, perNumber: false }, fireball: { base: 5, perNumber: false },
@@ -221,7 +247,7 @@ function escapeCell(view: GameView, from: Cell): Cell | null {
} }
/** Counteraction judgment; the worrier flinches at less. */ /** Counteraction judgment; the worrier flinches at less. */
function respond(view: GameView, style: AutomatonStyle): Command { function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Command {
const stack = view.stack!; const stack = view.stack!;
const you = view.you; const you = view.you;
const find = (id: string) => view.yourHand.find((c) => c.cardId === id); const find = (id: string) => view.yourHand.find((c) => c.cardId === id);
@@ -239,7 +265,7 @@ function respond(view: GameView, style: AutomatonStyle): Command {
return { type: "pass" }; return { type: "pass" };
} }
const flinch = style === "worrier" ? 1 : 0; const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
const attackId = stack.attackCard?.cardId ?? null; const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null; const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false; const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
@@ -268,7 +294,7 @@ function respond(view: GameView, style: AutomatonStyle): Command {
if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId }; if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId };
} }
const absorb = find("absorb"); const absorb = find("absorb");
if (absorb && incoming >= 2 && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId }; if (absorb && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
const blunt = find("blunt"); const blunt = find("blunt");
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId }; if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
// The berserker shares its pain out of spite. // The berserker shares its pain out of spite.
@@ -292,13 +318,13 @@ function respond(view: GameView, style: AutomatonStyle): Command {
} }
/** The best attack available against a visible target, numbers and amplify included. */ /** The best attack available against a visible target, numbers and amplify included. */
function bestAttack(view: GameView, targetId: PlayerId): Command | null { function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Command | null {
const numbers = numbersInHand(view); const numbers = numbersInHand(view);
const biggest = numbers[numbers.length - 1]; const biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0; const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!; const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position); const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = inHand(view, "amplify"); const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
let best: { cmd: Command; damage: number } | null = null; let best: { cmd: Command; damage: number } | null = null;
for (const c of view.yourHand) { for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId]; const atk = ATTACKS[c.cardId];
@@ -364,7 +390,7 @@ function summonSpot(view: GameView, near: Cell): Cell | null {
} }
/** Self-buffs and housekeeping worth a neutral cast this turn. */ /** Self-buffs and housekeeping worth a neutral cast this turn. */
function selfCare(view: GameView, style: AutomatonStyle): Command | null { function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Command | null {
const self = me(view); const self = me(view);
const numbers = numbersInHand(view); const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)]; const mid = numbers[Math.floor(numbers.length / 2)];
@@ -383,6 +409,7 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
return { type: "cast", 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. // A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some( const cursed = view.sustained.some(
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null, (e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
@@ -429,9 +456,9 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
} }
} }
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down. // Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = view.treasures.find( const myFloorTreasure = tier.guardGold
(t) => t.owner === view.you && t.position && !t.carriedBy, ? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
); : undefined;
if (myFloorTreasure && enemyNear) { if (myFloorTreasure && enemyNear) {
const safe = inHand(view, "safe"); const safe = inHand(view, "safe");
if (safe) { if (safe) {
@@ -448,7 +475,7 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
} }
} }
// Empty of violence: DEJA-VU pulls the best attack back from the pile. // Empty of violence: DEJA-VU pulls the best attack back from the pile.
const dv = inHand(view, "deja-vu"); const dv = tier.dejaVu ? inHand(view, "deja-vu") : undefined;
if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) { if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) {
const buried = [...view.discardPile].reverse().find( const buried = [...view.discardPile].reverse().find(
(c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber, (c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber,
@@ -458,7 +485,7 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
} }
} }
// An ambush costs nothing to hold and everything to walk into. // An ambush costs nothing to hold and everything to walk into.
const via = inHand(view, "interrupt") ?? inHand(view, "opportunity-fire"); 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 && const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
c.cardId !== "blaster-wand"); c.cardId !== "blaster-wand");
if (via && spell && view.yourAmbushes.length === 0) { if (via && spell && view.yourAmbushes.length === 0) {
@@ -472,8 +499,13 @@ function selfCare(view: GameView, style: AutomatonStyle): Command | null {
* One decision from the automaton's seat, or null when the maze is not * One decision from the automaton's seat, or null when the maze is not
* asking it anything. * asking it anything.
*/ */
export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter"): Command | null { export function automatonCommand(
view: GameView,
style: AutomatonStyle = "hunter",
tierName: AutomatonTier = "archmage",
): Command | null {
const you = view.you as PlayerId; const you = view.you as PlayerId;
const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null; if (view.phase !== "playing") return null;
if (view.pendingDiscard === you) { if (view.pendingDiscard === you) {
@@ -486,7 +518,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
: { type: "pass" }; : { type: "pass" };
} }
if (view.stack) { if (view.stack) {
return view.stack.waitingOn === you ? respond(view, style) : null; return view.stack.waitingOn === you ? respond(view, style, tier) : null;
} }
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" }; if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
if (view.activePlayerId !== you) return null; if (view.activePlayerId !== you) return null;
@@ -494,7 +526,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
const self = me(view); const self = me(view);
const here = cellKey(self.position); const here = cellKey(self.position);
if (view.turn.actionsEnded) return { type: "endTurn", draw: 2 }; if (view.turn.actionsEnded) return { type: "endTurn", draw: tier.draw };
// Deliver or grab treasure underfoot. // Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
@@ -517,7 +549,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
} }
} }
const care = selfCare(view, style); const care = selfCare(view, style, tier);
if (care) return care; if (care) return care;
// Command the menagerie: creatures march and maul before the wizard moves. // Command the menagerie: creatures march and maul before the wizard moves.
@@ -555,9 +587,9 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
}; };
} }
} }
const spell = bestAttack(view, target.id); const spell = bestAttack(view, target.id, tier);
if (spell) return spell; if (spell) return spell;
const misery = bestAffliction(view, target.id, thief?.id === target.id); const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
if (misery) return misery; if (misery) return misery;
if (cellKey(target.position) === here && style !== "worrier") { if (cellKey(target.position) === here && style !== "worrier") {
return { type: "punch", targetId: target.id }; return { type: "punch", targetId: target.id };
@@ -617,7 +649,7 @@ export function automatonCommand(view: GameView, style: AutomatonStyle = "hunter
} }
} }
return { type: "endTurn", draw: 2 }; return { type: "endTurn", draw: tier.draw };
} }
/** The safe fallback when the automaton's choice was refused. */ /** The safe fallback when the automaton's choice was refused. */
+19 -3
View File
@@ -6,7 +6,7 @@ import {
type PlayerId, type PlayerId,
} from "../src/game"; } from "../src/game";
import { viewFor } from "../src/view"; import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle } from "../src/automaton"; import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
/** Whose input does the maze want right now? */ /** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId { function actingSeat(state: GameState): PlayerId {
@@ -20,9 +20,13 @@ function actingSeat(state: GameState): PlayerId {
} }
/** Drive a full bot-vs-bot game; returns the final state and command count. */ /** Drive a full bot-vs-bot game; returns the final state and command count. */
function playOut(seed: number, players: number, expansion = true, styles: AutomatonStyle[] = []) { function playOut(
seed: number, players: number, expansion = true,
styles: AutomatonStyle[] = [], tiers: AutomatonTier[] = [],
) {
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`); const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"])); 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({ let { state } = createGame({
playerIds: ids, playerIds: ids,
seed, seed,
@@ -35,7 +39,7 @@ function playOut(seed: number, players: number, expansion = true, styles: Automa
while (state.phase === "playing" && commands < CAP) { while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state); const seat = actingSeat(state);
const view = viewFor(state, seat); const view = viewFor(state, seat);
const cmd = automatonCommand(view, styleOf.get(seat)) ?? automatonFallback(view); const cmd = automatonCommand(view, styleOf.get(seat), tierOf.get(seat)) ?? automatonFallback(view);
let r = applyCommand(state, seat, cmd); let r = applyCommand(state, seat, cmd);
if (!r.ok) { if (!r.ok) {
const fb = automatonFallback(view); const fb = automatonFallback(view);
@@ -85,6 +89,18 @@ describe("automaton vs automaton", () => {
expect(state.phase).toBe("finished"); 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", () => { it("every temperament finishes its wars", () => {
for (const styles of [ for (const styles of [
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"], ["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],
+6 -2
View File
@@ -173,7 +173,7 @@ function roomInfo(room: Room) {
started: room.state !== null, started: room.state !== null,
colors: Object.fromEntries(room.colorChoices), colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries( bots: Object.fromEntries(
[...room.bots].map(([name, b]) => [name, b.secret ? "mystery" : b.style]), [...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
), ),
}; };
} }
@@ -331,7 +331,11 @@ wss.on("connection", (socket) => {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); 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" }); 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); 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 }); if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcastRoomState(room); broadcastRoomState(room);
break; break;
+17 -6
View File
@@ -21,7 +21,9 @@ import {
automatonCommand, automatonCommand,
automatonFallback, automatonFallback,
AUTOMATON_STYLES, AUTOMATON_STYLES,
AUTOMATON_TIERS,
type AutomatonStyle, type AutomatonStyle,
type AutomatonTier,
} from "@wizwar/engine"; } from "@wizwar/engine";
export interface LoggedCommand { export interface LoggedCommand {
@@ -47,8 +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, and whether it is told. */ /** Seats the server itself plays: temperament, tier, and secrecy. */
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean }>; bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
} }
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
@@ -242,7 +244,11 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"]; const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
/** Seat a clockwork wizard (host's choice, before the game starts). */ /** Seat a clockwork wizard (host's choice, before the game starts). */
export function addAutomaton(room: Room, styleWanted?: string): { name: PlayerId } | { error: string } { export function addAutomaton(
room: Room,
styleWanted?: string,
tierWanted?: string,
): { name: PlayerId } | { error: string } {
if (room.state) return { error: "the game has started" }; if (room.state) return { error: "the game has started" };
if (room.players.length >= 6) return { error: "room is full" }; if (room.players.length >= 6) return { error: "room is full" };
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n)); const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
@@ -252,9 +258,12 @@ export function addAutomaton(room: Room, styleWanted?: string): { name: PlayerId
? (styleWanted as AutomatonStyle) ? (styleWanted as AutomatonStyle)
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!; : AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
const secret = !known; // the mystery machine keeps its mood to itself 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.players.push(name);
room.bots.set(name, { style, secret }); room.bots.set(name, { style, secret, tier });
appendLine(room.id, { kind: "join", name, bot: true, style, ...(secret ? { secret: true } : {}) }); appendLine(room.id, { kind: "join", name, bot: true, style, tier, ...(secret ? { secret: true } : {}) });
return { name }; return { name };
} }
@@ -279,7 +288,8 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
const seat = actingSeat(room); const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null; if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat); const view = viewFor(room.state!, seat);
const cmd = automatonCommand(view, room.bots.get(seat)?.style) ?? automatonFallback(view); const bot = room.bots.get(seat);
const cmd = automatonCommand(view, bot?.style, bot?.tier) ?? automatonFallback(view);
let r = runCommand(room, seat, cmd); let r = runCommand(room, seat, cmd);
if ("error" in r) { if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view)); r = runCommand(room, seat, automatonFallback(view));
@@ -499,6 +509,7 @@ export function loadPersistedRooms(): void {
room.bots.set(line.name, { room.bots.set(line.name, {
style: (line.style as AutomatonStyle) ?? "hunter", style: (line.style as AutomatonStyle) ?? "hunter",
secret: line.secret === true, secret: line.secret === true,
tier: (line.tier as AutomatonTier) ?? "archmage",
}); });
} else { } else {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
+2
View File
@@ -28,6 +28,8 @@ export interface JoinLine {
bot?: true; bot?: true;
/** The automaton's temperament. */ /** The automaton's temperament. */
style?: string; style?: string;
/** The automaton's difficulty tier. */
tier?: string;
/** A mystery machine: the temperament is not revealed to the table. */ /** A mystery machine: the temperament is not revealed to the table. */
secret?: true; secret?: true;
} }
+19 -5
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. */
@@ -1051,11 +1052,16 @@
{#if net.you === net.hostId} {#if net.you === net.hostId}
{#if net.players.length < 6} {#if net.players.length < 6}
<span class="bot-row"> <span class="bot-row">
⚙ seat an automaton: ⚙ seat a
<button class="stamp tiny" onclick={() => net.addBot("hunter")}>hunter</button> <select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
<button class="stamp tiny" onclick={() => net.addBot("berserker")}>berserker</button> <option value="apprentice">apprentice</option>
<button class="stamp tiny" onclick={() => net.addBot("worrier")}>worrier</button> <option value="adept">adept</option>
<button class="stamp tiny" onclick={() => net.addBot()}>mystery</button> <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> </span>
{/if} {/if}
<label class="check"> <label class="check">
@@ -2012,6 +2018,14 @@
.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 { .bot-row {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+2 -2
View File
@@ -428,8 +428,8 @@ class Net {
} }
/** Ask the server how all our games are doing. */ /** Ask the server how all our games are doing. */
addBot(style?: string): void { addBot(style?: string, tier?: string): void {
this.send({ type: "addBot", ...(style ? { style } : {}) }); this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
} }
rollTableDie(): void { rollTableDie(): void {