@homie-rocks/studio 0.24.3 → 0.24.4

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/lib/servers.mjs CHANGED
@@ -18,8 +18,14 @@
18
18
  * homie-studio agents brain key [--remove] the owner's own AI key (owner-key brains), from a page on this
19
19
  * computer only: it goes straight to the Worker secret
20
20
  * HOMIE_BRAIN_KEY, never through a chat, a file or a log
21
- * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
22
- * sit in a guide's seat from this terminal (the local MCP's agent_sit)
21
+ * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude] [--brain local]
22
+ * sit in a guide's seat from this terminal (the local MCP's agent_sit);
23
+ * --brain local: Clef on this computer (Ollama) decides, free
24
+ * homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>,…] --from <seat>] [--model <id>]
25
+ * [--mode workers-ai|owner-key|script] [--quiet] [--avoid <seat>,…]
26
+ * what the studio's guide brain would decide in that moment, with
27
+ * no seat taken: the decision, the brain's own pick, how sure, how
28
+ * long and what it cost (spent from the guides' day)
23
29
  *
24
30
  * The proof of ownership is the studio's own Cloudflare login, as for the office: a ten-minute office key is minted
25
31
  * and dropped around each call. Making a server, a widening change, a pass and a room's level happen at once. A
@@ -128,6 +134,29 @@ export async function agentsBrain(root, game, server, mode, { url, budget } = {}
128
134
  return askedFor(root, url, r, 'agents brain');
129
135
  }
130
136
 
137
+ /**
138
+ * `homie-studio agents try`: the studio's guide brain on one moment (a view file, asks), from its live Worker (or
139
+ * `homie-studio dev`). The answer is the office's (worker/office.mjs agentsTry).
140
+ */
141
+ export async function agentsTry(root, game, { url, view, ask, from, model, mode, quiet, avoid, players, readFile } = {}) {
142
+ const usage = 'usage: homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>,…] --from <seat>] [--model <id>] [--mode workers-ai|owner-key|script] [--quiet] [--avoid <seat>,…]';
143
+ if (!GAME.test(String(game ?? '')) || typeof view !== 'string') return { ok: false, command: 'agents try', why: usage };
144
+ let v;
145
+ try { v = JSON.parse(readFile(view)); } catch (error) { return { ok: false, command: 'agents try', why: `--view is a JSON file of the game state a guide sees (${String(error?.message ?? error).slice(0, 80)})` }; }
146
+ const seats = (x) => (x === undefined || x === true ? undefined : String(x).split(',').map(Number).filter((n) => Number.isInteger(n) && n >= 0));
147
+ const asks = [];
148
+ if (typeof ask === 'string') {
149
+ const [k, rest = ''] = ask.split(':');
150
+ const args = Object.fromEntries(rest.split(',').filter(Boolean).map((kv) => { const [a, val = ''] = kv.split('='); return [a, /^\d+$/.test(val) ? Number(val) : val]; }));
151
+ asks.push({ k, args, from: Number.isInteger(Number(from)) ? Number(from) : 0 });
152
+ }
153
+ const r = await withKey(root, url, (call) => call('/_studio/api/agents/try', {
154
+ game, view: v, asks, ...(typeof model === 'string' ? { model } : {}), ...(typeof mode === 'string' ? { mode } : {}),
155
+ ...(quiet ? { quiet: true } : {}), ...(seats(avoid) ? { avoid: seats(avoid) } : {}), ...(seats(players) ? { players: seats(players) } : {}),
156
+ }));
157
+ return r.ok ? { ok: true, command: 'agents try', ...r } : { ok: false, command: 'agents try', why: r.message ?? r.why };
158
+ }
159
+
131
160
  /**
132
161
  * `homie-studio agents brain key`: the owner's own AI provider key (owner-key brains: claude-haiku-4-5), typed once
133
162
  * into a page on this computer (127.0.0.1, one use, ten minutes) and handed straight to `wrangler secret put
@@ -1117,6 +1117,45 @@
1117
1117
  }
1118
1118
  }
1119
1119
  }
1120
+ },
1121
+ "0.24.4": {
1122
+ "files": {
1123
+ "CLAUDE.md": "ddf2b2a0376202f6",
1124
+ "README.md": "684b3cca8001673c",
1125
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1126
+ "games/README.md": "5ad32fe80c61f117",
1127
+ "music/README.md": "a3dc042dadd39df8",
1128
+ "videos/README.md": "16babc3437d5545b",
1129
+ "posts/README.md": "37841aae476bd540",
1130
+ "site/README.md": "ca131cd0472464fb",
1131
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1132
+ },
1133
+ "sections": {
1134
+ "AGENTS.md": {
1135
+ "(intro)": "214aa46eb3a895d5",
1136
+ "## Layout": "8baf49e4291e7e88",
1137
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1138
+ "## The site": "ab922bb6bde23293",
1139
+ "## Making games": "a8a893eeada33bf5",
1140
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1141
+ "## Art direction and models": "124772c374de7390",
1142
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1143
+ "## Servers and AI seats": "98d08705087a33ec",
1144
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1145
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1146
+ "## Rules": "7e8458adc1b81ece",
1147
+ "## Beta": "9d02638207a26e73"
1148
+ }
1149
+ },
1150
+ "named": {
1151
+ "Homie Arcade": {
1152
+ "sections": {
1153
+ "AGENTS.md": {
1154
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1120
1159
  }
1121
1160
  }
1122
1161
  }
@@ -309,6 +309,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
309
309
  | `roster` | host | `slots` | Labels it (section 17: a host cannot hide an AI), stores it, forwards it, and tells the shell's watchers. |
310
310
  | `caps` | host (revision 6) | `caps: ('skill'\|'agents')[]` | What the host's game does with servers: its bots read the dial, and it moves AI bodies. Stored as the room's (the play page offers the vote; a lite agent may sit) and in the watch feed. |
311
311
  | `vote` | a seated person, or the host (revision 6) | `of: 'skill'`, `n?: 1..levelMax`, `open?: true`, `reason?` | `open` starts the party's vote on the dial (a player may open one every 2 minutes); `n` casts or changes this seat's vote. Agents and watchers never vote. The shell's watch socket may send it too, counted as the seat of the client with the same browser key. |
312
+ | `decide` | host (0.24.4) | `n`, `state`, `questions` | A game's own decision (section 20): checked, paced (one every 3 s and 20 a minute per room), handed to the Table's decision model; answered with `decided` to that socket only. |
312
313
  | `ping` | everyone | `c`, `hid` | Replies `pong`. A host whose `hid` turns true yields. |
313
314
  | `yield` / `bye` | host / anyone | — | `yield` elects another host if one is available. `bye` is a close. |
314
315
 
@@ -328,6 +329,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
328
329
  | `round` / `roster` | everyone but the host | `round` / `slots` |
329
330
  | `pong` | the pinger | `c`, `st` |
330
331
  | `error` | the offender | `code`, `message`, `of?`, `until?` |
332
+ | `decided` | the host that asked (0.24.4) | `n`, `ok`, and `by`, `picks`, `p`, `ms`; or `why` (`off`, `no-ai`, `budget`, `pace` and `retryMs`, `busy`, `bad`, `slow`, `error`, `not-host`) |
331
333
  | `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
332
334
  | `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
333
335
  | `watch` | one watcher | `follow`, `why?` (`overview`, `seated-here`): whether it may follow one player now (section 16) |
@@ -396,6 +398,7 @@ so `ev.origin` is `"null"`.
396
398
  | `ckpt` | < 32 KB | 64 KB (128 KB for 17–32 seats) | 1 Hz | 4 |
397
399
  | `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB (16 / 8 KB for 17–32 seats) | on change | 4 / 8 |
398
400
  | `ping` | — | 256 B | every 2 s | 8 |
401
+ | `decide` | < 2 KB | 6 KB (state 2 KB) | per beat (section 20) | 2; and per room one every 3 s, 20 a minute |
399
402
 
400
403
  **Sockets.**
401
404
  - At most `maxPlayers + 16` per room, and per client address per room **12, or every seat
@@ -1057,12 +1060,29 @@ brain never runs in the frame loop, and it can only choose: goals and lines are
1057
1060
  game's own vocabulary, never free text.
1058
1061
 
1059
1062
  **Where a brain runs.** In the room's own Table, on the studio owner's Worker (a *house
1060
- guide*): Workers AI through the `AI` binding (model var `HOMIE_BRAIN_MODEL`, default
1061
- `@cf/meta/llama-3.1-8b-instruct-fp8-fast`), or the owner's own key (Worker secret
1062
- `HOMIE_BRAIN_KEY`, `claude-haiku-4-5` through the official `@anthropic-ai/sdk`). Or in the
1063
- owner's own AI through the local MCP (`agent_sit`, about 30 s a decision). With none of them,
1064
- over the day's budget, or between decisions: the game's scripted floor (`decide`). homie.rocks
1065
- runs nothing and stores nothing.
1063
+ guide*): Workers AI through the `AI` binding (model var `HOMIE_BRAIN_MODEL`, default since
1064
+ 0.24.4 `@cf/cloudflare/clef-flash`, Cloudflare's **Clef** decision model; `@cf/cloudflare/clef`,
1065
+ the 27B, or a chat model such as `@cf/meta/llama-3.1-8b-instruct-fp8-fast` also work), or the
1066
+ owner's own key (Worker secret `HOMIE_BRAIN_KEY`, `claude-haiku-4-5` through the official
1067
+ `@anthropic-ai/sdk`). Under `homie-studio dev` with no binding: Clef on the person's own computer
1068
+ (Ollama with `clef-flash`, which dev finds; free). Or in the owner's own AI through the local MCP
1069
+ (`agent_sit`, about 30 s a decision), or a seat that thinks with Clef on the owner's computer
1070
+ (`agent_sit { brain: "local" }`). With none of them, over the day's budget, or between decisions:
1071
+ the game's scripted floor (`decide`). homie.rocks runs nothing and stores nothing.
1072
+
1073
+ **Clef asks the decision as the questions it is.** A decision model never writes text: it takes a
1074
+ state and typed questions and returns a probability for every allowed answer, in one pass. A guide's
1075
+ decision is a Choice of goal (only goals whose every argument has a value to take), a Choice per set
1076
+ of argument values (one question for a goal's quest and a line's quest, so what the guide does and
1077
+ says agree; a seat that said "no thanks" and the guide's own seat are never options), a Choice of
1078
+ line or none (only when the guide may speak), and, with no ask open, a yes/no "anything to say at
1079
+ all?" that a line also needs. The newest ask is in the goal question itself, with the answer
1080
+ agents.json gives it. The state is the sanitized view, the open asks, who said no thanks, the
1081
+ guide's own last lines and who is new to it. The answers are composed back into one decision, which
1082
+ parseDecision and every fixed rule still check. Measured 2026-10-03 on 64 recorded Ember Vale
1083
+ moments (`homie-studio agents try`, a throwaway studio): Clef answered 36 of 40 open asks itself
1084
+ (Llama 3.1 8B: 0 of 40), blind judges preferred its decisions in 51 of 64 (Llama in none), model time
1085
+ p50 259 ms, p90 433 ms (Llama 303 / 364 ms).
1066
1086
 
1067
1087
  ### The vocabulary: `games/<id>/agents.json`
1068
1088
 
@@ -1161,14 +1181,16 @@ with a pass (the owner's own Claude) takes a guide's seat from a house guide (`a
1161
1181
  | Pace | At least 3 s between AI calls per guide, at most 10 a minute; one alarm per room at a time, at most one every 3 s. Never `setInterval`. |
1162
1182
  | Prompt | System: the persona, the goals, the lines, the asks (about 400 tokens, stable). User: the view (sanitized: no key that names a person, an account, a ticket, an address or an age; nothing that looks like a secret; seats, never names), the asks, and the last three party lines as ids and arguments (free text only on a `speech: game` server: quoted, 120 characters, labelled as data). |
1163
1183
  | Output | One JSON object `{ goal, args, say, sayArgs }` (a JSON schema where the provider takes one). Anything else (prose, a code fence, an unknown id, a wrong or extra argument, a player who said no thanks) is no decision: the scripted floor answers instead. |
1164
- | Fixed rules | A person's ask is answered the way agents.json says (its `goal`): a model's other choice is overruled, and the asked-for goal is carried through until it is done (or 60 s) with no model call meanwhile but for a new ask. A line at most every 8 s. "No thanks" holds the guide off that player for 10 minutes. No goal aims at a player. |
1184
+ | Fixed rules | A person's ask is answered the way agents.json says (its `goal`, with the values the person asked for): a model's other goal, or the asked goal with another value, is overruled, and the asked-for goal is carried through until it is done (or 60 s) with no model call meanwhile but for a new ask. A line at most every 8 s. "No thanks" holds the guide off that player for 10 minutes. No goal aims at a player. |
1165
1185
  | Budget | A day, for the whole studio (meta `brain_budget`): 8,000 Workers AI neurons (the free allocation is 10,000 an account), $1 of the owner's key. Counted in `stats_daily` (`brain-calls`, `brain-neurons`, `brain-microdollars`), written at most once a minute. Over it: the scripted floor until 00:00 UTC. |
1166
- | Log | The last 50 decisions per room (seat, goal, line, provider, time, why), in memory, in the office. |
1186
+ | Log | The last 50 decisions per room (seat, goal, line, provider, time, why; the model, Clef's probability for the goal and line, and the brain's own pick when a rule overruled it), in memory, in the office. |
1187
+ | Try | `homie-studio agents try <game> --view <file> [--ask …] [--model …]` (office API `/_studio/api/agents/try`): the same brain and rules on one moment, no seat taken, spent from the same day. |
1167
1188
 
1168
1189
  **Cost.** A house guide costs no request of its own beyond the alarm: one per room per due
1169
- decision, at most one every 3 s. A decision is about 4.3 Workers AI neurons
1170
- (`@cf/meta/llama-3.1-8b-instruct-fp8-fast`: 700 tokens in, 40 out), or $0.0009 of the owner's
1171
- key.
1190
+ decision, at most one every 3 s. A Clef decision is about 9 Workers AI neurons (clef-flash: about
1191
+ 1,000 tokens in at $0.09 a million, nothing for output), so the default day of 8,000 is about 900
1192
+ decisions; a Llama decision is about 4.1 (700 tokens in, 40 out); the owner's key about $0.0009.
1193
+ Under dev, Clef on the person's own computer costs nothing.
1172
1194
 
1173
1195
  **Kids and safety.** The AI never types: it picks ids, the game renders the creator's text.
1174
1196
  A beginner server's chat is quick lines only; on a kids server names are handles and no free
@@ -1270,7 +1292,7 @@ and reach every screen at once.
1270
1292
  | Review | |
1271
1293
  |---|---|
1272
1294
  | Budget | A day, for the whole studio (meta `chat_budget`): 2,000 neurons by default. With the AI guides' 8,000 that is the 10,000 Workers AI gives an account free a day. A short message measured about 2.3 neurons (clef-flash: $0.09 per million input tokens, no output charge; the question and its answers are most of the input), so about 850 reviewed messages a day. Counted in `stats_daily` (`chat-reviews`, `chat-neurons`). |
1273
- | No review | No `AI` binding (`homie-studio dev` without `--remote-ai`), the day's budget used, the model failing or slower than 1.5 s, more than 8 messages waiting: the floor alone decides, the message goes out, and the office counts it. |
1295
+ | No review | No `AI` binding (`homie-studio dev` without `--remote-ai`, unless Clef is on the person's own computer: then it reviews there, free), the day's budget used, the model failing or slower than 1.5 s, more than 8 messages waiting: the floor alone decides, the message goes out, and the office counts it. |
1274
1296
  | The owner | The studio's owner is never reviewed or held by slow mode (their lines are marked `owner`). |
1275
1297
 
1276
1298
  ### The owner's tools
@@ -1344,3 +1366,55 @@ about 2.3 neurons a typed line, inside the free allocation by default.
1344
1366
  **Old games and old relays.** A game built before revision 8 has the panel, the float, the ticker
1345
1367
  and the TV corner; it draws no bubbles and its own UI cannot send. A revision-8 helper on an older
1346
1368
  relay never hears a `line` and has `chatRules` null.
1369
+
1370
+ ---
1371
+
1372
+ ## 20. A game's own decisions (0.24.4)
1373
+
1374
+ Clef answers in about a tenth of a second and never writes text, so a game can ask it what to do
1375
+ next about its own state: a tactic for its opponents, an NPC's reaction from a fixed set, a
1376
+ director's call (a wave now? push harder?), a turn-based move. The host asks; the room's own
1377
+ Durable Object asks the studio's Workers AI (or Clef on the person's own computer under `dev`);
1378
+ the answer comes back as option ids, yes or no, and numbers. Nothing a player reads comes from a
1379
+ model.
1380
+
1381
+ ```ts
1382
+ const d = await net.decide(
1383
+ { heroes: [{ hp: 40 }, { hp: 90 }], slimes: { count: 9 } }, // the game's state (sanitized; seats, never names)
1384
+ {
1385
+ tactic: { type: 'choice', instructions: 'How should the slimes hunt?', criteria: { chase: 'Rush the nearest hero', surround: 'Close in from every side' } },
1386
+ wave: { type: 'noul', instructions: 'Should a wave come now?' },
1387
+ pressure: { type: 'score', instructions: 'How hard should the vale push?', criteria: ['A breather', 'Steady', 'Fierce'] },
1388
+ },
1389
+ { floor: () => ({ tactic: 'chase', wave: false, pressure: 1 }) }, // the game's own answer, synchronous
1390
+ );
1391
+ // d.by: 'ai' | 'local' | 'floor'; d.picks: { tactic: 'surround', wave: false, pressure: 1.4 }; d.p: probabilities
1392
+ ```
1393
+
1394
+ | | |
1395
+ |---|---|
1396
+ | Opt-in | game.json `"decide": true` (the build carries it into games.json; deploy binds Workers AI for it). Without it the room answers `off` and the floor plays. |
1397
+ | Questions | 1 to 8: Choice (2 to 26 option ids), yes/no (`noul`), Score (2 to 10 levels, lowest first; the pick is the probability-weighted level). Instructions and descriptions at most 160 characters. The state at most 2 KB after the same sanitizing as a guide's view. |
1398
+ | Frames | Host to relay `{ t: 'decide', n, state, questions }`; relay to that host only `{ t: 'decided', n, ok, by, picks, p, ms }` or `{ t: 'decided', n, ok: false, why }` (`off`, `no-ai`, `budget`, `pace` with `retryMs`, `busy`, `bad`, `slow`, `error`, `not-host`). |
1399
+ | Pace | One ask every 3 s and 20 a minute per room, two at a time; the helper waits 2.5 s (at most 5) and then answers from the floor. After `off`, `no-ai` or `budget` it asks again only after a minute. |
1400
+ | Budget | The AI brains' day (meta `brain_budget`, 8,000 neurons by default): decisions and guides share it, counted as `brain-calls` source `decide`. About 4 neurons for three short questions. |
1401
+ | Office | Each room's decisions: how many, who answered, the model's median time, the neurons. Never a state. |
1402
+
1403
+ **What rate is real.** Measured 2026-10-03 on a throwaway studio (Ember Vale's slimes' director,
1404
+ three questions every 6 s): the host's round trip p50 160 to 253 ms, p90 347 to 497 ms; the model's
1405
+ time in the room p50 115 ms; about 4.1 neurons a decision. So never per frame; per second is fast
1406
+ enough but one room asking every second would spend about 15,000 neurons an hour, more than the free
1407
+ allocation's whole day; **per beat (every 5 to 10 s), per turn, or on an event** is the rate that fits
1408
+ the free plan (a room asking every 6 s: about 2,500 neurons an hour). Clef on the person's own
1409
+ computer costs nothing; its time depends on the computer.
1410
+
1411
+ **Kids and the dial.** A decision moves the game's own world (its opponents, its director), never a
1412
+ person, and never the party's skill dial: the bots' level stays the room's. On a kids server the game
1413
+ keeps its decisions gentle (Ember Vale's slimes never gang up on the most hurt hero there, and the vale
1414
+ never pushes past steady). The state follows a view's rules: game state and seats, never a name, an
1415
+ account or typed text.
1416
+
1417
+ **Ember Vale** (the starter, opt-in: its game.json ships `"decide": false`): every 6 s of a live night
1418
+ the host asks how the slimes hunt (chase, surround, gang up on the most hurt, regroup at the King),
1419
+ whether a wave comes, and how hard to push; every screen shows what the slimes are up to when a model
1420
+ chose it. The floor is the vale as it always played.
@@ -402,6 +402,8 @@ export interface NetStats {
402
402
  reconnects: number;
403
403
  promotions: number;
404
404
  round: number | null;
405
+ /** Section 20: the host's decisions: asked, answered by the studio's model ('ai'), the person's own Ollama ('local'), or the floor; median ms. */
406
+ decides?: { asked: number; ai: number; local: number; floor: number; msP50: number | null };
405
407
  }
