@homie-rocks/studio 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,8 +110,8 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
110
110
  A studio pins one version, so nothing changes until it asks. To take a newer one:
111
111
 
112
112
  ```sh
113
- npx -y @homie-rocks/studio@0.16.1 upgrade # the plan; changes nothing
114
- npx -y @homie-rocks/studio@0.16.1 upgrade --apply # after the person agrees
113
+ npx -y @homie-rocks/studio@0.17.0 upgrade # the plan; changes nothing
114
+ npx -y @homie-rocks/studio@0.17.0 upgrade --apply # after the person agrees
115
115
  npm install && npm run build
116
116
  ```
117
117
 
@@ -265,13 +265,20 @@ npx homie-studio agents pass night-rush --label Claude
265
265
  - **Asks:** making a server, a widening change, a pass and a room's level happen at once; narrowing one
266
266
  (humans-only, a stricter door), closing it, removing a member, and the first time AI guides may talk are asked
267
267
  for, and the owner confirms with one tap.
268
+ - **AI guides that talk** (0.17.0): a game's `agents.json` is its guides' vocabulary (goals, lines, the asks a
269
+ player taps), and `useAgents` from `@homie-rocks/studio/agents` its host side (Ember Vale is the reference). The
270
+ brain is the server's: `agents brain night-rush first-light workers-ai` (the studio's own Workers AI; deploy binds
271
+ it; 8,000 neurons a day by default) or `owner-key` (the owner's own key, `agents brain key`, capped in dollars a
272
+ day); with none, the game's scripted floor. The local MCP's `agent_sit` puts the owner's own Claude in a guide's
273
+ seat. The AI never types: it picks ids; the room drops anything else.
274
+ [agents/GUIDES.md](agents/GUIDES.md) is the short path for an existing RPG.
268
275
  - Everything is in the studio's own Worker and D1 (migration `0006_studio_servers.sql`). The site's side is in
269
- [site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) section 17.
276
+ [site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) sections 17 and 18.
270
277
 
271
278
  ## Setup status
272
279
 
273
280
  ```sh
274
- npx -y @homie-rocks/studio@0.16.1 setup status --connector yes # before a studio exists
281
+ npx -y @homie-rocks/studio@0.17.0 setup status --connector yes # before a studio exists
275
282
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
276
283
  ```
277
284
 
