@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/worker/chat.mjs CHANGED
@@ -14,12 +14,15 @@
14
14
  * links, swears, and the studio's own words. It always runs, and it needs nothing.
15
15
  * review typed text the floor let through, on the studio's own Workers AI: Cloudflare's Clef decision model
16
16
  * (`@cf/cloudflare/clef-flash`, launched 2026-10-01: a probability for each answer, about 40 ms), within a
17
- * day's budget of neurons. No binding, no budget left or an error: the floor alone decides.
17
+ * day's budget of neurons. Under `homie-studio dev` with no binding, the same questions go to the person's
18
+ * own Ollama (clef-flash) when dev found it there: free. No model, no budget left or an error: the floor
19
+ * alone decides.
18
20
  *
19
21
  * Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
20
22
  * NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
21
23
  */
22
24
  import { WORDS_B64 } from './chat-words.mjs';
25
+ import { clefRun, localAiOf } from './brain.mjs';
23
26
 
24
27
  export const CHAT_MODES = Object.freeze(['off', 'emoji', 'lines', 'text']);
25
28
  export const CHAT_WHO = Object.freeze(['anyone', 'signed-in', 'members']);
@@ -387,9 +390,11 @@ const timeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(()
387
390
  * (`why` is the most likely other answer). Throws when there is no binding, the model fails or it takes too long:
388
391
  * the caller lets the floor decide.
389
392
  */
390
- export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now() } = {}) {
391
- if (!env?.AI || typeof env.AI.run !== 'function') throw new Error('this Worker has no Workers AI binding (AI)');
392
- const spec = CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
393
+ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now(), fetch: fetchImpl = null } = {}) {
394
+ // No binding: the person's own Ollama (`homie-studio dev` found clef-flash there), with the same question. Free.
395
+ const local = env?.AI && typeof env.AI.run === 'function' ? null : localAiOf(env);
396
+ if (!local && (!env?.AI || typeof env.AI.run !== 'function')) throw new Error('this Worker has no Workers AI binding (AI)');
397
+ const spec = local ? { kind: 'clef', name: local.model, input: 0, output: 0 } : CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
393
398
  const at = now();
394
399
  if (spec.kind === 'guard') {
395
400
  const r = await timeout(env.AI.run(model, { messages: [{ role: 'user', content: text }], temperature: 0, max_tokens: 16, response_format: { type: 'json_object' } }), ms);
@@ -402,7 +407,9 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
402
407
  }
403
408
  // A room that allows links says so, so a plain link is not read as spam.
404
409
  const verdict = links === 'allow' ? { ...VERDICT, criteria: { ...VERDICT.criteria, spam: 'Ads, scams or spam (a plain link is fine here)' } } : VERDICT;
405
- const r = await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
410
+ const r = local
411
+ ? await clefRun(env, { state: text, questions: { verdict }, ms: Math.max(ms, 4000), local, fetch: fetchImpl })
412
+ : await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
406
413
  const a = r?.answers?.verdict;
407
414
  if (!a || typeof a !== 'object') throw new Error('the review answered nothing it could use');
408
415
  const probs = a.probabilities && typeof a.probabilities === 'object' ? a.probabilities : null;
@@ -415,5 +422,5 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
415
422
  why = why ?? 'held';
416
423
  }
417
424
  const input = Number(r?.usage?.input_tokens ?? r?.usage?.prompt_tokens) || Math.ceil((text.length + VERDICT_CHARS) / 4);
418
- return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model };
425
+ return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model: local ? local.model : model, ...(local ? { local: true } : {}) };
419
426
  }
package/worker/index.mjs CHANGED
@@ -86,7 +86,7 @@ import {
86
86
  setMembership,
87
87
  } from './servers.mjs';
88
88
  import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
89
- import { talks } from './brain.mjs';
89
+ import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
90
90
  import { REACTIONS, reviewChat } from './chat.mjs';
91
91
  import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
92
92
  import { doorPage, serverPage, serversPage } from './site.mjs';