406
408
 
407
409
  export interface Vec { x: number; y: number; z?: number }
@@ -497,6 +499,29 @@ export interface NetHandlers<S, A, C> {
497
499
  held: (h: ChatHeld) => void;
498
500
  }
499
501
 
502
+ /**
503
+ * A GAME'S OWN DECISIONS (section 20): typed questions about the game's state that the studio's decision model (Clef)
504
+ * answers with a probability for every option, never with text. A Choice picks one option id (2 to 26), a yes/no
505
+ * (`noul`) is true or false, a Score is a level (2 to 10, lowest first; the answer is the probability-weighted level).
506
+ */
507
+ export type DecideQuestion =
508
+ | { type: 'choice'; instructions: string; criteria: Record<string, string | null> }
509
+ | { type: 'noul'; instructions: string; criteria?: { true?: string; false?: string } }
510
+ | { type: 'score'; instructions: string; criteria: string[] };
511
+ export type DecidePicks = Record<string, string | boolean | number>;
512
+ /**
513
+ * What `decide` resolves to, always: `by` 'ai' (the studio's Workers AI) or 'local' (the person's own Ollama under dev),
514
+ * or 'floor' (the game's own answer: no AI, not opted in, over budget, paced, slow, not the host); `why` says which.
515
+ * `p`: the model's probability for each option (a choice), `{ yes }` (a yes/no), or each level (a score).
516
+ */
517
+ export interface Decided { by: 'ai' | 'local' | 'floor'; picks: DecidePicks; p?: Record<string, Record<string, number>>; ms: number; why?: string }
518
+ export interface DecideOptions {
519
+ /** The game's own answer, synchronous: used whenever the model's is not there in time. Missing picks fall back to it one by one. */
520
+ floor?: (state: unknown) => DecidePicks;
521
+ /** How long to wait for the model (default 2500 ms; at most 5000). */
522
+ ms?: number;
523
+ }
524
+
500
525
  export interface Netplay<S = unknown, A = unknown, C = unknown> {
501
526
  readonly role: Role;
502
527
  readonly isHost: boolean;
@@ -640,6 +665,13 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
640
665
  /** Interpolation pair for rendering remote entities. */
641
666
  sample(delayMs?: number): Sample<S> | null;
642
667
  latest(): Snapshot<S> | null;
668
+ /**
669
+ * Host: ask the studio's decision model (Clef) a few typed questions about the game's state, for a game whose
670
+ * game.json says "decide": true. Always resolves: with the model's picks, or the floor's when it cannot answer in
671
+ * time (no AI, over the day's budget, at most one ask every 3 s a room, a replica, offline). Never per frame: per
672
+ * beat (every few seconds), and the game keeps playing while it waits.
673
+ */
674
+ decide(state: unknown, questions: Record<string, DecideQuestion>, opts?: DecideOptions): Promise<Decided>;
643
675
  stats(): NetStats;
644
676
  expose(probes: Probes): void;
645
677
  close(): void;
@@ -1069,6 +1101,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1069
1101
  /** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
1070
1102
  const inSent: number[] = [];
1071
1103
  let inSlowUntil = 0;
1104
+ /** Section 20: decisions waiting for the room's answer (by request id), and how long the room said it is off. */
1105
+ const deciding = new Map<string, { done: (d: Decided) => void; timer: ReturnType<typeof setTimeout>; at: number; floor: () => Decided }>();
1106
+ let decideSeq = 0;
1107
+ let decideOffUntil = 0;
1108
+ let decideNextAt = 0;
1109
+ const decideStats = { asked: 0, ai: 0, local: 0, floor: 0, ms: [] as number[] };
1072
1110
  let idleSkipped = 0;
1073
1111
  /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
1074
1112
  let closedWhy: string | null = null;
@@ -1460,6 +1498,29 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1460
1498
  resolveView('policy');
1461
1499
  return;
1462
1500
  }