@@ -0,0 +1,99 @@
1
+ # AI guides in an RPG of your own
2
+
3
+ How an existing studio game with zones, quests and a party (an RPG, a dungeon crawler, an adventure) adopts AI
4
+ guides that answer "Help me with this quest". It takes an afternoon. Do it in the game's own repository, with its
5
+ owner's say; nothing here changes another studio's game. The contract is `netplay/NETPLAY.md` section 18; Ember
6
+ Vale (`homie-studio game new <id> --from ember-vale`) is the worked example.
7
+
8
+ ## 0. What you get
9
+
10
+ - A beginner server whose AI guide seats (always marked "· AI") follow new heroes, lead them to places, take on a
11
+ quest with them, and pull back when asked.
12
+ - **Hands** are your game's own bot code, every frame, at the party's skill dial. **The brain** picks a goal every
13
+ few seconds: the studio's own Workers AI, the owner's own key, or the owner's Claude in a seat. With none of them
14
+ (or over the day's budget) your scripted `decide` plays.
15
+ - The AI never types. It chooses ids from your `agents.json`; your game draws your own words.
16
+
17
+ ## 1. Upgrade
18
+
19
+ `npx --no-install homie-studio upgrade --apply`, to `@homie-rocks/studio` 0.17.0 or later, then build. A game that
20
+ vendors `netplay.ts` updates its copy (revision 7). `homie-studio servers` shows each build's revision.
21
+
22
+ ## 2. Write `games/<id>/agents.json` in the game's voice
23
+
24
+ Start from the goals your bot code can really do. For an RPG with zones and quests:
25
+
26
+ ```json
27
+ { "v": 1,
28
+ "persona": "A veteran of the north roads who helps new adventurers. Brief, warm, a little dry; never mocking.",
29
+ "names": ["Tamsin", "Oren", "Bryn"],
30
+ "labels": { "old-mill": "the Old Mill", "wolf-den": "the wolf den", "town": "town" },
31
+ "goals": {
32
+ "follow": { "about": "stay with a player", "args": { "seat": "player" } },
33
+ "quest": { "about": "do a quest with the party", "args": { "quest": "view.quests" } },
34
+ "lead": { "about": "lead the party to a zone", "args": { "zone": "view.zones" } },
35
+ "heal": { "about": "stay behind the party and heal whoever is hurt" },
36
+ "back": { "about": "pull back to the nearest safe zone" } },
37
+ "lines": {
38
+ "hello": { "text": "Well met, {player}. I'm {me}, an AI guide. Ask if you need a hand.", "args": { "player": "player" } },
39
+ "quest_help": { "text": "{quest}? I know the way. Stay close.", "args": { "quest": "view.quests" } },
40
+ "this_way": { "text": "This way, to {zone}.", "args": { "zone": "view.zones" } },
41
+ "careful": { "text": "Careful: {thing} ahead.", "args": { "thing": ["wolves", "a trap", "the boss"] } },
42
+ "bye": { "text": "Understood. I'll keep my distance." } },
43
+ "asks": {
44
+ "ask_help": { "text": "Help me with {quest}", "args": { "quest": "view.quests" }, "goal": "quest", "say": "quest_help" },
45
+ "take_me": { "text": "Take me to {zone}", "args": { "zone": "view.zones" }, "goal": "lead", "say": "this_way" },
46
+ "no_thanks": { "text": "No thanks", "goal": "back", "say": "bye", "leave": true } } }
47
+ ```
48
+
49
+ Rules the build enforces: ids are `a-z0-9_`; a text is at most 120 characters and its `{placeholders}` are its
50
+ arguments (or `{me}`); an argument is `"player"`, a list, or `"view.<key>"`; a goal is done *with* players, never
51
+ to one. Keep `labels` for every id a player will read. Quest and zone ids that come from the view (`view.quests`)
52
+ must be ids, not sentences.
53
+
54
+ ## 3. Wire `useAgents` (host side, a few dozen lines)
55
+
56
+ ```ts
57
+ import vocab from '../agents.json';
58
+ import { useAgents, type Vocabulary } from '@homie-rocks/studio/agents';
59
+
60
+ const agents = useAgents(room.net, vocab as unknown as Vocabulary, {
61
+ view: (slot) => ({ // game state only, seats not names, under 2 KB
62
+ me: { hp: hpPct(slot), zone: zoneOf(slot) }, zone: zoneOf(slot), danger: dangerNear(slot),
63
+ party: partyNear(slot, 900).map((p) => ({ seat: p.seat, dist: p.dist, hp: p.hpPct })),
64
+ quests: activeQuestIds(), zones: knownZoneIds(),
65
+ }),
66
+ decide: (v) => { // the floor: synchronous, never waits
67
+ const a = v.asks?.[0];
68
+ if (a?.k === 'ask_help') return { goal: 'quest', args: { quest: a.args.quest }, say: 'quest_help', sayArgs: { quest: a.args.quest } };
69
+ if (a?.k === 'take_me') return { goal: 'lead', args: { zone: a.args.zone }, say: 'this_way', sayArgs: { zone: a.args.zone } };
70
+ if (a?.k === 'no_thanks') return { goal: 'back', say: 'bye' };
71
+ return v.party?.[0] ? { goal: 'follow', args: { seat: v.party[0].seat } } : { goal: 'back' };
72
+ },
73
+ });
74
+ ```
75
+
76
+ - **Hands.** In the host's bot step, a guide slot (`b.agent?.role === 'guide'`) does `agents.goalOf(b.slot)` with
77
+ your own movement and combat, at `room.skillOf(b)`; call `agents.done(b.slot, ok)` when the quest or the walk is
78
+ over. With no goal, your usual bot code runs.
79
+ - **Asks.** Draw `agents.askButtons(slot, { quests, zones })` as buttons near a guide (mouse and keyboard: a small
80
+ panel; a number key per button works well); a click is `agents.ask(slot, k, args)`. Never a text box.
81
+ - **Lines.** `agents.on('say', ({ slot, text }) => speechBubble(slot, text))`, styled like your game's dialogue.
82
+ Quiet AI is already handled.
83
+ - **Zones and quests** your RPG keeps server-side or in saves stay where they are; the view only names their ids.
84
+
85
+ ## 4. Turn it on (the owner)
86
+
87
+ 1. `homie-studio servers new <id> "First Steps" --policy beginner --guides 2` (add `--kids` for children).
88
+ 2. `homie-studio agents brain <id> first-steps workers-ai`: the first time is an ASK the owner taps; then
89
+ `npm run deploy` once (it binds Workers AI). The free allocation (10,000 neurons a day) gives about six busy
90
+ guide-hours; the default budget is 8,000. Or `owner-key` with `agents brain key` on the owner's own computer.
91
+ 3. Try it: two browsers on the server's page, walk up to a guide, tap "Help me with ...". In a terminal or the
92
+ local MCP, `agent_sit { game: "<id>", server: "first-steps" }` puts your own Claude in a guide's seat.
93
+
94
+ ## 5. Do not
95
+
96
+ - Put a player's name, account, chat text or anything personal in a view. Seats only.
97
+ - Give a guide a goal against players (attack, steal, block), or lines that tease, flirt or ask about a person.
98
+ - Let a guide hold the quest's win: it helps, the hero finishes.
99
+ - Store guide state in the room for days: a room forgets 60 s after the last person; lasting things are saves.
@@ -0,0 +1,395 @@
1
+ /*
2
+ * @homie-rocks/studio/agents — AI guides in a game (NETPLAY.md section 18, contract revision 7).
3
+ * =============================================================================
4
+ *
5
+ * A beginner server keeps some seats in every room for AI guides (section 17). Their HANDS are the game's own bot
6
+ * code, every frame, at the dial the party agreed. Their BRAIN picks a goal every few seconds and, when the server's
7
+ * AI may talk, a line to say: only goals and lines from the game's own `agents.json` (its vocabulary), never free
8
+ * text. The brain runs in the room's Table on the studio's Worker (Workers AI, or the owner's key), in the owner's
9
+ * Claude through the local MCP (`agent_sit`), or (no AI at all, over budget, between decisions) in the host as the
10
+ * game's own scripted `decide`. This file is the host's side of it, and every browser's side of the asks and lines:
11
+ *
12
+ * const agents = useAgents(room.net, vocab, {
13
+ * view: (slot) => ({ ... }), // host: what a guide sees, game state only, under 2 KB, at most every 2 s
14
+ * decide: (view) => ({ ... }), // the scripted floor: synchronous, never waits
15
+ * });
16
+ * agents.goalOf(slot) // hands, every host frame: the goal in force (null: the game's own bot code)
17
+ * agents.done(slot, ok) // host: that goal finished (or failed): the brain thinks again
18
+ * agents.ask(slot, 'ask_help', { quest: 'king-slime' }) // a person's button: "Help me with King Slime"
19
+ * agents.on('say', ({ slot, text }) => bubble(slot, text)) // draw a guide's line; the text is the game's own
20
+ *
21
+ * Fixed rules, outside any model: a guide says at most one line every 8 s; after "no thanks" (an ask with `leave`)
22
+ * it leaves that player alone for 10 minutes; a goal aimed at a player is never in a vocabulary; Quiet AI
23
+ * (`net.hushed`) hides every AI line on this browser; the relay checks every frame again (an AI's line must be a
24
+ * vocabulary line with arguments that fit, on a server whose AI may talk).
25
+ * =============================================================================
26
+ */
27
+ import { aiName, stripAi, type Netplay, type NetEvent, type Policy, type Slot } from '../netplay/netplay';
28
+
29
+ /* ------------------------------------------------------------------ the vocabulary (agents.json) */
30
+
31
+ /** An argument's type: a seat in the room, a list of values, or a value the guide's latest view offers. */
32
+ export type ArgType = 'player' | readonly string[] | `view.${string}`;
33
+ export interface GoalDef { about: string; args?: Record<string, ArgType> }
34
+ export interface LineDef { text: string; args?: Record<string, ArgType> }
35
+ /** A person's ask: a button the game draws. `goal`/`say`: how the scripted floor answers it; `leave`: "no thanks". */
36
+ export interface AskDef { text: string; args?: Record<string, ArgType>; goal?: string; say?: string; leave?: boolean }
37
+ export interface Vocabulary {
38
+ v: 1;
39
+ persona?: string;
40
+ /** The guides' names (" · AI" is added). */
41
+ names?: string[];
42
+ /** How a value reads aloud ("king-slime": "King Slime"); else its dashes become spaces. */
43
+ labels?: Record<string, string>;
44
+ goals: Record<string, GoalDef>;
45
+ lines?: Record<string, LineDef>;
46
+ asks?: Record<string, AskDef>;
47
+ }
48
+ /** What a brain (or the floor) chose: a goal of the vocabulary, and at most one of its lines. */
49
+ export interface Decision { goal?: string | null; args?: Record<string, unknown>; say?: string | null; sayArgs?: Record<string, unknown> }
50
+ export type GoalState = 'active' | 'done' | 'failed';
51
+ /** `asked`: this goal answered a person's ask; it is carried through until done (or 60 s), whatever a brain says. */
52
+ export interface Goal { goal: string; args: Record<string, unknown>; from: 'brain' | 'floor'; at: number; state: GoalState; asked?: boolean }
53
+ export interface Ask { k: string; args: Record<string, unknown>; from: number; at: number }
54
+ export interface SayEvent { slot: number; seat: number | null; line: string; args: Record<string, unknown>; text: string }
55
+ export interface GoalEvent { slot: number; goal: Goal; prev: Goal | null; askAt: number | null }
56
+ export interface AskButton { k: string; args: Record<string, unknown>; text: string }
57
+
58
+ export interface AgentsOptions {
59
+ /** Host: what a guide in this slot sees. Game state only: never an account, an address or typed text. Under 2 KB. */
60
+ view: (slot: number) => Record<string, unknown>;
61
+ /** The scripted floor: runs with no AI, over budget, and between AI decisions. Synchronous; never waits. */
62
+ decide?: (view: Record<string, unknown>, ctx: { slot: number; goal: Goal | null; asks: Ask[] }) => Decision | null;
63
+ /** Which AI slots this game gives a brain (default: guides). */
64
+ roles?: ('guide' | 'party')[];
65
+ /** How long an AI's decision holds before the floor may decide again (default 45 s: an AI in a seat on the owner's computer decides about every 30 s). */
66
+ holdMs?: number;
67
+ /** How long an ask waits for an AI's answer before the floor answers it (default 4 s). */
68
+ askWaitMs?: number;
69
+ }
70
+
71
+ export interface AgentsStats { views: number; dos: number; says: number; asks: number; drops: number; floors: number; viewBytes: number }
72
+
73
+ export interface Agents {
74
+ readonly vocab: Vocabulary;
75
+ /** Whether this server's AI may talk (its brain is workers-ai or owner-key, and speech is not off). */
76
+ readonly talking: boolean;
77
+ /** The AI slots that get a brain (a seat kept for a guide, or a guide's). */
78
+ guides(): Slot[];
79
+ /** The goal in force for a slot, or null (the game's own bot code drives). */
80
+ goalOf(slot: number): Goal | null;
81
+ /** Host: the asks made of this guide in the last 30 s, newest first. */
82
+ asksFor(slot: number): Ask[];
83
+ /** A person asks a guide (a button): false when not sent. */
84
+ ask(slot: number, k: string, args?: Record<string, unknown>): boolean;
85
+ /** The buttons to draw for a guide: each ask, one per value its argument may take (`offer`: what this browser knows). */
86
+ askButtons(slot: number, offer?: Record<string, unknown>): AskButton[];
87
+ /** Host: the goal in force finished (or failed). The brain thinks again; the floor decides at once. */
88
+ done(slot: number, ok?: boolean): void;
89
+ /** A line's (or an ask's) text, from the vocabulary, or null. */
90
+ render(id: string, args: Record<string, unknown>, slot: number | null, kind?: 'lines' | 'asks'): string | null;
91
+ on(kind: 'say', fn: (e: SayEvent) => void): () => void;
92
+ on(kind: 'goal', fn: (e: GoalEvent) => void): () => void;
93
+ on(kind: 'ask', fn: (e: Ask & { slot: number }) => void): () => void;
94
+ stats(): AgentsStats;
95
+ stop(): void;
96
+ }
97
+
98
+ const TALK = ['workers-ai', 'owner-key'];
99
+ export const AGENT_RULES = Object.freeze({ viewMs: 2050, floorMs: 1000, quietMs: 8000, leaveMs: 10 * 60_000, askMs: 30_000, holdMs: 45_000, askWaitMs: 4000, carryMs: 60_000, viewBytes: 1900 });
100
+ const VIEW_ARG = /^view\.([A-Za-z][A-Za-z0-9_]{0,23})$/;
101
+ const SLOT_IN_TEXT = /\{([a-z][a-z0-9_]{0,15})\}/g;
102
+
103
+ /** Whether a server's AI may talk (the owner's consent, agents_brain). */
104
+ export const talksOn = (p: Policy | null | undefined): boolean => Boolean(p && TALK.includes(p.brain) && p.speech !== 'off' && p.kind !== 'humans-only');
105
+
106
+ /** Whether one value fits its type (the same rule as the relay's, worker/brain.mjs `argOk`). */
107
+ export function argOk(type: ArgType | undefined, value: unknown, view: Record<string, unknown> | null, players: number[] | null): boolean {
108
+ if (type === 'player') return Number.isInteger(value) && (value as number) >= 0 && (value as number) < 64 && (!players || players.includes(value as number));
109
+ if (Array.isArray(type)) return typeof value === 'string' && type.includes(value);
110
+ const m = typeof type === 'string' ? VIEW_ARG.exec(type) : null;
111
+ if (!m || !view || (typeof value !== 'string' && typeof value !== 'number')) return false;
112
+ const offered = view[m[1] as string];
113
+ if (Array.isArray(offered)) return offered.some((o) => o === value || (o && typeof o === 'object' && (o as { id?: unknown }).id === value));
114
+ return offered !== null && offered !== undefined && typeof offered !== 'object' && offered === value;
115
+ }
116
+
117
+ /** Why arguments do not fit (missing, extra, a wrong value), or null when they do. */
118
+ export function argsWhy(types: Record<string, ArgType> | undefined, args: unknown, view: Record<string, unknown> | null, players: number[] | null): string | null {
119
+ const want = Object.keys(types ?? {});
120
+ const a = (args === undefined || args === null ? {} : args) as Record<string, unknown>;
121
+ if (typeof a !== 'object' || Array.isArray(a)) return 'args is an object';
122
+ for (const k of Object.keys(a)) if (!want.includes(k)) return `no argument ${k}`;
123
+ for (const k of want) {
124
+ if (!(k in a)) return `missing ${k}`;
125
+ if (!argOk((types as Record<string, ArgType>)[k], a[k], view, players)) return `${k} does not fit`;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ export const labelOf = (vocab: Vocabulary, value: unknown): string => vocab.labels?.[String(value)] ?? String(value ?? '').replace(/[-_]+/g, ' ');
131
+
132
+ /* ------------------------------------------------------------------ useAgents */
133
+
134
+ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts: AgentsOptions): Agents {
135
+ const roles = opts.roles ?? ['guide'];
136
+ const holdMs = opts.holdMs ?? AGENT_RULES.holdMs;
137
+ const askWaitMs = opts.askWaitMs ?? AGENT_RULES.askWaitMs;
138
+ const goals = new Map<number, Goal>();
139
+ const asks = new Map<number, Ask[]>();
140
+ const avoid = new Map<number, Map<number, number>>();
141
+ const sayAt = new Map<number, number>();
142
+ const viewAt = new Map<number, number>();
143
+ const lastView = new Map<number, Record<string, unknown>>();
144
+ const floorAt = new Map<number, number>();
145
+ const askAt = new Map<number, number>();
146
+ const handlers = { say: new Set<(e: SayEvent) => void>(), goal: new Set<(e: GoalEvent) => void>(), ask: new Set<(e: Ask & { slot: number }) => void>() };
147
+ const st: AgentsStats = { views: 0, dos: 0, says: 0, asks: 0, drops: 0, floors: 0, viewBytes: 0 };
148
+ const wall = (): number => Date.now();
149
+
150
+ const guides = (): Slot[] => (net.slots ?? []).filter((s) => s.agent && roles.includes(s.agent.role as 'guide' | 'party'));
151
+ const slotOf = (slot: number): Slot | undefined => guides().find((s) => s.slot === slot);
152
+ const slotOfSeat = (seat: number | null): Slot | undefined => (seat === null ? undefined : guides().find((s) => s.agent?.seat === seat));
153
+ const people = (): number[] => [...net.peers.values()].filter((p) => p.seat !== null && !p.agent && !p.watch).map((p) => p.seat as number);
154
+ const nameOf = (seat: unknown): string => {
155
+ for (const p of net.peers.values()) if (p.seat === seat) return p.name;
156
+ return `Player ${Number(seat) + 1}`;
157
+ };
158
+ const emit = (k: keyof typeof handlers, e: unknown): void => {
159
+ for (const fn of handlers[k] as Set<(x: unknown) => void>) { try { fn(e); } catch { /* the game's own */ } }
160
+ };
161
+ const avoided = (slot: number): number[] => {
162
+ const m = avoid.get(slot);
163
+ if (!m) return [];
164
+ const now = wall();
165
+ for (const [s, until] of m) if (until <= now) m.delete(s);
166
+ return [...m.keys()];
167
+ };
168
+ const touchesAvoided = (types: Record<string, ArgType> | undefined, a: Record<string, unknown>, slot: number): boolean => {
169
+ const out = avoided(slot);
170
+ return Object.entries(types ?? {}).some(([k, t]) => t === 'player' && out.includes(a[k] as number));
171
+ };
172
+
173
+ function render(id: string, args: Record<string, unknown>, slot: number | null, kind: 'lines' | 'asks' = 'lines'): string | null {
174
+ const def = kind === 'asks' ? vocab.asks?.[id] : vocab.lines?.[id];
175
+ if (!def) return null;
176
+ const s = slot === null ? undefined : (net.slots ?? []).find((x) => x.slot === slot);
177
+ const me = s ? stripAi(s.name) : 'Guide';
178
+ return def.text.replace(SLOT_IN_TEXT, (_m, k: string) => (k === 'me' ? me : def.args?.[k] === 'player' ? nameOf(args[k]) : labelOf(vocab, args[k])));
179
+ }
180
+
181
+ /** A line the host lets a guide say: from the vocabulary, its arguments fit, not within 8 s of its last. */
182
+ function say(slot: number, line: string, args: Record<string, unknown>): boolean {
183
+ const def = vocab.lines?.[line];
184
+ const now = wall();
185
+ if (!def || !talksOn(net.policy) || now - (sayAt.get(slot) ?? -Infinity) < AGENT_RULES.quietMs) { st.drops += 1; return false; }
186
+ if (argsWhy(def.args, args, lastView.get(slot) ?? null, people()) || touchesAvoided(def.args, args, slot)) { st.drops += 1; return false; }
187
+ sayAt.set(slot, now);
188
+ const s = slotOf(slot);
189
+ const seat = s?.agent?.seat ?? null;
190
+ // Everyone renders it from their own copy of the vocabulary; `ai` lets Quiet AI hide it on any browser.
191
+ net.send(`say:${line}`, { slot, seat, args, ai: true });
192
+ st.says += 1;
193
+ const text = render(line, args, slot);
194
+ if (text && !net.hushed) emit('say', { slot, seat, line, args, text });
195
+ return true;
196
+ }
197
+
198
+ function setGoal(slot: number, d: Decision, from: 'brain' | 'floor', answering = true): boolean {
199
+ if (!d.goal) return false;
200
+ const def = vocab.goals[d.goal];
201
+ const args = (d.args ?? {}) as Record<string, unknown>;
202
+ if (!def || argsWhy(def.args, args, lastView.get(slot) ?? opts.view(slot), people()) || touchesAvoided(def.args, args, slot)) { st.drops += 1; return false; }
203
+ const prev = goals.get(slot) ?? null;
204
+ const same = prev && prev.goal === d.goal && JSON.stringify(prev.args) === JSON.stringify(args) && prev.state === 'active';
205
+ const now = wall();
206
+ if (same) { if (from === 'brain') { prev.from = 'brain'; prev.at = now; } return false; }
207
+ const g: Goal = { goal: d.goal, args: { ...args }, from, at: now, state: 'active' };
208
+ goals.set(slot, g);
209
+ const pendingAsk = answering ? askAt.get(slot) ?? null : null;
210
+ emit('goal', { slot, goal: g, prev, askAt: pendingAsk });
211
+ return true;
212
+ }
213
+
214
+ function apply(slot: number, d: Decision | null, from: 'brain' | 'floor', speak: boolean): void {
215
+ if (!d) return;
216
+ // A person's ask is answered the way agents.json says (its `goal`): a brain's decision that does something else
217
+ // leaves the ask open, and the floor answers it once the brain has had askWaitMs (a fixed rule, not the model's).
218
+ const open = askAt.has(slot) ? (asks.get(slot) ?? []).at(-1) ?? null : null;
219
+ const want = open ? vocab.asks?.[open.k]?.goal : undefined;
220
+ const answering = !open || from === 'floor' || !want || want === d.goal;
221
+ // What a person asked for is carried through until it is done (or 60 s): a brain's other goal waits.
222
+ const cur = goals.get(slot);
223
+ if (!open && from === 'brain' && cur && cur.asked && cur.state === 'active' && wall() - cur.at < AGENT_RULES.carryMs && d.goal !== cur.goal) { st.drops += 1; if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>); return; }
224
+ setGoal(slot, d, from, answering);
225
+ if (open && answering) { askAt.delete(slot); asks.set(slot, []); const g = goals.get(slot); if (g && d.goal === g.goal) g.asked = true; }
226
+ if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>);
227
+ }
228
+
229
+ function viewOf(slot: number): Record<string, unknown> {
230
+ let v: Record<string, unknown> = {};
231
+ try { v = opts.view(slot) ?? {}; } catch { v = {}; }
232
+ const g = goals.get(slot);
233
+ return { ...v, ...(g ? { goal: { goal: g.goal, args: g.args, state: g.state } } : {}), ...(v['asks'] ? {} : { asks: asksFor(slot).slice(0, 3).map((a) => ({ k: a.k, args: a.args, from: a.from, at: a.at })) }) };
234
+ }
235
+
236
+ function asksFor(slot: number): Ask[] {
237
+ const now = wall();
238
+ const list = (asks.get(slot) ?? []).filter((a) => now - a.at < AGENT_RULES.askMs);
239
+ asks.set(slot, list);
240
+ return [...list].reverse();
241
+ }
242
+
243
+ /** The floor for one slot: the game's own decide, on its view. */
244
+ function floor(slot: number, held: boolean): void {
245
+ if (!opts.decide) return;
246
+ const v = viewOf(slot);
247
+ lastView.set(slot, v);
248
+ let d: Decision | null = null;
249
+ try { d = opts.decide(v, { slot, goal: goals.get(slot) ?? null, asks: asksFor(slot) }); } catch { d = null; }
250
+ floorAt.set(slot, wall());
251
+ if (!d) return;
252
+ st.floors += 1;
253
+ // The floor speaks only for a guide no AI holds (a held guide's own brain speaks for it).
254
+ apply(slot, d, 'floor', !held);
255
+ }
256
+
257
+ /** A person's ask reached the host (theirs, or its own player's). */
258
+ function onAsk(slot: number, k: string, args: Record<string, unknown>, from: number): void {
259
+ const def = vocab.asks?.[k];
260
+ const s = slotOf(slot);
261
+ if (!def || !s || argsWhy(def.args, args, lastView.get(slot) ?? viewOf(slot), null)) { st.drops += 1; return; }
262
+ const now = wall();
263
+ if (def.leave) { const m = avoid.get(slot) ?? new Map<number, number>(); m.set(from, now + AGENT_RULES.leaveMs); avoid.set(slot, m); }
264
+ asks.set(slot, [...(asks.get(slot) ?? []), { k, args, from, at: now }].slice(-4));
265
+ askAt.set(slot, now);
266
+ st.asks += 1;
267
+ emit('ask', { slot, k, args, from, at: now });
268
+ const held = s.agent?.seat !== null && s.agent?.seat !== undefined;
269
+ // No AI holds this guide: the floor answers at once. Held: its brain has askWaitMs to answer first.
270
+ if (!held) floor(slot, false);
271
+ }
272
+
273
+ const offEvent = net.on('event', (e: NetEvent) => {
274
+ const k = e.k;
275
+ const d = (e.d && typeof e.d === 'object' ? e.d : {}) as Record<string, unknown>;
276
+ if (net.isHost) {
277
+ const s = slotOfSeat(e.from);
278
+ if (k === 'agent:do' && s) {
279
+ st.dos += 1;
280
+ apply(s.slot, { goal: d['goal'] as string, args: d['args'] as Record<string, unknown> }, 'brain', false);
281
+ return;
282
+ }
283
+ const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
284
+ if (line && s && d['ai'] === undefined) { say(s.slot, line, (d['args'] ?? {}) as Record<string, unknown>); return; }
285
+ const ask = /^ask:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
286
+ if (ask && e.from !== null && !net.isAgent(e.from) && Number.isInteger(d['slot'])) { onAsk(d['slot'] as number, ask, (d['args'] ?? {}) as Record<string, unknown>, e.from); return; }
287
+ return;
288
+ }
289
+ // Every other browser: a guide's line the host relayed (Quiet AI already dropped it in the helper). Its words are
290
+ // this browser's own copy of the vocabulary; an argument is a seat, a value of the line's list, or an id.
291
+ const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
292
+ if (line && d['ai'] === true && Number.isInteger(d['slot'])) {
293
+ const args = (d['args'] ?? {}) as Record<string, unknown>;
294
+ const def = vocab.lines?.[line];
295
+ const ids = Object.fromEntries(Object.entries(def?.args ?? {}).map(([n, t]) => [n, t]).filter(([, t]) => typeof t === 'string' && VIEW_ARG.test(t)).map(([n, t]) => [VIEW_ARG.exec(t as string)![1], /^[a-z0-9][a-z0-9_-]{0,39}$/.test(String(args[n as string] ?? '')) ? [args[n as string]] : []]));
296
+ if (!def || argsWhy(def.args, args, ids, null)) { st.drops += 1; return; }
297
+ const text = render(line, args, d['slot'] as number);
298
+ if (text && !net.hushed) emit('say', { slot: d['slot'] as number, seat: (d['seat'] as number | null) ?? null, line, args, text });
299
+ }
300
+ });
301
+
302
+ // The host's beat: views to the guides an AI holds (at most one every 2 s each), the floor for the rest.
303
+ const timer = setInterval(() => {
304
+ if (!net.isHost || net.offline) return;
305
+ const now = wall();
306
+ for (const s of guides()) {
307
+ const seat = s.agent?.seat ?? null;
308
+ const held = seat !== null;
309
+ if (held && now - (viewAt.get(s.slot) ?? -Infinity) >= AGENT_RULES.viewMs) {
310
+ const v = viewOf(s.slot);
311
+ const text = JSON.stringify(v);
312
+ st.viewBytes = text.length;
313
+ if (text.length <= AGENT_RULES.viewBytes) {
314
+ lastView.set(s.slot, v);
315
+ net.send('agent:view', v, seat as number);
316
+ viewAt.set(s.slot, now);
317
+ st.views += 1;
318
+ } else st.drops += 1;
319
+ }
320
+ const g = goals.get(s.slot);
321
+ const brainFresh = held && g && g.from === 'brain' && g.state === 'active' && now - g.at < holdMs;
322
+ const waiting = held && askAt.has(s.slot) && now - (askAt.get(s.slot) as number) < askWaitMs;
323
+ // The floor decides for a guide with no AI's decision in force, at most once a second; an ask an AI left
324
+ // unanswered for askWaitMs is answered by the floor (silently: a held guide's lines are its brain's).
325
+ if ((!brainFresh && !waiting && now - (floorAt.get(s.slot) ?? -Infinity) >= AGENT_RULES.floorMs) || (held && askAt.has(s.slot) && !waiting)) floor(s.slot, held);
326
+ }
327
+ // Slots no longer an AI's: forget them.
328
+ const live = new Set(guides().map((s) => s.slot));
329
+ for (const k of [...goals.keys()]) if (!live.has(k)) goals.delete(k);
330
+ }, 250);
331
+
332
+ const agents: Agents = {
333
+ vocab,
334
+ get talking() { return talksOn(net.policy); },
335
+ guides,
336
+ goalOf: (slot) => goals.get(slot) ?? null,
337
+ asksFor,
338
+ ask(slot, k, args = {}) {
339
+ const def = vocab.asks?.[k];
340
+ const s = slotOf(slot);
341
+ if (!def || !s) return false;
342
+ const seat = s.agent?.seat ?? null;
343
+ if (net.isHost) {
344
+ const me = net.offline ? 0 : net.seat;
345
+ if (me === null) return false;
346
+ onAsk(slot, k, args, me);
347
+ // The AI holding this guide hears its host's own player's ask directly.
348
+ if (seat !== null) net.send(`ask:${k}`, { slot, seat, args }, seat);
349
+ return true;
350
+ }
351
+ if (net.seat === null) return false;
352
+ net.send(`ask:${k}`, { slot, seat, args });
353
+ return true;
354
+ },
355
+ askButtons(slot, offer = {}) {
356
+ const out: AskButton[] = [];
357
+ const view = { ...(lastView.get(slot) ?? {}), ...offer };
358
+ for (const [k, def] of Object.entries(vocab.asks ?? {})) {
359
+ const names = Object.keys(def.args ?? {});
360
+ if (!names.length) { const text = render(k, {}, slot, 'asks'); if (text) out.push({ k, args: {}, text }); continue; }
361
+ if (names.length !== 1) continue;
362
+ const name = names[0] as string;
363
+ const type = (def.args as Record<string, ArgType>)[name];
364
+ const m = typeof type === 'string' ? VIEW_ARG.exec(type) : null;
365
+ const values = Array.isArray(type) ? [...type] : m ? (Array.isArray(view[m[1] as string]) ? (view[m[1] as string] as unknown[]) : []) : [];
366
+ for (const v of values.slice(0, 4)) {
367
+ const value = v && typeof v === 'object' ? (v as { id?: unknown }).id : v;
368
+ const text = render(k, { [name]: value }, slot, 'asks');
369
+ if (text) out.push({ k, args: { [name]: value }, text });
370
+ }
371
+ }
372
+ return out;
373
+ },
374
+ done(slot, ok = true) {
375
+ const g = goals.get(slot);
376
+ if (!g || g.state !== 'active') return;
377
+ g.state = ok ? 'done' : 'failed';
378
+ g.at = wall();
379
+ const s = slotOf(slot);
380
+ // A guide no AI holds decides again at once; one an AI holds hears it in its next view.
381
+ if (s && (s.agent?.seat ?? null) === null) floor(slot, false);
382
+ },
383
+ render,
384
+ on(kind: 'say' | 'goal' | 'ask', fn: (e: never) => void) {
385
+ (handlers[kind] as Set<unknown>).add(fn);
386
+ return () => { (handlers[kind] as Set<unknown>).delete(fn); };
387
+ },
388
+ stats: () => ({ ...st }),
389
+ stop() { clearInterval(timer); offEvent(); },
390
+ } as Agents;
391
+ return agents;
392
+ }
393
+
394
+ /** The name an AI guide plays under in a vocabulary (its n-th name, " · AI"). */
395
+ export const guideName = (vocab: Vocabulary, n: number): string => aiName(vocab.names?.[n % Math.max(1, vocab.names?.length ?? 0)] ?? 'Guide');