@homie-rocks/studio 0.15.0 → 0.16.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/worker/room.mjs CHANGED
@@ -21,10 +21,58 @@
21
21
  * (`hello.watch`, or a socket the Worker opened through a game's watch door,
22
22
  * `conn.watch`) never takes a seat, hosts only when no player can, and is told
23
23
  * whether it may follow one player's view (`welcome.watch`, then `watch` frames).
24
+ *
25
+ * SERVERS AND AGENT SEATS (revision 6, NETPLAY.md section 17): the Worker hands
26
+ * every socket the room's policy (`setPolicy`) and, for an AI with a verified
27
+ * pass, its facts (`conn.agent`). The relay keeps the seats a hybrid server
28
+ * reserves for AI, names every agent "<label> · AI", rewrites the host's roster
29
+ * and results so they cannot hide an AI, filters speech, runs the party's vote on
30
+ * the skill dial, and closes agents that are left alone with no person seated.
24
31
  * =============================================================================
25
32
  */
33
+ import { aiName, skillPreset, stripAi } from './agents.mjs';
26
34
 
27
35
  export const NET_VERSION = 1;
36
+ /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
37
+ export const NET_REVISION = 6;
38
+ const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
39
+ /**
40
+ * A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
41
+ * NETPLAY.md section 17: `server` null is a named room; `aiSeats + guides` seats are kept for AI (the top seats).
42
+ */
43
+ export const DEFAULT_POLICY = Object.freeze({ v: 1, at: 0, server: null, kind: 'open', aiSeats: 0, guides: 0, bots: 'fill', level: 3, levelMax: 5, speech: 'game', kids: false, brain: 'script' });
44
+ /** An agent's speech: at most one line every 4 s and 8 a minute (the relay drops the rest and counts them). */
45
+ export const AGENT_SPEECH = Object.freeze({ gapMs: 4000, perMinute: 8 });
46
+ /** The party's vote on the dial: open 15 s (or until every seated person voted); one opened by a player per 2 min. */
47
+ export const VOTE = Object.freeze({ ms: 15_000, cooldownMs: 120_000, autoMs: 30 * 60_000 });
48
+ /** Agents left with no person seated for this long are closed (`agents-alone`): an AI never keeps a room alive. */
49
+ export const AGENTS_ALONE_MS = 60_000;
50
+ const CAPS = ['skill', 'agents'];
51
+
52
+ /** A policy as the relay keeps it: every field checked (the Worker composed it, but a bad one must not break a room). */
53
+ export function normalizePolicy(p) {
54
+ if (!p || typeof p !== 'object') return null;
55
+ const int = (v, lo, hi, d) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; };
56
+ const kind = POLICY_KINDS.includes(p.kind) ? p.kind : 'open';
57
+ const kids = p.kids === true;
58
+ const levelMax = int(p.levelMax, 1, kids ? 3 : 5, kids ? 3 : 5);
59
+ const server = p.server && typeof p.server === 'object' && /^[a-z0-9][a-z0-9-]{0,19}$/.test(String(p.server.id ?? ''))
60
+ ? { id: String(p.server.id), name: String(p.server.name ?? p.server.id).replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 40) } : null;
61
+ const reserveOk = kind === 'hybrid' || kind === 'beginner';
62
+ return {
63
+ v: 1, at: Math.max(0, Number(p.at) || 0), server, kind,
64
+ aiSeats: reserveOk ? int(p.aiSeats, 0, 31, 0) : 0, guides: kind === 'beginner' ? int(p.guides, 0, 31, 0) : 0,
65
+ bots: p.bots === 'off' ? 'off' : 'fill', level: Math.min(levelMax, int(p.level, 1, 5, 3)), levelMax,
66
+ speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game', kids,
67
+ brain: kind === 'humans-only' ? 'off' : ['off', 'script', 'workers-ai', 'owner-key'].includes(p.brain) ? p.brain : 'script',
68
+ };
69
+ }
70
+ const policySig = (p) => JSON.stringify({ ...p, at: 0 });
71
+ /** The lower middle of the votes (DESIGN D8: a tie is settled kindly), or null with none. */
72
+ export function medianVote(list) {
73
+ const v = [...list].filter(Number.isFinite).sort((a, b) => a - b);
74
+ return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
75
+ }
28
76
  const PALETTE_SIZE = 12;
29
77
  const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
30
78
 