1501
+ case 'decided': {
1502
+ const n = String(m['n'] ?? '');
1503
+ const w = deciding.get(n);
1504
+ if (!w) return;
1505
+ deciding.delete(n);
1506
+ clearTimeout(w.timer);
1507
+ const ms = Math.round(wall() - w.at);
1508
+ if (m['ok'] === true && m['picks'] && typeof m['picks'] === 'object') {
1509
+ const base = w.floor();
1510
+ const by = m['by'] === 'local' ? 'local' : 'ai';
1511
+ decideStats[by] += 1;
1512
+ decideStats.ms.push(ms); if (decideStats.ms.length > 40) decideStats.ms.shift();
1513
+ w.done({ by, picks: { ...base.picks, ...(m['picks'] as DecidePicks) }, p: (m['p'] as Decided['p']) ?? {}, ms });
1514
+ return;
1515
+ }
1516
+ const why = String(m['why'] ?? 'error');
1517
+ // Not opted in, no AI or out of budget: ask again only after a minute. Paced: at the room's next slot.
1518
+ if (why === 'off' || why === 'no-ai' || why === 'budget') decideOffUntil = wall() + 60_000;
1519
+ if (why === 'pace' && Number.isFinite(Number(m['retryMs']))) decideNextAt = wall() + Number(m['retryMs']);
1520
+ decideStats.floor += 1;
1521
+ w.done({ ...w.floor(), why, ms });
1522
+ return;
1523
+ }
1463
1524
  case 'error': {
1464
1525
  const code = String(m['code'] ?? '');
1465
1526
  // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
@@ -1748,6 +1809,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1748
1809
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1749
1810
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
1750
1811
  peers: peers.size, reconnects, promotions, round: roundInfo ? roundInfo.n : null,
1812
+ decides: { asked: decideStats.asked, ai: decideStats.ai, local: decideStats.local, floor: decideStats.floor, msP50: [...decideStats.ms].sort((x, y) => x - y)[Math.floor(decideStats.ms.length / 2)] ?? null },
1751
1813
  };