@@ -1163,16 +1163,71 @@ export class Table {
1163
1163
  this.vocabRead = this.readVocab(game).catch(() => false);
1164
1164
  // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
1165
1165
  this.room.review = (text, opts) => this.reviewChat(text, opts);
1166
+ // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
1167
+ this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1166
1168
  return this.room;
1167
1169
  }
1168
1170
 
1171
+ /** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
1172
+ async decideOn() {
1173
+ if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
1174
+ let on = false;
1175
+ try {
1176
+ const res = this.env.ASSETS ? await this.env.ASSETS.fetch(new Request('https://assets.local/games.json')) : null;
1177
+ const cat = res?.ok ? await res.json() : null;
1178
+ const g = (cat?.games ?? []).find((x) => x.id === this.game);
1179
+ on = Boolean(g && (g.decide === true || (g.decide && typeof g.decide === 'object' && g.decide.on !== false)));
1180
+ } catch { on = false; }
1181
+ this.decideRead = { at: Date.now(), on };
1182
+ return on;
1183
+ }
1184
+
1185
+ /**
1186
+ * One of a game's own decisions (worker/brain.mjs checkDecide, picksOf): Clef on the studio's Workers AI (model
1187
+ * HOMIE_DECIDE_MODEL, else clef-flash), or the person's own Ollama under `homie-studio dev`, within the AI brains' day
1188
+ * (the same budget the guides spend: decisions and guides are one day of neurons). Not opted in, no AI, over budget, too
1189
+ * slow or failed: `{ ok: false, why }`, and the host's floor answers.
1190
+ */
1191
+ async decide(state, questions) {
1192
+ if (!(await this.decideOn())) return { ok: false, why: 'off' };
1193
+ const local = this.env.AI ? null : localAiOf(this.env);
1194
+ if (!this.env.AI && !local) return { ok: false, why: 'no-ai' };
1195
+ if (this.env.AI) {
1196
+ if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(() => {});
1197
+ if (this.brainCap.neurons - this.brainUsed.neurons <= 0) return { ok: false, why: 'budget' };
1198
+ }
1199
+ const model = CLEF.models[this.env.HOMIE_DECIDE_MODEL] ? this.env.HOMIE_DECIDE_MODEL : '@cf/cloudflare/clef-flash';
1200
+ const t0 = Date.now();
1201
+ try {
1202
+ const r = await clefRun(this.env, { state, questions, model, ms: DECIDE.ms, local });
1203
+ const ms = Date.now() - t0;
1204
+ const usage = r.usage ?? { input_tokens: Math.ceil(JSON.stringify({ state, questions }).length / 4) + 400, output_tokens: 0 };
1205
+ const cost = costOf({ provider: r.engine === 'local' ? 'local' : 'workers-ai', model: r.model, usage });
1206
+ this.brainSpend({ neurons: cost.neurons, micros: 0, provider: r.engine === 'local' ? 'decide-local' : 'decide' });
1207
+ this.flushSoon();
1208
+ const { picks, p } = picksOf(r.answers, questions);
1209
+ return { ok: true, by: r.engine === 'local' ? 'local' : 'ai', picks, p, ms, neurons: cost.neurons };
1210
+ } catch (error) {
1211
+ return { ok: false, why: /took over/.test(String(error?.message ?? error)) ? 'slow' : 'error' };
1212
+ }
1213
+ }
1214
+
1215
+ /** The day's counters go to D1 on an alarm: set one a minute out when none is set (a house guide's alarm also flushes). */
1216
+ flushSoon() {
1217
+ this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(() => {}));
1218
+ }
1219
+
1169
1220
  /**
1170
1221
  * One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
1171
1222
  * budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
1172
1223
  * floor alone has decided (`by` says which), and the message goes out.
1173
1224
  */