@@ -144,7 +192,23 @@ export class NetRoom {
144
192
  constructor({
145
193
  code, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
146
194
  holdMs = 60_000, forgetMs = 60_000, persistMs = 30_000, store = null, now = Date.now, log = () => {},
195
+ agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
147
196
  }) {
197
+ this.agentsAloneMs = agentsAloneMs;
198
+ this.voteMs = voteMs;
199
+ this.voteCooldownMs = voteCooldownMs;
200
+ this.autoVoteMs = autoVoteMs;
201
+ /** Servers and agent seats (section 17): the Worker's policy, the party's dial, what the host's game reads. */
202
+ this.policy = { ...DEFAULT_POLICY };
203
+ this.level = null;
204
+ this.levelBy = null;
205
+ this.caps = new Set();
206
+ this.vote = null;
207
+ this.lastVoteOpen = -Infinity;
208
+ this.lastAutoVote = -Infinity;
209
+ this.lastHumanAt = 0;
210
+ /** A policy that no longer lets AI in, waiting for the round to finish: when the agents leave. */
211
+ this.agentsOut = null;
148
212
  this.code = code;
149
213
  this.seatCap = maxPlayers;
150
214
  this.maxPlayers = maxPlayers;
@@ -184,6 +248,7 @@ export class NetRoom {
184
248
  this.stats = {
185
249
  snaps: 0, ins: 0, evs: 0, ckpts: 0, states: 0, rounds: 0, drops: 0, oversize: 0, stFixed: 0, reclaimed: 0, seated: 0,
186
250
  kicked: 0, refused: 0, persists: 0, restoredFrom: null, bytesIn: 0, bytesOut: 0, elections: [], promotions: 0,
251
+ speechDrops: 0, agentDrops: 0, labelled: 0, votes: 0,
187
252
  };
188
253
  this.snapTimes = [];
189
254
  /** The owner's controls (section 15): who is held out of this room until when, who is muted, the banner, a closed door. */
@@ -210,6 +275,8 @@ export class NetRoom {
210
275
  rates: new Map(), drops: [], errAt: 0,
211
276
  // A watcher (section 16): the Worker's word for a socket opened through a watch door, else the hello's own.
212
277
  watch: conn.watch === true, policy: WATCH_POLICIES.includes(conn.watchPolicy) ? conn.watchPolicy : 'follow', follow: null, followWhy: null,
278
+ // An agent (section 17): only the Worker's word, from a verified pass, makes one; its hello cannot.
279
+ agentWord: conn.agent && typeof conn.agent === 'object' ? conn.agent : null, agent: null, caps: new Set(), speechAt: [],
213
280
  };
214
281
  this.clients.set(c.id, c);
215
282
  return {
@@ -219,13 +286,32 @@ export class NetRoom {
219
286
  };
220
287
  }
221
288
 
289
+ /**
290
+ * The shell's watch socket: the room's facts, and (revision 6) the party's vote from the play page's card. A vote
291
+ * from it counts as the seat of the client in this room with the same browser key (`conn.browser`).
292
+ */
222
293
  watch(conn) {
223
294
  this.watchers.add(conn);
224
295
  this.sendText(conn, JSON.stringify(this.facts()));
225
296
  // A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
226
297
  const held = this.heldNotice(conn);
227
298
  if (held) this.sendText(conn, JSON.stringify(held));
228
- return { onClose: () => this.watchers.delete(conn) };
299
+ const rate = [];
300
+ return {
301
+ onClose: () => this.watchers.delete(conn),
302
+ onMessage: (text) => {
303
+ const now = this.now();
304
+ while (rate.length && rate[0] <= now - 1000) rate.shift();
305
+ if (rate.length >= 4 || String(text).length > 256) return;
306
+ rate.push(now);
307
+ let m = null;
308
+ try { m = JSON.parse(String(text)); } catch { return; }
309
+ if (!m || m.t !== 'vote' || !conn.browser) return;
310
+ const c = this.live().find((o) => o.browser === conn.browser && o.seat !== null && !o.agent && !o.watch);
311
+ if (c) this.onVote(c, m, conn);
312
+ else this.sendText(conn, JSON.stringify({ t: 'error', code: 'vote', message: 'only a seated player votes' }));
313
+ },
314
+ };
229
315
  }
230
316
 
231
317
  /* ------------------------------------------------------------ wire */
@@ -240,7 +326,29 @@ export class NetRoom {
240
326
  host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
241
327
  hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
242
328
  roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
243
- peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(this.muteOf(c) ? { muted: true } : {}) }; }
329
+ peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}) }; }
330
+ /** A hands-`host` agent (section 17): no game client of its own, so no snapshots, checkpoints or state. */
331
+ lite(c) { return Boolean(c.agent && c.agent.hands === 'host'); }
332
+ seatedHumans() { return this.live().filter((c) => c.seat !== null && !c.agent); }
333
+ /** Seats kept for AI: the top `aiSeats + guides` (DESIGN D6), never every seat (one is always a person's). */
334
+ reserve() {
335
+ const p = this.policy;
336
+ const n = p.kind === 'hybrid' || p.kind === 'beginner' ? p.aiSeats + p.guides : 0;
337
+ return Math.max(0, Math.min(n, this.maxPlayers - 1));
338
+ }
339
+ humanCap() { return Math.max(1, this.maxPlayers - this.reserve()); }
340
+ agentsAllowed() { return this.policy.kind !== 'humans-only'; }
341
+ /** The room's dial now: the party's vote (or the owner's), else the server's level; a kids room caps it. */
342
+ levelNow() { return Math.max(1, Math.min(this.policy.levelMax, this.level ?? this.policy.level)); }
343
+ policyOut() {
344
+ const level = this.levelNow();
345
+ return { ...this.policy, skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
346
+ }
347
+ /** Send to every client in the room (a lite agent too: it hears join/leave, roster, round, policy and votes). */
348
+ broadcast(msg) {
349
+ const text = JSON.stringify(msg);
350
+ for (const o of this.live()) this.sendText(o.conn, text);
351
+ }
244
352
  peers() { return this.live().map((c) => this.peer(c)); }
245
353
  stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
246
354
 
@@ -298,6 +406,7 @@ export class NetRoom {
298
406
  const out = JSON.stringify({ t: 'snap', from: c.seat, ...snap });
299
407
  this.lastSnapText = out;
300
408
  for (const o of this.others(c)) {
409
+ if (this.lite(o)) continue; // an agent with no game client draws nothing
301
410
  // A congested socket skips a snapshot rather than queueing a stale one.
302
411
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
303
412
  this.sendText(o.conn, out);
@@ -305,7 +414,7 @@ export class NetRoom {
305
414
  return;
306
415
  }
307
416
  case 'in': {
308
- if (isHost || c.seat === null) return;
417
+ if (isHost || c.seat === null || this.lite(c)) return;
309
418
  const h = this.host();
310
419
  if (!h) return;
311
420
  this.stats.ins += 1;
@@ -320,12 +429,22 @@ export class NetRoom {
320
429
  const kind = String(m.k ?? '').slice(0, 64);
321
430
  // A muted player's speech goes nowhere (a host relays everyone's, so only a non-host's own is dropped).
322
431
  if (!isHost && SPEECH.test(kind) && this.muteOf(c)) { this.stats.mutedDrops = (this.stats.mutedDrops ?? 0) + 1; return; }
432
+ // The server's speech (section 17): `lines` drops every free-text chat (quick lines and emotes pass), `off`
433
+ // drops all speech, whoever sends it (a host relaying a player's chat too).
434
+ if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
435
+ // An agent says at most one line every 4 s and 8 a minute.
436
+ if (c.agent && SPEECH.test(kind)) {
437
+ while (c.speechAt.length && c.speechAt[0] <= now - 60_000) c.speechAt.shift();
438
+ if (c.speechAt.length >= AGENT_SPEECH.perMinute || (c.speechAt.length && now - c.speechAt[c.speechAt.length - 1] < AGENT_SPEECH.gapMs)) { this.stats.agentDrops += 1; return; }
439
+ c.speechAt.push(now);
440
+ }
323
441
  this.stats.evs += 1;
324
442
  const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
325
443
  if (isHost) {
326
444
  if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && o !== c) this.send(o, out); }
327
445
  else if (Number.isInteger(m.to)) { for (const o of this.others(c)) if (o.seat === m.to) this.send(o, out); }
328
- else for (const o of this.others(c)) this.send(o, out);
446
+ // An agent with no game client hears only what is addressed to its seat (the lite feed).
447
+ else for (const o of this.others(c)) { if (!this.lite(o)) this.send(o, out); }
329
448
  } else {
330
449
  const h = this.host();
331
450
  if (h) this.send(h, { ...out, id: c.id });
@@ -360,28 +479,48 @@ export class NetRoom {
360
479
  this.stats.states += 1;
361
480
  this.persistDirty = true;
362
481
  const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
363
- for (const o of this.others(c)) this.sendText(o.conn, out);
482
+ for (const o of this.others(c)) if (!this.lite(o)) this.sendText(o.conn, out);
364
483
  return;
365
484
  }
366
485
  case 'round': {
367
486
  if (!isHost || !m.round || typeof m.round !== 'object') return;
368
- this.lastRound = m.round;
487
+ // The results name every AI as AI, whatever the host's game says (section 17).
488
+ const round = Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
489
+ const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
490
+ this.lastRound = round;
369
491
  // The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
370
- if (this.regate && m.round.phase === 'over' && (this.regate.roundN === null || Number(m.round.n) >= this.regate.roundN)) this.regate.until = Math.min(this.regate.until, this.now() + 5000);
492
+ if (this.regate && round.phase === 'over' && (this.regate.roundN === null || Number(round.n) >= this.regate.roundN)) this.regate.until = Math.min(this.regate.until, this.now() + 5000);
493
+ if (this.agentsOut && round.phase === 'over' && (this.agentsOut.roundN === null || Number(round.n) >= this.agentsOut.roundN)) this.agentsOut.until = Math.min(this.agentsOut.until, this.now() + 5000);
371
494
  this.stats.rounds += 1;
372
495
  this.persistDirty = true;
373
- for (const o of this.others(c)) this.send(o, { t: 'round', round: m.round });
496
+ for (const o of this.others(c)) this.send(o, { t: 'round', round });
497
+ // A party's first live round with AI seats: the vote on how strong the AI should be (once per 30 minutes).
498
+ if (round.phase === 'live' && wasLive !== Number(round.n)) this.autoVote();
374
499
  this.tellWatchers();
375
500
  return;
376
501
  }
377
502
  case 'roster': {
378
503
  if (!isHost || !Array.isArray(m.slots)) return;
379
- this.lastRoster = m.slots.slice(0, 64);
504
+ // A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
505
+ this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
380
506
  this.persistDirty = true;
381
507
  for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
382
508
  this.tellWatchers();
383
509
  return;
384
510
  }
511
+ case 'caps': {
512
+ // The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
513
+ if (!isHost || !Array.isArray(m.caps)) return;
514
+ for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
515
+ this.caps = new Set(c.caps);
516
+ this.persistDirty = true;
517
+ this.tellWatchers();
518
+ return;
519
+ }
520
+ case 'vote': {
521
+ this.onVote(c, m);
522
+ return;
523
+ }
385
524
  case 'ping': {
386
525
  const wasHidden = c.hidden;
387
526
  c.hidden = m.hid === true;
@@ -435,28 +574,48 @@ export class NetRoom {
435
574
  const ban = this.banOf(c, typeof m.token === 'string' ? m.token : '');
436
575
  if (ban) return refuse('kicked', ban.message, { until: ban.until });
437
576
  // A watcher of a game that cannot be watched (game.json "watch": false) is refused for good (section 16).
438
- const watching = c.watch || m.watch === true;
577
+ const watching = !c.agentWord && (c.watch || m.watch === true);
439
578
  if (watching && c.policy === 'off') return refuse('watch-off', 'this game cannot be watched; play it instead');
579
+ // An AI sits only with a pass the Worker verified (section 17): a hello that says it is one, with none, is refused.
580
+ if (m.agent && typeof m.agent === 'object' && !c.agentWord) return refuse('agent-pass', 'an AI plays here with an agent pass from the studio');
581
+ const now = this.now();
582
+ if (c.agentWord) {
583
+ const a = c.agentWord;
584
+ if (!this.agentsAllowed()) return refuse('agents-off', 'This server is for humans only');
585
+ // A lite agent (hands `host`) needs a host whose game moves AI bodies (it declared `caps: ['agents']`).
586
+ const hostCaps = this.host() ? this.host().caps : this.caps;
587
+ if (a.hands === 'host' && !hostCaps.has('agents')) return refuse('agents-unsupported', 'this room\'s game cannot move an AI\'s body (its build predates agent seats); an AI that runs the game itself can still sit');
588
+ // An AI never keeps a room alive (DESIGN D9): with no person seated for a while, it may not come in.
589
+ if (!this.seatedHumans().length && now - this.lastHumanAt >= this.agentsAloneMs) return refuse('agents-alone', 'nobody is playing in this room; an AI comes back when a person is seated', { final: false });
590
+ c.agent = { pass: String(a.pass ?? '').slice(0, 16), role: ['party', 'guide', 'player'].includes(a.role) ? a.role : 'party', hands: a.hands === 'host' ? 'host' : 'self', by: ['studio', 'guest', 'service'].includes(a.by) ? a.by : 'studio' };
591
+ c.agentName = aiName(a.name ?? a.label ?? 'Agent');
592
+ }
440
593
  if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
441
594
  if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
442
- const now = this.now();
443
595
  c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
444
- // A watcher is a screen that never takes a seat, whatever else its hello says.
596
+ // A watcher is a screen that never takes a seat, whatever else its hello says. An agent always plays.
445
597
  c.watch = watching;
446
- c.want = watching || m.want === 'screen' ? 'screen' : 'play';
447
- c.canHost = m.canHost !== false;
598
+ c.want = c.agent ? 'play' : watching || m.want === 'screen' ? 'screen' : 'play';
599
+ // An agent with no game client never hosts; one that runs the game hosts only a room no person can (DESIGN D10).
600
+ c.canHost = c.agent ? c.agent.hands === 'self' && m.canHost !== false : m.canHost !== false;
448
601
  c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
449
- c.typed = typeof m.name === 'string' ? m.name.replace(/\s+/g, ' ').trim().slice(0, 24) : '';
602
+ // A person's typed name never ends in an AI or bot mark; on a kids server nobody's typed name is used (handles).
603
+ c.typed = c.agent || this.policy.kids ? '' : typeof m.name === 'string' ? stripAi(m.name.replace(/\s+/g, ' ').trim()).slice(0, 24) : '';
604
+ if (Array.isArray(m.caps)) for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
450
605
  // The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
451
606
  if (Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
452
607
  this.reapSeats(now);
453
608
  let full = false;
454
- if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) { full = true; c.waiting = true; }
609
+ if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) {
610
+ if (c.agent) return refuse('agents-off', 'no AI seat is free in this room');
611
+ full = true; c.waiting = true;
612
+ }
455
613
  if (c.seat === null) c.name = c.typed || (c.want === 'screen' && !c.watch ? 'Screen' : 'Watcher');
614
+ if (c.seat !== null && !c.agent) this.lastHumanAt = now;
456
615
  c.helloed = true;
457
616
  c.joinedAt = now;
458
617
  if (!this.openedAt) this.openedAt = now;
459
- this.emptySince = 0;
618
+ if (!c.agent) this.emptySince = 0;
460
619
  this.lastJoinAt = now;
461
620
 
462
621
  let role = c.seat === null ? 'screen' : 'replica';
@@ -464,18 +623,24 @@ export class NetRoom {
464
623
  let deposed = null;
465
624
  const h = this.host();
466
625
  const preferred = this.preferHost && now < this.preferHost.until ? this.preferHost.seat : undefined;
467
- // A watcher hosts only a room nobody else can host (a player who can is elected first), and never deposes one.
468
- const lastResort = c.watch && live.some((o) => o.canHost && !o.watch && !o.hidden);
626
+ // A watcher hosts only a room nobody else can host (a player who can is elected first), and never deposes one;
627
+ // an agent hosts only a room no person can host.
628
+ const lastResort = (c.watch && live.some((o) => o.canHost && !o.watch && !o.agent && !o.hidden)) || (c.agent && live.some((o) => o.canHost && !o.agent && !o.hidden));
469
629
  if (c.canHost && !lastResort && (preferred === undefined || preferred === c.seat)) {
470
630
  if (!h) {
471
631
  role = 'host';
472
632
  why = this.lastSnap || this.lastCkpt ? 'resumed' : 'first';
473
633
  if (preferred !== undefined) this.preferHost = null;
474
- } else if (!c.watch && (h.hidden || now - h.lastSeen > this.silentMs)) {
634
+ } else if (!c.watch && !c.agent && (h.hidden || now - h.lastSeen > this.silentMs)) {
475
635
  // The host is a frozen phone (socket open, nothing sent): the newcomer runs the round now, not after a stall.
476
636
  deposed = h;
477
637
  role = 'host';
478
638
  why = h.hidden ? 'host-hidden' : 'host-stalled';
639
+ } else if (!c.watch && !c.agent && h.agent) {
640
+ // A person arrives in a room an AI was hosting: the rules go back to a person's browser (DESIGN D10).
641
+ deposed = h;
642
+ role = 'host';
643
+ why = 'host-person';
479
644
  }
480
645
  }
481
646
  if (role === 'host') {
@@ -483,17 +648,22 @@ export class NetRoom {
483
648
  this.hostSince = now;
484
649
  c.lastSnapAt = 0;
485
650
  this.seatsDirty = true;
651
+ this.caps = new Set(c.caps);
486
652
  this.stats.elections.push({ at: now, id: c.id, seat: c.seat, why });
487
653
  if (this.stats.elections.length > 32) this.stats.elections.shift();
488
654
  }
655
+ const lite = this.lite(c);
489
656
  this.send(c, {
490
- t: 'welcome', v: NET_VERSION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
657
+ t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
491
658
  role, why, host: this.hostRef(), peers: this.peers(), st: now, max: this.maxPlayers,
492
- round: this.lastRound, roster: this.lastRoster, snap: this.lastSnap, state: this.stateObject(),
659
+ round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.lastSnap, state: lite ? {} : this.stateObject(),
493
660
  ...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
494
661
  ...(full ? { full: true } : {}),
495
662
  ...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
496
663
  ...(c.watch ? { watch: this.followFacts(c) } : {}),
664
+ policy: this.policyOut(),
665
+ ...(this.vote ? { vote: this.voteView() } : {}),
666
+ ...(c.agent ? { agent: { ...c.agent, name: c.name } } : {}),
497
667
  });
498
668
  for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
499
669
  // A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
@@ -502,14 +672,16 @@ export class NetRoom {
502
672
  this.send(deposed, { t: 'role', role: deposed.seat === null ? 'screen' : 'replica', why, host: this.hostRef(), peers: this.peers() });
503
673
  for (const o of this.others(c)) if (o !== deposed) this.send(o, { t: 'host', host: this.hostRef(), why });
504
674
  }
505
- this.log({ ev: 'hello', room: this.code, id: c.id, seat: c.seat, device: c.device, want: c.want, role, why, full, resumed: typeof m.token === 'string' && m.token === c.token, ...(c.watch ? { watch: true } : {}) });
675
+ // A roster from before an agent sat is labelled again now (its seat is an AI's).
676
+ if (c.agent && Array.isArray(this.lastRoster)) this.lastRoster = this.labelRoster(this.lastRoster);
677
+ this.log({ ev: 'hello', room: this.code, id: c.id, seat: c.seat, device: c.device, want: c.want, role, why, full, resumed: typeof m.token === 'string' && m.token === c.token, ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: c.agent.hands } : {}) });
506
678
  this.persist(now); // a new seat (or a new host) is written now, not on the next tick: a deploy can come any moment
507
679
  this.tellWatchers();
508
680
  }
509
681
 
510
682
  /** Give `c` a seat: its token's seat, a free one, or the seat absent longest. False when every seat is present. */
511
683
  seatClient(c, token) {
512
- const seat = this.seatFor(token);
684
+ const seat = this.seatFor(token, c.agent ? 'agent' : 'human');
513
685
  if (seat === null) return false;
514
686
  const s = this.seats.get(seat);
515
687
  // Same seat open twice (two tabs, or a reload racing its own close): the newer socket wins.
@@ -524,17 +696,31 @@ export class NetRoom {
524
696
  c.token = s.token;
525
697
  c.waiting = false;
526
698
  s.present = true;
527
- const name = c.typed || s.name || handleFor(s.token, new Set([...this.seats.values()].map((x) => x.name).filter(Boolean)));
699
+ s.agent = c.agent ? { ...c.agent } : null;
700
+ // An agent is always "<label> · AI" (DESIGN D7); a person keeps their seat's name, else gets a handle.
701
+ const name = c.agent ? c.agentName : c.typed || (s.name && !s.wasAgent ? s.name : '') || handleFor(s.token, new Set([...this.seats.values()].map((x) => x.name).filter(Boolean)));
702
+ s.wasAgent = Boolean(c.agent);
528
703
  if (name !== s.name) { s.name = name; this.seatsDirty = true; }
529
704
  c.name = name;
530
705
  c.colour = seat % PALETTE_SIZE;
531
706
  return true;
532
707
  }
533
708
 
534
- seatFor(token) {
535
- if (token) for (const [seat, s] of this.seats) if (s.token === token) return seat;
536
- const fresh = () => ({ token: randomId(18), name: '', since: this.now(), present: true });
537
- for (let seat = 0; seat < this.maxPlayers; seat += 1) {
709
+ /**
710
+ * A seat for a token (its own seat, whatever the range: a person seated above a new cap keeps it), else a free
711
+ * seat of the right kind: a person's in [0, max - reserve), an agent's in [max - reserve, max) (DESIGN D6; with no
712
+ * reserve, any seat, from the top). Every seat taken: the one absent longest of the same kind.
713
+ */
714
+ seatFor(token, kind = 'human') {
715
+ if (token) for (const [seat, s] of this.seats) if (s.token === token && Boolean(s.agent) === (kind === 'agent')) return seat;
716
+ const fresh = () => ({ token: randomId(18), name: '', since: this.now(), present: true, agent: null });
717
+ const reserve = this.reserve();
718
+ const order = [];
719
+ if (kind === 'agent') {
720
+ const lo = reserve ? this.maxPlayers - reserve : 0;
721
+ for (let seat = this.maxPlayers - 1; seat >= lo; seat -= 1) order.push(seat);
722
+ } else for (let seat = 0; seat < this.humanCap(); seat += 1) order.push(seat);
723
+ for (const seat of order) {
538
724
  if (this.seats.has(seat)) continue;
539
725
  this.seats.set(seat, fresh());
540
726
  this.seatsDirty = true;
@@ -542,7 +728,10 @@ export class NetRoom {
542
728
  }
543
729
  // Every seat is present or held. A visitor who is here beats one who left: reclaim the seat absent longest.
544
730
  let oldest = null;
545
- for (const [seat, s] of this.seats) if (!s.present && seat < this.maxPlayers && (!oldest || s.since < oldest[1].since)) oldest = [seat, s];
731
+ for (const seat of order) {
732
+ const s = this.seats.get(seat);
733
+ if (s && !s.present && Boolean(s.agent) === (kind === 'agent') && (!oldest || s.since < oldest[1].since)) oldest = [seat, s];
734
+ }
546
735
  if (!oldest) return null;
547
736
  this.seats.set(oldest[0], fresh());
548
737
  this.seatsDirty = true;
@@ -613,7 +802,9 @@ export class NetRoom {
613
802
  this.hostId = null;
614
803
  this.elect(null, why === 'replaced' ? 'host-replaced' : why === 'silent' ? 'host-stalled' : 'host-left');
615
804
  }
616
- if (!this.live().length) this.emptySince = now;
805
+ if (c.seat !== null && !c.agent) this.lastHumanAt = now;
806
+ // An agent never keeps a room from forgetting (DESIGN D9): the room is empty when no person is left.
807
+ if (!this.live().some((o) => !o.agent) && !this.emptySince) this.emptySince = now;
617
808
  this.seatWaiting();
618
809
  if (c.seat !== null) this.refreshWatchers();
619
810
  this.tellWatchers();
@@ -632,7 +823,8 @@ export class NetRoom {
632
823
  .filter((c) => c.canHost && c.id !== excludeId)
633
824
  .sort((a, b) => Number(a.hidden) - Number(b.hidden)
634
825
  || silent(a) - silent(b)
635
- // A watcher is the host of last resort: any player who can host comes first (section 16).
826
+ // An agent hosts only when no person can (DESIGN D10); a watcher is next to last: any player comes first.
827
+ || Number(Boolean(a.agent)) - Number(Boolean(b.agent))
636
828
  || Number(a.watch) - Number(b.watch)
637
829
  || tenure(b) - tenure(a)
638
830
  || (DEVICE_RANK[a.device] ?? 3) - (DEVICE_RANK[b.device] ?? 3)
@@ -647,6 +839,8 @@ export class NetRoom {
647
839
  this.hostSince = now;
648
840
  this.preferHost = null;
649
841
  this.seatsDirty = true;
842
+ // What the room's game reads (the dial, agent bodies) is what its new host's build says.
843
+ this.caps = new Set(next.caps);
650
844
  next.lastSnapAt = 0;
651
845
  this.stats.promotions += 1;
652
846
  this.stats.elections.push({ at: now, id: next.id, seat: next.seat, why });
@@ -695,21 +889,235 @@ export class NetRoom {
695
889
  if (this.live().some((c) => c.canHost)) this.elect(null, 'host-left');
696
890
  }
697
891
  if (this.regate && now >= this.regate.until) this.applyRegate(now);
892
+ if (this.agentsOut && now >= this.agentsOut.until) this.applyAgentsOut(now);
893
+ if (this.vote && this.vote.open && now >= this.vote.until) this.closeVote('time');
894
+ // An AI never keeps a room alive (DESIGN D9): with no person seated for agentsAloneMs, every agent is closed.
895
+ if (this.seatedHumans().length) this.lastHumanAt = now;
896
+ else if (now - this.lastHumanAt >= this.agentsAloneMs) {
897
+ for (const c of this.live().filter((o) => o.agent)) {
898
+ this.error(c, 'agents-alone', 'nobody is playing in this room; an AI comes back when a person is seated');
899
+ this.kick(c, 'agents-alone', 4001);
900
+ }
901
+ }
698
902
  this.reapSeats(now);
699
903
  this.seatWaiting();
700
- if (!this.live().length && this.emptySince && now - this.emptySince > this.forgetMs) {
904
+ if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) {
905
+ for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
701
906
  this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
702
907
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
703
908
  this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
909
+ // The party's dial and vote are the room's: a new party starts from the server's level.
910
+ this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
704
911
  this.seatsDirty = false; this.persistDirty = false;
705
912
  try { this.store?.clear?.(); } catch { /* best effort */ }
706
913
  }
707
914
  this.persist(now);
708
915
  }
709
916
 
917
+ /* ------------------------------------------------------------ servers, agents and the dial (section 17) */
918
+
919
+ /**
920
+ * The Worker's policy for this room (the server's, composed with the game and the office). A newer stamp replaces
921
+ * an older one; `force` is the owner's `policy` control. A policy that no longer lets AI in sends the agents out
922
+ * after the round, as a launch change does ("This server is humans-only now.").
923
+ */
924
+ setPolicy(pol, { force = false } = {}) {
925
+ const p = normalizePolicy(pol);
926
+ if (!p) return { ok: false, error: 'policy', message: 'not a policy' };
927
+ const before = this.policy;
928
+ const newer = p.at > before.at || (p.at === before.at && policySig(p) !== policySig(before));
929
+ if (!force && !newer) return { ok: true, same: true };
930
+ if (policySig(p) === policySig(before)) { this.policy = p; return { ok: true, same: true }; }
931
+ this.policy = p;
932
+ if (this.level !== null) this.level = Math.min(this.level, p.levelMax);
933
+ const now = this.now();
934
+ const agents = this.live().filter((c) => c.agent).length;
935
+ if (!this.agentsAllowed() && agents) {
936
+ const r = this.lastRound;
937
+ const playing = Boolean(r && r.phase === 'live' && Number(r.endsAt) > now);
938
+ const until = Math.min(now + 15 * 60_000, playing ? Number(r.endsAt) + 20_000 : now + 30_000);
939
+ this.agentsOut = { roundN: playing ? Number(r.n) : null, until, at: now, message: 'This server is humans-only now.' };
940
+ } else if (this.agentsAllowed()) this.agentsOut = null;
941
+ if (Array.isArray(this.lastRoster)) this.lastRoster = this.labelRoster(this.lastRoster);
942
+ this.seatsDirty = true;
943
+ this.officeDirty = true;
944
+ this.broadcast({ t: 'policy', policy: this.policyOut() });
945
+ this.tellWatchers();
946
+ return { ok: true, policy: this.policyOut(), leaving: this.agentsOut ? agents : 0, ...(this.agentsOut ? { until: this.agentsOut.until } : {}) };
947
+ }
948
+
949
+ /** The agents leave (a policy that no longer lets AI in, after the round): each told why, its seat freed. */
950
+ applyAgentsOut(now = this.now()) {
951
+ const g = this.agentsOut;
952
+ if (!g) return 0;
953
+ this.agentsOut = null;
954
+ this.officeDirty = true;
955
+ const out = this.agentsAllowed() ? [] : this.live().filter((c) => c.agent);
956
+ for (const c of out) {
957
+ const seat = c.seat;
958
+ const token = c.token;
959
+ this.error(c, 'agents-off', g.message);
960
+ this.onClose(c, 'agents-off');
961
+ const s = seat === null ? null : this.seats.get(seat);
962
+ if (s && s.token === token) { this.seats.delete(seat); this.seatsDirty = true; }
963
+ try { c.conn.close(1008, 'agents-off'); } catch { /* gone */ }
964
+ }
965
+ this.persist(now);
966
+ this.tellWatchers();
967
+ return out.length;
968
+ }
969
+
970
+ /** The seat a slot names (its own, or the one its agent sits in), and what holds it. */
971
+ holderOf(seat) {
972
+ if (!Number.isInteger(seat)) return { kind: 'none' };
973
+ const c = this.live().find((o) => o.seat === seat);
974
+ const s = this.seats.get(seat);
975
+ if (c && c.agent) return { kind: 'agent', agent: c.agent, name: c.name };
976
+ if (c) return { kind: 'human', name: c.name };
977
+ if (s && s.agent) return { kind: 'agent', agent: s.agent, name: s.name };
978
+ if (s) return { kind: 'human', name: s.name };
979
+ return { kind: 'none' };
980
+ }
981
+
982
+ /**
983
+ * The host's roster as everyone sees it (section 17): a slot whose seat an agent holds is named and marked AI; a
984
+ * slot with no seat is a bot; a slot naming a seat nobody holds is a bot; a person is never marked AI and never
985
+ * named with an AI mark. A host's game cannot present an AI as a person.
986
+ */
987
+ labelRoster(slots) {
988
+ return slots.map((raw) => this.labelOne(raw, false)).filter(Boolean);
989
+ }
990
+
991
+ /** A round's results, labelled the same way (`agent: true` on every AI's row). */
992
+ labelResults(rows) {
993
+ return rows.slice(0, 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
994
+ }
995
+
996
+ labelOne(raw, result) {
997
+ if (!raw || typeof raw !== 'object') return null;
998
+ const out = { ...raw };
999
+ const seat = Number.isInteger(raw.seat) ? raw.seat : null;
1000
+ const agentSeat = !result && raw.agent && Number.isInteger(raw.agent.seat) ? raw.agent.seat : null;
1001
+ const who = this.holderOf(seat ?? agentSeat);
1002
+ const role = (a) => (['party', 'guide', 'player'].includes(a?.role) ? a.role : 'party');
1003
+ if (who.kind === 'agent') {
1004
+ out.name = who.name || aiName(raw.name);
1005
+ if (result) out.agent = true;
1006
+ else out.agent = { seat: seat ?? agentSeat, role: role(who.agent), hands: who.agent.hands === 'host' ? 'host' : 'self' };
1007
+ if (who.agent.hands === 'host') out.bot = true;
1008
+ this.stats.labelled += 1;
1009
+ } else if (seat !== null && who.kind === 'human') {
1010
+ delete out.agent;
1011
+ out.bot = false;
1012
+ out.name = stripAi(raw.name) || who.name;
1013
+ } else {
1014
+ // No seat, or a seat nobody holds: a body the host's own bot code moves.
1015
+ if (seat !== null) this.stats.labelled += 1;
1016
+ out.bot = true;
1017
+ if (raw.agent) {
1018
+ // A seat kept for AI with nobody in it yet (a brainless AI seat), marked so.
1019
+ out.agent = result ? true : { seat: null, role: role(raw.agent), hands: 'host' };
1020
+ out.name = aiName(raw.name);
1021
+ } else delete out.agent;
1022
+ }
1023
+ if (out.bot && out.seat !== null && who.kind !== 'agent') out.seat = null;
1024
+ out.name = String(out.name ?? '').slice(0, 40);
1025
+ return out;
1026
+ }
1027
+
1028
+ /** The vote as everyone sees it: the options, how many chose each, who voted (a count), the result once closed. */
1029
+ voteView() {
1030
+ const v = this.vote;
1031
+ if (!v) return null;
1032
+ const counts = {};
1033
+ for (const n of v.options) counts[n] = 0;
1034
+ for (const n of v.votes.values()) counts[n] = (counts[n] ?? 0) + 1;
1035
+ return {
1036
+ of: 'skill', id: v.id, open: v.open, until: v.until, options: v.options, counts, voters: v.votes.size,
1037
+ of_total: this.seatedHumans().length, ...(v.result ? { result: v.result } : {}), ...(v.reason ? { reason: v.reason } : {}),
1038
+ };
1039
+ }
1040
+
1041
+ tellVote() {
1042
+ this.broadcast({ t: 'vote', ...this.voteView() });
1043
+ const text = JSON.stringify({ t: 'vote', ...this.voteView() });
1044
+ for (const w of this.watchers) this.sendText(w, text);
1045
+ this.tellWatchers();
1046
+ }
1047
+
1048
+ /** Open a vote on the dial: the options are 1 to the server's ceiling. A player opens one at most every 2 minutes. */
1049
+ openVote({ reason = null, auto = false, by = 'player' } = {}) {
1050
+ const now = this.now();
1051
+ if (this.vote && this.vote.open) return this.vote;
1052
+ if (!auto && by !== 'owner' && now - this.lastVoteOpen < this.voteCooldownMs) return null;
1053
+ const options = [];
1054
+ for (let n = 1; n <= this.policy.levelMax; n += 1) options.push(n);
1055
+ this.vote = { id: randomId(6), open: true, at: now, until: now + this.voteMs, options, votes: new Map(), reason: reason ? oneLine(reason, 40) : null, result: null };
1056
+ this.lastVoteOpen = now;
1057
+ if (auto) this.lastAutoVote = now;
1058
+ this.stats.votes += 1;
1059
+ this.tellVote();
1060
+ return this.vote;
1061
+ }
1062
+
1063
+ /** A party's first live round with AI seats (and a game that reads the dial): the vote opens by itself. */
1064
+ autoVote() {
1065
+ const now = this.now();
1066
+ if (!this.caps.has('skill') || (this.vote && this.vote.open) || now - this.lastAutoVote < this.autoVoteMs) return;
1067
+ const aiSeats = this.reserve() > 0 || this.live().some((c) => c.agent);
1068
+ if (!aiSeats || !this.seatedHumans().length) return;
1069
+ this.openVote({ auto: true, reason: 'start' });
1070
+ }
1071
+
1072
+ /**
1073
+ * A `vote` from a seated person (their game, or their play page's card through the watch socket) or the host:
1074
+ * `open` starts one, `n` casts (or changes) this seat's vote. Agents and watchers never vote.
1075
+ */
1076
+ onVote(c, m, reply = null) {
1077
+ const no = (message) => { const e = { t: 'error', code: 'vote', message }; if (reply) this.sendText(reply, JSON.stringify(e)); else this.send(c, e); };
1078
+ if (m.of !== undefined && m.of !== 'skill') return no('only the skill dial is voted on');
1079
+ const seated = c.seat !== null && !c.agent && !c.watch;
1080
+ if (!seated && c.id !== this.hostId) return no('only a seated player votes');
1081
+ const n = Math.floor(Number(m.n));
1082
+ const casting = Number.isFinite(n) && seated;
1083
+ if (!(this.vote && this.vote.open)) {
1084
+ if (!m.open && !casting) return no('no vote is open');
1085
+ if (!this.openVote({ reason: typeof m.reason === 'string' ? m.reason : null })) return no('a vote was held a moment ago; try again in a minute or two');
1086
+ }
1087
+ if (!casting) return;
1088
+ const v = this.vote;
1089
+ if (!v.options.includes(n)) return no(`vote 1 to ${this.policy.levelMax}`);
1090
+ v.votes.set(c.seat, n);
1091
+ // Everyone seated has voted (a lone player's tap decides at once).
1092
+ const voters = this.seatedHumans().map((o) => o.seat);
1093
+ if (voters.every((s) => v.votes.has(s))) this.closeVote('all');
1094
+ else this.tellVote();
1095
+ }
1096
+
1097
+ /** The middle vote wins, rounded down (DESIGN D8), capped by the server's ceiling, and applies at once. */
1098
+ closeVote(why = 'time') {
1099
+ const v = this.vote;
1100
+ if (!v || !v.open) return null;
1101
+ v.open = false;
1102
+ const here = new Set(this.seatedHumans().map((o) => o.seat));
1103
+ const counted = [...v.votes].filter(([seat]) => here.has(seat)).map(([, n]) => n);
1104
+ const m = medianVote(counted);
1105
+ if (m !== null) {
1106
+ this.level = Math.max(1, Math.min(this.policy.levelMax, m));
1107
+ this.levelBy = 'vote';
1108
+ const s = skillPreset(this.level, { kids: this.policy.kids });
1109
+ v.result = { level: this.level, name: s.name, votes: counted.length, why };
1110
+ // Saved on the next tick, not within 30 s: a deploy right after the vote keeps the party's choice.
1111
+ this.seatsDirty = true;
1112
+ this.broadcast({ t: 'policy', policy: this.policyOut() });
1113
+ } else v.result = { level: this.levelNow(), name: skillPreset(this.levelNow()).name, votes: 0, why };
1114
+ this.tellVote();
1115
+ return v.result;
1116
+ }
1117
+
710
1118
  /* ------------------------------------------------------------ storage (optional) */
711
1119
 
712
- /** Seats on change; checkpoint/round/roster/state at most every persistMs. Never per frame. */
1120
+ /** Seats, the policy and the party's dial on change; checkpoint/round/roster/state at most every persistMs. Never per frame. */
713
1121
  persist(now = this.now(), force = false) {
714
1122
  if (!this.store) return;
715
1123
  const due = this.seatsDirty || (this.persistDirty && now - this.lastPersistAt >= this.persistMs);
@@ -724,8 +1132,10 @@ export class NetRoom {
724
1132
  saved(now = this.now()) {
725
1133
  return {
726
1134
  v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null, openedAt: this.openedAt || null,
727
- seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name]),
1135
+ seats: [...this.seats].map(([seat, s]) => (s.agent ? [seat, s.token, s.name, s.agent] : [seat, s.token, s.name])),
728
1136
  ckpt: this.lastCkpt, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
1137
+ // Section 17: a deploy keeps the room's policy, the party's dial and what its game reads.
1138
+ policy: this.policy, level: this.level, levelBy: this.levelBy, caps: [...this.caps],
729
1139
  };
730
1140
  }
731
1141
 
@@ -739,7 +1149,13 @@ export class NetRoom {
739
1149
  if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
740
1150
  if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
741
1151
  if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
742
- for (const [seat, token, name] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false });
1152
+ for (const [seat, token, name, agent] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false, agent: agent && typeof agent === 'object' ? agent : null });
1153
+ const pol = normalizePolicy(saved.policy);
1154
+ if (pol) this.policy = pol;
1155
+ if (Number.isInteger(saved.level)) { this.level = Math.max(1, Math.min(this.policy.levelMax, saved.level)); this.levelBy = saved.levelBy === 'owner' ? 'owner' : 'vote'; }
1156
+ if (Array.isArray(saved.caps)) this.caps = new Set(saved.caps.filter((k) => CAPS.includes(k)));
1157
+ // The people coming back get the agents' grace too (an AI is not "alone" in a room a deploy just emptied).
1158
+ if ((saved.seats ?? []).some((s) => !s[3])) this.lastHumanAt = now;
743
1159
  this.lastCkpt = saved.ckpt ?? null;
744
1160
  this.lastRound = saved.round ?? null;
745
1161
  this.lastRoster = saved.roster ?? null;
@@ -767,7 +1183,8 @@ export class NetRoom {
767
1183
  /** The hold that keeps this client out, if any: by its seat token, its browser's room key, its account, or (only when the owner asked) its address. */
768
1184
  banOf(c, token = c.token) {
769
1185
  const now = this.now();
770
- return this.bans.find((b) => b.until > now && ((b.token && b.token === token) || (b.browser && b.browser === c.browser) || (b.player && b.player === c.player) || (b.ip && b.ip === c.ip))) ?? null;
1186
+ const pass = c.agent?.pass ?? c.agentWord?.pass ?? null;
1187
+ return this.bans.find((b) => b.until > now && ((b.token && b.token === token) || (b.browser && b.browser === c.browser) || (b.player && b.player === c.player) || (b.ip && b.ip === c.ip) || (b.pass && b.pass === pass))) ?? null;
771
1188
  }
772
1189
 
773
1190
  muteOf(c) {
@@ -814,7 +1231,8 @@ export class NetRoom {
814
1231
  const until = now + minutes * 60_000;
815
1232
  // The message is the studio's words; `until` says when (the shell says it in the player's own time).
816
1233
  const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
817
- const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, name: target.name, seat: target.seat, until, at: now, message };
1234
+ // An AI is held out by its pass (its seat becomes a brainless AI seat again); a person by token, browser, account.
1235
+ const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, pass: target.agent?.pass ?? null, name: target.name, seat: target.seat, until, at: now, message };
818
1236
  this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
819
1237
  // Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
820
1238
  const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
@@ -913,6 +1331,22 @@ export class NetRoom {
913
1331
  this.tellWatchers();
914
1332
  return { ok: true, op, max: this.maxPlayers };
915
1333
  }
1334
+ case 'policy': {
1335
+ // The owner changed the server (section 17): its new policy now, and AI out after the round if it is humans-only.
1336
+ const r = this.setPolicy(a.pol, { force: true });
1337
+ return r.ok ? { ...r, op } : { ...r, op };
1338
+ }
1339
+ case 'level': {
1340
+ // The owner sets this room's dial (room_level): it applies at once, as a vote's result does.
1341
+ const n = Math.floor(Number(a.level));
1342
+ if (!(n >= 1 && n <= 5)) return { ok: false, error: 'level', message: 'level is 1 to 5' };
1343
+ this.level = Math.min(this.policy.levelMax, n);
1344
+ this.levelBy = 'owner';
1345
+ this.seatsDirty = true;
1346
+ this.broadcast({ t: 'policy', policy: this.policyOut() });
1347
+ this.tellWatchers();
1348
+ return { ok: true, op, level: this.level, name: skillPreset(this.level, { kids: this.policy.kids }).name };
1349
+ }
916
1350
  default:
917
1351
  return { ok: false, error: 'op', message: `no control called ${String(op).slice(0, 24)}` };
918
1352
  }
@@ -920,7 +1354,9 @@ export class NetRoom {
920
1354
 
921
1355
  /** Whether a client may stay once the room re-gates: one of its ticket's holders (o, i-…, p-…) is of an allowed kind. */
922
1356
  allowedAfter(c, allow) {
923
- return String(c.via ?? '').split('~').some((part) => (part === 'o' && allow.includes('o')) || (part.startsWith('i-') && allow.includes('i')) || (part.startsWith('p-') && allow.includes('p')));
1357
+ // The owner's own agent passes count as the owner's (only the owner can mint one).
1358
+ return String(c.via ?? '').split('~').some((part) => (part === 'o' && allow.includes('o')) || (part.startsWith('i-') && allow.includes('i')) || (part.startsWith('p-') && allow.includes('p'))
1359
+ || (part.startsWith('a-') && allow.includes('o') && c.agent?.by === 'studio'));
924
1360
  }
925
1361
 
926
1362
  /** The launch change, now: everyone it leaves out is sent out of the room with the studio's words; the rest play on. */
@@ -962,7 +1398,7 @@ export class NetRoom {
962
1398
  return {
963
1399
  bans: this.bans.filter((b) => b.until > now), mutes: this.mutes.filter((m) => m.until > now),
964
1400
  announcement: this.liveAnnouncement(), closedUntil: this.closedUntil > now ? this.closedUntil : 0, closedWhy: this.closedWhy,
965
- regate: this.regate,
1401
+ regate: this.regate, agentsOut: this.agentsOut,
966
1402
  };
967
1403
  }
968
1404
 
@@ -976,6 +1412,7 @@ export class NetRoom {
976
1412
  this.closedWhy = this.closedUntil ? String(o.closedWhy ?? '') : '';
977
1413
  // A launch change still waiting comes back; one long past is over (whoever it was for has gone).
978
1414
  this.regate = o.regate && Array.isArray(o.regate.allow) && Number(o.regate.until) > now - 120_000 ? o.regate : null;
1415
+ this.agentsOut = o.agentsOut && Number(o.agentsOut.until) > now - 120_000 ? o.agentsOut : null;
979
1416
  }
980
1417
 
981
1418
  /** The room as its owner sees it: the facts, plus who each player is to the studio (never an address). */
@@ -989,6 +1426,7 @@ export class NetRoom {
989
1426
  openedAt: this.openedAt || null,
990
1427
  closedUntil: this.closedUntil > now ? this.closedUntil : null,
991
1428
  regate: this.regate ? { allow: this.regate.allow, until: this.regate.until, afterRound: this.regate.roundN } : null,
1429
+ agentsOut: this.agentsOut ? { until: this.agentsOut.until, afterRound: this.agentsOut.roundN } : null,
992
1430
  clients: f.clients.map((p) => {
993
1431
  const c = byId.get(p.id);
994
1432
  const mute = c ? this.muteOf(c) : null;
@@ -1007,8 +1445,9 @@ export class NetRoom {
1007
1445
  while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
1008
1446
  const slots = Array.isArray(this.lastRoster) ? this.lastRoster : [];
1009
1447
  const live = this.live();
1448
+ const agents = live.filter((c) => c.agent && c.seat !== null).length;
1010
1449
  return {
1011
- t: 'net', v: NET_VERSION, room: this.code, st: now,
1450
+ t: 'net', v: NET_VERSION, rev: NET_REVISION, room: this.code, st: now,
1012
1451
  host: this.hostRef(),
1013
1452
  openedAt: this.openedAt || null,
1014
1453
  announce: this.liveAnnouncement(),
@@ -1018,14 +1457,22 @@ export class NetRoom {
1018
1457
  roster: this.lastRoster,
1019
1458
  snapHz: this.snapTimes.length,
1020
1459
  counts: {
1021
- players: live.filter((c) => c.seat !== null).length,
1460
+ // Players are people (section 17): an agent is never counted as one, by the Lobby or anybody else.
1461
+ players: live.filter((c) => c.seat !== null && !c.agent).length,
1462
+ agents,
1022
1463
  screens: live.filter((c) => c.seat === null).length,
1023
1464
  waiting: live.filter((c) => c.waiting).length,
1024
1465
  watchers: live.filter((c) => c.watch).length,
1025
- humans: slots.filter((s) => s && !s.bot).length,
1026
- bots: slots.filter((s) => s && s.bot).length,
1466
+ humans: slots.filter((s) => s && !s.bot && !s.agent).length,
1467
+ bots: slots.filter((s) => s && s.bot && !s.agent).length,
1468
+ // Every AI body: a slot marked AI (an agent, or a seat kept for one), else the agents seated.
1469
+ ai: Math.max(agents, slots.filter((s) => s && s.agent).length),
1027
1470
  maxPlayers: this.maxPlayers,
1471
+ humanSeats: this.humanCap(),
1028
1472
  },
1473
+ policy: this.policyOut(),
1474
+ ...(this.vote ? { vote: this.voteView() } : {}),
1475
+ caps: [...this.caps],
1029
1476
  memory: {
1030
1477
  snapBytes: this.lastSnapText ? this.lastSnapText.length : 0,
1031
1478
  ckptBytes: this.lastCkpt ? JSON.stringify(this.lastCkpt).length : 0,