1752
1814
  }
1753
1815
  function notifyStats(force: boolean): void {
@@ -2185,6 +2247,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2185
2247
  return { a: newest, b: newest, alpha: 1, renderT, starved: 0 };
2186
2248
  },
2187
2249
  latest() { return buf.length ? buf[buf.length - 1] as Snapshot<S> : null; },
2250
+ decide(state, questions, o = {}) {
2251
+ const t0 = wall();
2252
+ const floor = (): Decided => {
2253
+ let picks: DecidePicks = {};
2254
+ try { picks = o.floor ? o.floor(state) ?? {} : {}; } catch { picks = {}; }
2255
+ return { by: 'floor', picks, ms: Math.round(wall() - t0) };
2256
+ };
2257
+ decideStats.asked += 1;
2258
+ const now0 = wall();
2259
+ const why = role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
2260
+ if (why) { decideStats.floor += 1; return Promise.resolve({ ...floor(), why }); }
2261
+ decideNextAt = now0 + 3000;
2262
+ const n = `d${(decideSeq += 1).toString(36)}`;
2263
+ return new Promise<Decided>((done) => {
2264
+ const ms = Math.max(200, Math.min(5000, Number(o.ms) || 2500));
2265
+ const timer = setTimeout(() => { if (deciding.delete(n)) { decideStats.floor += 1; done({ ...floor(), why: 'slow' }); } }, ms);
2266
+ deciding.set(n, { done, timer, at: t0, floor });
2267
+ if (!raw({ t: 'decide', n, state, questions })) { deciding.delete(n); clearTimeout(timer); decideStats.floor += 1; done({ ...floor(), why: 'offline' }); }
2268
+ });
2269
+ },
2188
2270
  stats: statsNow,
