@homie-rocks/studio 0.24.3 → 0.24.5

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/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.5';