1174
1225
  async reviewChat(text, { links = 'block' } = {}) {
1175
- if (!this.env.AI) return { ok: true, by: 'none' };
1226
+ // Under `homie-studio dev` with no binding, the person's own Ollama reviews (free, so no budget).
1227
+ if (!this.env.AI) {
1228
+ if (!localAiOf(this.env)) return { ok: true, by: 'none' };
1229
+ try { const v = await reviewChat(this.env, text, { links }); return { ok: v.ok, by: 'local', why: v.why, ms: v.ms, p: v.p }; } catch { return { ok: true, by: 'error' }; }
1230
+ }
1176
1231
  if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
1177
1232
  if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
1178
1233
  try {
package/worker/office.mjs CHANGED
@@ -56,9 +56,9 @@
56
56
  */
57
57
  import { SEAT_MAX, seatsOf } from './seats.mjs';
58
58
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
59
- import { BRAIN_BUDGET, aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
60
- import { DEFAULT_MODEL, OWNER_MODEL } from './brain.mjs';
61
- import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from './stats.mjs';
59
+ import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
60
+ import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
61
+ import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
62
62
  import { esc, layout, notFoundPage } from './site.mjs';
63
63
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
64
64
  import { licenseOf } from './license.mjs';
@@ -473,6 +473,8 @@ async function roomRow(env, meta, room, max) {
473
473
  clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
474
474
  // Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
475
475
  chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
476
+ // A game's own decisions (0.24.4, net.decide): how many, who answered, the model's median time, the neurons.
477
+ ...(office.decides && typeof office.decides === 'object' ? { decides: { n: Number(office.decides.n) || 0, by: office.decides.by ?? {}, msP50: office.decides.msP50 ?? null, neurons: Number(office.decides.neurons) || 0 } } : {}),
476
478
  // The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
477
479
  ...(f.brains && typeof f.brains === 'object' ? { brains: {
478
480
  brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
@@ -482,6 +484,62 @@ async function roomRow(env, meta, room, max) {
482
484
  };
483
485
  }
484
486
 
487
+ /**
488
+ * `agents try` (0.24.4): a guide's brain on one moment the owner gives, with no seat taken and nobody in a room: the
489
+ * game's own vocabulary, the view, the asks and the party's lines in; the decision out, with the brain's own pick
490
+ * before the fixed rules, Clef's probabilities, the model's time and the neurons. It runs exactly what a house guide
491
+ * runs (agents.mjs think) and spends from the same day's budget, so a creator can test an agents.json, and compare a
492
+ * model (`model`: one of the models this toolkit prices) before setting it.
493
+ */
494
+ export async function agentsTry(env, cat, body) {
495
+ const bad = (message) => ({ ok: false, error: 'bad-request', message });
496
+ const meta = (cat.games ?? []).find((g) => g.id === body.game);
497
+ if (!meta) return bad('game is one of this studio\'s game ids');
498
+ const mode = body.mode ?? 'workers-ai';
499
+ if (!['workers-ai', 'owner-key', 'script'].includes(mode)) return bad('mode is workers-ai, owner-key or script');
500
+ if (body.model !== undefined && !(typeof body.model === 'string' && NEURONS_PER_M[body.model])) return bad(`model is one of ${Object.keys(NEURONS_PER_M).join(', ')}`);
501
+ const view = body.view && typeof body.view === 'object' && !Array.isArray(body.view) ? body.view : null;
502
+ if (!view || JSON.stringify(view).length > 2048) return bad('view is the game state a guide sees (an object, under 2 KB)');
503
+ const int = (v) => Number.isInteger(v) && v >= 0 && v < 64;
504
+ const asks = (Array.isArray(body.asks) ? body.asks : []).slice(0, 3).filter((a) => a && typeof a.k === 'string' && int(a.from)).map((a, i) => ({ k: a.k, args: a.args && typeof a.args === 'object' ? a.args : {}, from: a.from, at: Date.now() - i }));
505
+ const party = (Array.isArray(body.party) ? body.party : []).slice(-3).filter((p) => p && int(p.seat) && typeof p.line === 'string').map((p) => ({ seat: p.seat, line: p.line, args: p.args ?? {} }));
506
+ const seats = (list) => (Array.isArray(list) ? list.filter(int).slice(0, 32) : []);
507
+ let vocab = null;
508
+ try {
509
+ const res = env.ASSETS ? await env.ASSETS.fetch(new Request(`https://assets.local/games/${meta.id}/agents.json`)) : null;
510
+ if (res?.ok) vocab = vocabularyOf(await res.json()).vocab;
511
+ } catch { vocab = null; }
512
+ if (!vocab) return { ok: false, error: 'no-vocabulary', message: `${meta.name} has no agents.json in this build: its guides have nothing to choose from.` };
513
+ const day = await brainDay(env);
514
+ const budget = { left: (k) => (k === 'neurons' ? day.budget.neurons - day.used.neurons : day.budget.usd * 1e6 - day.used.usd * 1e6) };
515
+ const e = body.model ? { ...env, HOMIE_BRAIN_MODEL: body.model } : env;
516
+ const brain = brainFor(mode, e, { budget });
517
+ const ctx = {
518
+ view, me: { seat: int(body.seat) ? body.seat : 7 }, goal: view.goal ?? null, asks, party, speech: ['game', 'lines', 'off'].includes(body.speech) ? body.speech : 'lines',
519
+ kids: body.kids === true, quiet: body.quiet === true, players: seats(body.players ?? (Array.isArray(view.party) ? view.party.map((p) => p?.seat) : [])), avoid: seats(body.avoid),
520
+ // What a house guide remembers: who is new to it, and its own last lines (ids, arguments, how long ago).
521
+ newHere: seats(body.newHere),
522
+ said: (Array.isArray(body.said) ? body.said : []).slice(-4).filter((x) => x && typeof x.line === 'string' && vocab.lines[x.line]).map((x) => ({ line: x.line, args: x.args && typeof x.args === 'object' ? x.args : {}, ago: Math.max(0, Number(x.agoMs) || 0) })),
523
+ };
524
+ const t = await think(vocab, { mode, brain, ctx });
525
+ if (t.engine && env.DB) {
526
+ const rows = [counter(env, { metric: 'brain-calls', subject: meta.id, source: 'try' })];
527
+ const n = Math.round(t.cost.neurons);
528
+ if (n > 0) rows.push(counter(env, { metric: 'brain-neurons', subject: meta.id, n }));
529
+ if (t.cost.micros >= 1) rows.push(counter(env, { metric: 'brain-microdollars', subject: meta.id, n: Math.round(t.cost.micros) }));
530
+ await env.DB.batch(rows.filter(Boolean)).catch(() => {});
531
+ }
532
+ return {
533
+ ok: true, game: meta.id, mode, model: t.model ?? (mode === 'workers-ai' ? e.HOMIE_BRAIN_MODEL || DEFAULT_MODEL : null), engine: t.engine, provider: t.provider,
534
+ decision: t.decision, own: t.own, overruled: /\+floor$/.test(t.provider), why: t.why, p: t.p, ms: t.ms,
535
+ // A decision model's whole answer: every option's probability, per question (ids and numbers only).
536
+ ...(t.answers ? { answers: Object.fromEntries(Object.entries(t.answers).map(([k, a]) => [k, a?.probabilities ? Object.fromEntries(Object.entries(a.probabilities).map(([o, v]) => [o, Math.round(Number(v) * 100) / 100])) : a?.noul ?? a?.score ?? null])) } : {}),
537
+ ...(t.usage ? { tokens: { input: Number(t.usage.input_tokens ?? t.usage.prompt_tokens) || null, output: Number(t.usage.output_tokens ?? t.usage.completion_tokens) || 0 } } : {}),
538
+ neurons: Math.round(t.cost.neurons * 1000) / 1000, micros: Math.round(t.cost.micros),
539
+ budget: { neurons: day.budget.neurons, usedBefore: Math.round(day.used.neurons) },
540
+ };
541
+ }
542
+
485
543
  /** The AI brains' day for the whole studio: the budget (meta brain_budget) and what today used (stats_daily). */
486
544
  export async function brainDay(env) {
487
545
  const budget = { neurons: BRAIN_BUDGET.neurons, usd: BRAIN_BUDGET.usd };
@@ -1163,6 +1221,8 @@ async function api(request, env, url, cat) {
1163
1221
  return json({ ok: true, game, server, members });
1164
1222
  } catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
1165
1223
  }
1224
+ // A guide's brain on a moment the owner gives (0.24.4): what it would decide, why, how sure, how long and what it cost.
1225
+ if (path === '/_studio/api/agents/try' && request.method === 'POST') return json(await agentsTry(env, cat, body));
1166
1226
  // Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
1167
1227
  if (path === '/_studio/api/chat' && request.method === 'GET') {
1168
1228
  const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
package/worker/room.mjs CHANGED
@@ -44,7 +44,7 @@
44
44
  * =============================================================================
45
45
  */
46
46
  import { aiName, skillPreset, stripAi } from './agents.mjs';
47
- import { checkArgs, talks, vocabularyOf } from './brain.mjs';
47
+ import { DECIDE, checkArgs, checkDecide, talks, vocabularyOf } from './brain.mjs';
48
48
  import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat } from './chat.mjs';
49
49
 
50
50
  export const NET_VERSION = 1;
@@ -103,6 +103,8 @@ export const LIMITS = Object.freeze({
103
103
  hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
104
104
  // Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
105
105
  say: 1536, react: 256,
106
+ // A game's own decision (section 20): a few typed questions about its state.
107
+ decide: DECIDE.bytes,
106
108
  });
107
109
  /**
108
110
  * What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
@@ -112,7 +114,7 @@ export const LIMITS = Object.freeze({
112
114
  export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
113
115
  export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
114
116
  /** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
115
- export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10 });
117
+ export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10, decide: 2 });
116
118
  /** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
117
119
  export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
118
120
  /** The keyed state channel: at most this many keys and bytes per room. */
@@ -274,7 +276,7 @@ export class NetRoom {
274
276
  this.stats = {
275
277
  snaps: 0, ins: 0, evs: 0, ckpts: 0, states: 0, rounds: 0, drops: 0, oversize: 0, stFixed: 0, reclaimed: 0, seated: 0,
276
278
  kicked: 0, refused: 0, persists: 0, restoredFrom: null, bytesIn: 0, bytesOut: 0, elections: [], promotions: 0,
277
- speechDrops: 0, agentDrops: 0, labelled: 0, votes: 0,
279
+ speechDrops: 0, agentDrops: 0, labelled: 0, votes: 0, decides: 0, decided: {}, decideMs: [],
278
280
  };
279
281
  this.snapTimes = [];
280
282
  /** The owner's controls (section 15): who is held out of this room until when, who is muted, the banner, a closed door. */
@@ -299,6 +301,13 @@ export class NetRoom {
299
301
  this.chatReacts = [];
300
302
  this.chatPending = 0;
301
303
  this.review = null;
304
+ /**
305
+ * A game's own decisions (section 20): the host's typed questions, answered by the studio's decision model (the Table
306
+ * sets `decider`; null: the host's floor answers), at most one every 3 s and 20 a minute a room, two at a time.
307
+ */
308
+ this.decider = null;
309
+ this.decideTimes = [];
310
+ this.decidePending = 0;
302
311
  this.watchIds = new WeakMap();
303
312
  this.defaultChat = null;
304
313
  }
@@ -403,6 +412,39 @@ export class NetRoom {
403
412
  peers() { return this.live().map((c) => this.peer(c)); }
404
413
  stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
405
414
 
415
+ /**
416
+ * A game's own decision (section 20): the host's typed questions about its state, answered by the studio's decision
417
+ * model through the Table (`decider`). Only the host asks; the answer (`decided`, option ids and numbers only) goes
418
+ * back to that socket alone. Off, paced, over budget, too slow or wrong: `ok: false` with why, and the host's own floor
419
+ * answers. `n` is the host's own id for the request.
420
+ */
421
+ onDecide(c, m, isHost) {
422
+ const n = typeof m.n === 'string' || Number.isInteger(m.n) ? String(m.n).slice(0, 16) : '';
423
+ const reply = (o) => this.send(c, { t: 'decided', n, ...o });
424
+ if (!isHost || this.lite(c)) return reply({ ok: false, why: 'not-host' });
425
+ if (typeof this.decider !== 'function') return reply({ ok: false, why: 'off' });
426
+ const now = this.now();
427
+ while (this.decideTimes.length && this.decideTimes[0] <= now - 60_000) this.decideTimes.shift();
428
+ const last = this.decideTimes.at(-1) ?? -Infinity;
429
+ if (now - last < DECIDE.gapMs - 100 || this.decideTimes.length >= DECIDE.perMinute) return reply({ ok: false, why: 'pace', retryMs: Math.max(DECIDE.gapMs - (now - last), this.decideTimes.length >= DECIDE.perMinute ? this.decideTimes[0] + 60_000 - now : 0) });
430
+ if (this.decidePending >= DECIDE.inFlight) return reply({ ok: false, why: 'busy' });
431
+ const q = checkDecide(m.state, m.questions);
432
+ if (!q.ok) return reply({ ok: false, why: 'bad', message: q.why });
433
+ this.decideTimes.push(now);
434
+ this.decidePending += 1;
435
+ this.stats.decides += 1;
436
+ Promise.resolve()
437
+ .then(() => this.decider(q.state, q.questions, { room: this.code, kids: this.policy.kids }))
438
+ .then((r) => {
439
+ const by = r?.ok ? r.by ?? 'ai' : r?.why ?? 'error';
440
+ this.stats.decided[by] = (this.stats.decided[by] ?? 0) + 1;
441
+ if (r?.ok) { this.stats.decideMs.push(r.ms ?? 0); if (this.stats.decideMs.length > 40) this.stats.decideMs.shift(); this.stats.decideNeurons = (this.stats.decideNeurons ?? 0) + (Number(r.neurons) || 0); }
442
+ reply(r?.ok ? { ok: true, by: r.by ?? 'ai', picks: r.picks ?? {}, p: r.p ?? {}, ms: r.ms ?? null } : { ok: false, why: r?.why ?? 'error' });
443
+ })
444
+ .catch(() => { this.stats.decided.error = (this.stats.decided.error ?? 0) + 1; reply({ ok: false, why: 'error' }); })
445
+ .finally(() => { this.decidePending -= 1; });
446
+ }
447
+
406
448
  /** Drop the socket from the room and close it. */
407
449
  kick(c, why, code = 1008) {
408
450
  this.stats.kicked += 1;
@@ -515,6 +557,7 @@ export class NetRoom {
515
557
  }
516
558
  return;
517
559
  }
560
+ case 'decide': return this.onDecide(c, m, isHost);
518
561
  case 'ckpt': {
519
562
  if (!isHost) return;
520
563
  this.lastCkpt = { k: Number(m.k) || 0, st: this.stamp(m.st), d: m.d ?? null, ...(Array.isArray(m.c) ? { c: m.c.slice(0, 64) } : {}) };
@@ -1821,6 +1864,8 @@ export class NetRoom {
1821
1864
  pending: this.chatPending,
1822
1865
  lines: this.recentChat(now).map((r) => ({ ...this.wireLine(r), kind: r.kind, client: r.from?.client ?? null, player: r.from?.player ?? null, browser: browserTag(r.from?.browser), ...(r.review ? { review: r.review } : {}) })),
1823
1866
  },
1867
+ // A game's own decisions (section 20): how many, who answered, the model's time and neurons (never a state).
1868
+ ...(this.stats.decides ? { decides: { n: this.stats.decides, by: { ...this.stats.decided }, msP50: [...this.stats.decideMs].sort((x, y) => x - y)[Math.floor(this.stats.decideMs.length / 2)] ?? null, neurons: Math.round((this.stats.decideNeurons ?? 0) * 100) / 100 } } : {}),
1824
1869
  },
1825
1870
  };
1826
1871
  }
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.24.3';
2
+ export const STUDIO_VERSION_TAG = '0.24.4';