2189
2271
  expose(p) { probes = { ...probes, ...p }; },
2190
2272
  close() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.24.3",
3
+ "version": "0.24.4",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -7,6 +7,7 @@
7
7
  "entry": "src/main.ts",
8
8
  "netplay": { "v": 1, "public": true, "movement": "owner" },
9
9
  "saves": true,
10
+ "decide": false,
10
11
  "landing": {
11
12
  "pitch": "Your hero keeps every level. The night does not.",
12
13
  "howToPlay": [
@@ -43,7 +43,7 @@
43
43
  * The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
44
44
  * every lab call is a no-op.
45
45
  */
46
- import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
46
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type DecidePicks, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
47
47
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
48
48
  import vocabulary from '../agents.json';
49
49
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
@@ -376,12 +376,66 @@ function stepHost(dt: number): void {
376
376
  b.flags = now - b.strikeAt < T.flashMs ? STRIKING : 0;
377
377
  if (wants && live && now - b.atkAt > T.strikeMs) strike(b, now);
378
378
  }
379
- if (live) stepSlimes(dt, now);
379
+ if (live) { stepDirector(now); stepSlimes(dt, now); }
380
380
  // Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
381
381
  net.state('quests', openQuests());
382
382
  room.update();
383
383
  }
384
384
 
385
+ /* ------------------------------------------------------------------ the slimes' director (section 20, opt-in) */
386
+ /**
387
+ * Every 6 s of a live night the host asks the studio's decision model how the slimes should hunt (`net.decide`: Clef,
388
+ * three typed questions, never text): a tactic, whether a wave comes now, how hard to push. It runs only when game.json
389
+ * says "decide": true (the room answers "off" otherwise, and asks are spent from the studio's AI brains' day); without it,
390
+ * over budget or slow, the floor answers: chase, no wave, steady, which is how the vale always played. On a kids server
391
+ * the slimes never gang up on the most hurt hero and the vale never pushes past steady. The heroes' dial is untouched:
392
+ * this is the slimes' mind, not the bots'.
393
+ */
394
+ type Tactic = 'chase' | 'surround' | 'weakest' | 'regroup';
395
+ const TACTICS: Record<Tactic, string> = {
396
+ chase: 'Rush the nearest hero',
397
+ surround: 'Spread out and close in on a hero from every side',
398
+ weakest: 'Gang up on the most hurt hero',
399
+ regroup: 'Fall back to the King Slime and gather before the next push',
400
+ };
401
+ /** How often a slime comes, by pressure (0 a breather .. 4 fierce). 1100 ms at 2 is the vale as it always was. */
402
+ const SPAWN_MS = [1900, 1450, 1100, 850, 700];
403
+ const DIRECTOR_MS = 6000;
404
+ const director = { tactic: 'chase' as Tactic, pressure: 2, waveLeft: 0, by: 'floor', at: 0, asking: false, log: [] as Record<string, unknown>[] };
405
+ const directorFloor = (): DecidePicks => ({ tactic: 'chase', wave: false, pressure: 2 });
406
+ function stepDirector(now: number): void {
407
+ if (!room.hosting || director.asking || now < director.at) return;
408
+ director.at = now + DIRECTOR_MS;
409
+ director.asking = true;
410
+ const kidsRoom = kids();
411
+ const king = slimes.find((o) => o.size === 3);
412
+ const heroes = [...room.bodies.values()].filter((b) => !b.bot && b.seat !== null);
413
+ const state = {
414
+ heroes: heroes.map((b) => ({ hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN), level: b.level, nearKing: king ? Math.round(Math.hypot(king.x - b.x, king.y - b.y)) : null })),
415
+ slimes: { count: slimes.length, big: slimes.filter((o) => o.size === 2).length, king: king ? Math.round((100 * king.hp) / king.maxHp) : null },
416
+ night: { secondsLeft: room.clock().secondsLeft }, now: { tactic: director.tactic, pressure: director.pressure },
417
+ ...(kidsRoom ? { note: 'Young players: keep it gentle' } : {}),
418
+ };
419
+ const tactics = Object.fromEntries(Object.entries(TACTICS).filter(([k]) => !(kidsRoom && k === 'weakest')));
420
+ const asked = performance.now();
421
+ void net.decide(state, {
422
+ tactic: { type: 'choice', instructions: 'How should the slimes hunt the heroes for the next few seconds? A fun fight, not a massacre: hurt heroes deserve a breather.', criteria: tactics },
423
+ wave: { type: 'noul', instructions: 'Should a wave of extra slimes come now?', criteria: { true: 'The heroes are healthy and it has gone quiet', false: 'The heroes are hurt, down or few' } },
424
+ pressure: { type: 'score', instructions: 'How hard should the vale push the heroes right now?', criteria: ['A breather', 'Easy', 'Steady', 'Hard', 'Fierce'] },
425
+ }, { floor: directorFloor }).then((d) => {
426
+ director.asking = false;
427
+ const t = String(d.picks['tactic'] ?? 'chase') as Tactic;
428
+ director.tactic = t in tactics ? t : 'chase';
429
+ director.pressure = clamp(Math.round(Number(d.picks['pressure'] ?? 2)), 0, kidsRoom ? 2 : 4);
430
+ if (d.picks['wave'] === true && director.waveLeft === 0) director.waveLeft = 3;
431
+ director.by = d.by;
432
+ director.log.push({ at: Date.now(), by: d.by, why: d.why ?? null, ms: Math.round(performance.now() - asked), tactic: director.tactic, pressure: director.pressure, wave: d.picks['wave'] === true });
433
+ if (director.log.length > 40) director.log.shift();
434
+ // Every screen sees what the slimes are up to (only when a model decided; the floor is the vale as always).
435
+ net.state('slimes', d.by === 'floor' ? null : { tactic: director.tactic, pressure: director.pressure });
436
+ });
437
+ }
438
+
385
439
  /* ------------------------------------------------------------------ the guides' hands */
386
440
  const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
387
441
  const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
@@ -498,8 +552,11 @@ function stepSlimes(dt: number, now: number): void {
498
552
  if (lab.stage === 'dummy') return; // the lab's training slime stands still and never bites
499
553
  const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
500
554
  const people = bodies.filter((b) => !b.bot).length;
501
- if (now >= spawnAt && slimes.length < 6 + people * 2) {
502
- spawnAt = now + 1100;
555
+ // The director's pressure (0 a breather .. 4 fierce; 2 is how the vale always played) sets how fast and how many.
556
+ const cap = Math.max(4, 6 + people * 2 + (director.pressure - 2) * 2) + (director.waveLeft > 0 ? 3 : 0);
557
+ if ((now >= spawnAt || director.waveLeft > 0) && slimes.length < cap) {
558
+ spawnAt = now + SPAWN_MS[director.pressure]!;
559
+ if (director.waveLeft > 0) director.waveLeft -= 1;
503
560
  const edge = Math.floor(Math.random() * 4);
504
561
  const x = edge === 0 ? 30 : edge === 1 ? W - 30 : Math.random() * W;
505
562
  const y = edge === 2 ? 30 : edge === 3 ? H - 30 : Math.random() * H;
@@ -517,10 +574,18 @@ function stepSlimes(dt: number, now: number): void {
517
574
  if (struck.has(s.id)) continue;
518
575
  let target: Body | null = null; let bd = Infinity;
519
576
  for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
577
+ // The director's tactic (section 20; chase is how the vale always played): the most hurt hero, a ring round the
578
+ // nearest, or back to the King to gather (a hero close by is still bitten).
579
+ if (director.tactic === 'weakest' && prey.length > 1) target = prey.reduce((a, b) => (b.hp / b.maxHp < a.hp / a.maxHp ? b : a));
520
580
  const speed = s.size === 3 ? 70 : 95 - s.size * 10;
521
581
  if (target) {
522
- const dx = target.x - s.x; const dy = target.y - s.y; const dist = Math.hypot(dx, dy) || 1;
523
- s.x += (dx / dist) * speed * dt; s.y += (dy / dist) * speed * dt;
582
+ let gx = target.x; let gy = target.y;
583
+ const king = slimes.find((o) => o.size === 3);
584
+ if (director.tactic === 'surround' && bd > 90) { const a = s.id * 2.39996; gx += Math.cos(a) * 80; gy += Math.sin(a) * 80; }
585
+ if (director.tactic === 'regroup' && king && s !== king && bd > 140) { gx = king.x + Math.cos(s.id) * 70; gy = king.y + Math.sin(s.id) * 70; }
586
+ const dx = gx - s.x; const dy = gy - s.y; const dist = Math.hypot(target.x - s.x, target.y - s.y) || 1;
587
+ const step = Math.hypot(dx, dy) || 1;
588
+ if (step > 4) { s.x += (dx / step) * speed * dt; s.y += (dy / step) * speed * dt; }
524
589
  if (dist < R + 10 + s.size * 10 && now - s.hitAt > 800) {
525
590
  s.hitAt = now;
526
591
  target.hp -= 5 * s.size + (s.size === 3 ? 10 : 0);
@@ -919,6 +984,13 @@ function hud(t: number): void {
919
984
  // Top centre; on a narrow screen where that would touch the panel, just under it.
920
985
  if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
921
986
  else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
987
+ // What the slimes are up to, when the studio's decision model chose it (section 20; nothing shows on the floor).
988
+ const mind = c.phase === 'live' ? net.stateOf<{ tactic: string; pressure: number }>('slimes') : null;
989
+ if (mind) {
990
+ const words: Record<string, string> = { chase: 'rushing you', surround: 'closing in from every side', weakest: 'hunting the hurt', regroup: 'gathering round the King' };
991
+ ctx.font = SMALL; ctx.fillStyle = '#e7a86b'; ctx.textAlign = 'center';
992
+ ctx.fillText(`Slimes: ${words[mind.tactic] ?? mind.tactic}`, cw / 2, x0 + pw + 10 > cw / 2 - 80 ? y0 + 62 + 40 : 50);
993
+ }
922
994
  ctx.textAlign = 'center';
923
995
  if (c.phase === 'over') {
924
996
  const rows = room.results().slice(0, 5);
@@ -1144,6 +1216,8 @@ net.expose({
1144
1216
  me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
1145
1217
  log: guideLog.slice(),
1146
1218
  }),
1219
+ // The slimes' director (section 20): its tactic now, and each decision (who answered, how long it took).
1220
+ director: () => ({ tactic: director.tactic, pressure: director.pressure, by: director.by, log: director.log.slice(), net: net.stats().decides ?? null }),
1147
1221
  // Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
1148
1222
  camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
1149
1223
  labels: () => shownLabels.map((l) => ({ text: l.text, self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),