@homie-rocks/studio 0.14.4 → 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
@@ -16,10 +16,63 @@
16
16
  * allocated or the host changes, plus the latest checkpoint/round/
17
17
  * roster/state at most every 30 s. That is what lets a Worker deploy
18
18
  * (which evicts every Durable Object) keep seats, bodies and round.
19
+ *
20
+ * WATCHERS (revision 5, NETPLAY.md section 16): a screen that came to watch
21
+ * (`hello.watch`, or a socket the Worker opened through a game's watch door,
22
+ * `conn.watch`) never takes a seat, hosts only when no player can, and is told
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.
19
31
  * =============================================================================
20
32
  */
33
+ import { aiName, skillPreset, stripAi } from './agents.mjs';
21
34
 
22
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
+ }
23
76
  const PALETTE_SIZE = 12;
24
77
  const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
25
78
 
@@ -51,6 +104,16 @@ export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
51
104
  export const CONTROL_LIMITS = Object.freeze({ minutes: 24 * 60, announce: 280, announceSeconds: 3600, bans: 200, mutes: 200, message: 200 });
52
105
  /** What a muted player cannot say: an `ev` whose kind starts with one of these (a game's chat, quick lines, emotes). */
53
106
  export const SPEECH = /^(?:say|chat|emote)/i;
107
+ /**
108
+ * The contract's 12 player colours: a peer's `colour` (its seat % 12) is an index into this list. The watch page's
109
+ * player strip draws them; a game that colours its players the same way matches it (Gem Rush does).
110
+ */
111
+ export const NET_PALETTE = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
112
+ /**
113
+ * What a game lets its watchers see (game.json "watch", passed by the Worker as `conn.watchPolicy`): `follow` (any
114
+ * player's view, the default), `overview` (the whole room only: a game with hidden hands or hidden roles), `off`.
115
+ */
116
+ export const WATCH_POLICIES = Object.freeze(['follow', 'overview', 'off']);
54
117
  const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
55
118
  /** A short tag for a browser's room key: enough for the owner to see "the same browser", never the key itself. */
56
119
  export function browserTag(key) {
@@ -129,7 +192,23 @@ export class NetRoom {
129
192
  constructor({
130
193
  code, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
131
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,
132
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;
133
212
  this.code = code;
134
213
  this.seatCap = maxPlayers;
135
214
  this.maxPlayers = maxPlayers;
@@ -169,6 +248,7 @@ export class NetRoom {
169
248
  this.stats = {
170
249
  snaps: 0, ins: 0, evs: 0, ckpts: 0, states: 0, rounds: 0, drops: 0, oversize: 0, stFixed: 0, reclaimed: 0, seated: 0,
171
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,
172
252
  };
173
253
  this.snapTimes = [];
174
254
  /** The owner's controls (section 15): who is held out of this room until when, who is muted, the banner, a closed door. */
@@ -193,6 +273,10 @@ export class NetRoom {
193
273
  id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
194
274
  device: 'desk', want: 'play', canHost: true, hidden: false, waiting: false, joinedAt: now, lastSeen: now, lastSnapAt: 0,
195
275
  rates: new Map(), drops: [], errAt: 0,
276
+ // A watcher (section 16): the Worker's word for a socket opened through a watch door, else the hello's own.
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: [],
196
280
  };
197
281
  this.clients.set(c.id, c);
198
282
  return {
@@ -202,13 +286,32 @@ export class NetRoom {
202
286
  };
203
287
  }
204
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
+ */
205
293
  watch(conn) {
206
294
  this.watchers.add(conn);
207
295
  this.sendText(conn, JSON.stringify(this.facts()));
208
296
  // A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
209
297
  const held = this.heldNotice(conn);
210
298
  if (held) this.sendText(conn, JSON.stringify(held));
211
- 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
+ };
212
315
  }
213
316
 
214
317
  /* ------------------------------------------------------------ wire */
@@ -223,7 +326,29 @@ export class NetRoom {
223
326
  host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
224
327
  hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
225
328
  roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
226
- 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), ...(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
+ }
227
352
  peers() { return this.live().map((c) => this.peer(c)); }
228
353
  stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
229
354
 
@@ -281,6 +406,7 @@ export class NetRoom {
281
406
  const out = JSON.stringify({ t: 'snap', from: c.seat, ...snap });
282
407
  this.lastSnapText = out;
283
408
  for (const o of this.others(c)) {
409
+ if (this.lite(o)) continue; // an agent with no game client draws nothing
284
410
  // A congested socket skips a snapshot rather than queueing a stale one.
285
411
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
286
412
  this.sendText(o.conn, out);
@@ -288,7 +414,7 @@ export class NetRoom {
288
414
  return;
289
415
  }
290
416
  case 'in': {
291
- if (isHost || c.seat === null) return;
417
+ if (isHost || c.seat === null || this.lite(c)) return;
292
418
  const h = this.host();
293
419
  if (!h) return;
294
420
  this.stats.ins += 1;
@@ -303,12 +429,22 @@ export class NetRoom {
303
429
  const kind = String(m.k ?? '').slice(0, 64);
304
430
  // A muted player's speech goes nowhere (a host relays everyone's, so only a non-host's own is dropped).
305
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
+ }
306
441
  this.stats.evs += 1;
307
442
  const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
308
443
  if (isHost) {
309
444
  if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && o !== c) this.send(o, out); }
310
445
  else if (Number.isInteger(m.to)) { for (const o of this.others(c)) if (o.seat === m.to) this.send(o, out); }
311
- 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); }
312
448
  } else {
313
449
  const h = this.host();
314
450
  if (h) this.send(h, { ...out, id: c.id });
@@ -343,28 +479,48 @@ export class NetRoom {
343
479
  this.stats.states += 1;
344
480
  this.persistDirty = true;
345
481
  const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
346
- 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);
347
483
  return;
348
484
  }
349
485
  case 'round': {
350
486
  if (!isHost || !m.round || typeof m.round !== 'object') return;
351
- 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;
352
491
  // The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
353
- 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);
354
494
  this.stats.rounds += 1;
355
495
  this.persistDirty = true;
356
- 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();
357
499
  this.tellWatchers();
358
500
  return;
359
501
  }
360
502
  case 'roster': {
361
503
  if (!isHost || !Array.isArray(m.slots)) return;
362
- 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));
363
506
  this.persistDirty = true;
364
507
  for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
365
508
  this.tellWatchers();
366
509
  return;
367
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
+ }
368
524
  case 'ping': {
369
525
  const wasHidden = c.hidden;
370
526
  c.hidden = m.hid === true;
@@ -417,24 +573,49 @@ export class NetRoom {
417
573
  if (this.closedUntil > this.now()) return refuse('room-closed', this.closedWhy || 'The studio closed this room.', { until: this.closedUntil });
418
574
  const ban = this.banOf(c, typeof m.token === 'string' ? m.token : '');
419
575
  if (ban) return refuse('kicked', ban.message, { until: ban.until });
576
+ // A watcher of a game that cannot be watched (game.json "watch": false) is refused for good (section 16).
577
+ const watching = !c.agentWord && (c.watch || m.watch === true);
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
+ }
420
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`);
421
594
  if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
422
- const now = this.now();
423
595
  c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
424
- c.want = m.want === 'screen' ? 'screen' : 'play';
425
- c.canHost = m.canHost !== false;
596
+ // A watcher is a screen that never takes a seat, whatever else its hello says. An agent always plays.
597
+ c.watch = watching;
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;
426
601
  c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
427
- 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);
428
605
  // The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
429
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)); }
430
607
  this.reapSeats(now);
431
608
  let full = false;
432
- if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) { full = true; c.waiting = true; }
433
- if (c.seat === null) c.name = c.typed || (c.want === 'screen' ? 'Screen' : 'Watcher');
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
+ }
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;
434
615
  c.helloed = true;
435
616
  c.joinedAt = now;
436
617
  if (!this.openedAt) this.openedAt = now;
437
- this.emptySince = 0;
618
+ if (!c.agent) this.emptySince = 0;
438
619
  this.lastJoinAt = now;
439
620
 
440
621
  let role = c.seat === null ? 'screen' : 'replica';
@@ -442,16 +623,24 @@ export class NetRoom {
442
623
  let deposed = null;
443
624
  const h = this.host();
444
625
  const preferred = this.preferHost && now < this.preferHost.until ? this.preferHost.seat : undefined;
445
- if (c.canHost && (preferred === undefined || preferred === c.seat)) {
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));
629
+ if (c.canHost && !lastResort && (preferred === undefined || preferred === c.seat)) {
446
630
  if (!h) {
447
631
  role = 'host';
448
632
  why = this.lastSnap || this.lastCkpt ? 'resumed' : 'first';
449
633
  if (preferred !== undefined) this.preferHost = null;
450
- } else if (h.hidden || now - h.lastSeen > this.silentMs) {
634
+ } else if (!c.watch && !c.agent && (h.hidden || now - h.lastSeen > this.silentMs)) {
451
635
  // The host is a frozen phone (socket open, nothing sent): the newcomer runs the round now, not after a stall.
452
636
  deposed = h;
453
637
  role = 'host';
454
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';
455
644
  }
456
645
  }
457
646
  if (role === 'host') {
@@ -459,30 +648,40 @@ export class NetRoom {
459
648
  this.hostSince = now;
460
649
  c.lastSnapAt = 0;
461
650
  this.seatsDirty = true;
651
+ this.caps = new Set(c.caps);
462
652
  this.stats.elections.push({ at: now, id: c.id, seat: c.seat, why });
463
653
  if (this.stats.elections.length > 32) this.stats.elections.shift();
464
654
  }
655
+ const lite = this.lite(c);
465
656
  this.send(c, {
466
- 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,
467
658
  role, why, host: this.hostRef(), peers: this.peers(), st: now, max: this.maxPlayers,
468
- 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(),
469
660
  ...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
470
661
  ...(full ? { full: true } : {}),
471
662
  ...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
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 } } : {}),
472
667
  });
473
668
  for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
669
+ // A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
670
+ this.refreshWatchers(c);
474
671
  if (deposed) {
475
672
  this.send(deposed, { t: 'role', role: deposed.seat === null ? 'screen' : 'replica', why, host: this.hostRef(), peers: this.peers() });
476
673
  for (const o of this.others(c)) if (o !== deposed) this.send(o, { t: 'host', host: this.hostRef(), why });
477
674
  }
478
- 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 });
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 } : {}) });
479
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
480
679
  this.tellWatchers();
481
680
  }
482
681
 
483
682
  /** Give `c` a seat: its token's seat, a free one, or the seat absent longest. False when every seat is present. */
484
683
  seatClient(c, token) {
485
- const seat = this.seatFor(token);
684
+ const seat = this.seatFor(token, c.agent ? 'agent' : 'human');
486
685
  if (seat === null) return false;
487
686
  const s = this.seats.get(seat);
488
687
  // Same seat open twice (two tabs, or a reload racing its own close): the newer socket wins.
@@ -497,17 +696,31 @@ export class NetRoom {
497
696
  c.token = s.token;
498
697
  c.waiting = false;
499
698
  s.present = true;
500
- 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);
501
703
  if (name !== s.name) { s.name = name; this.seatsDirty = true; }
502
704
  c.name = name;
503
705
  c.colour = seat % PALETTE_SIZE;
504
706
  return true;
505
707
  }
506
708
 
507
- seatFor(token) {
508
- if (token) for (const [seat, s] of this.seats) if (s.token === token) return seat;
509
- const fresh = () => ({ token: randomId(18), name: '', since: this.now(), present: true });
510
- 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) {
511
724
  if (this.seats.has(seat)) continue;
512
725
  this.seats.set(seat, fresh());
513
726
  this.seatsDirty = true;
@@ -515,7 +728,10 @@ export class NetRoom {
515
728
  }
516
729
  // Every seat is present or held. A visitor who is here beats one who left: reclaim the seat absent longest.
517
730
  let oldest = null;
518
- 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
+ }
519
735
  if (!oldest) return null;
520
736
  this.seats.set(oldest[0], fresh());
521
737
  this.seatsDirty = true;
@@ -539,10 +755,38 @@ export class NetRoom {
539
755
  for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
540
756
  this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
541
757
  this.persist();
758
+ this.refreshWatchers();
542
759
  this.tellWatchers();
543
760
  }
544
761
  }
545
762
 
763
+ /* ------------------------------------------------------------ watchers (section 16) */
764
+
765
+ /**
766
+ * Whether this watcher may follow one player's view, and if not, why: `overview` (the game shows watchers the
767
+ * whole room only) or `seated-here` (the same browser holds a seat in this room: a second tab is not a way to
768
+ * look over an opponent's shoulder). The stream is the same either way; this decides what the watcher's page
769
+ * offers and its helper renders.
770
+ */
771
+ followFacts(c) {
772
+ if (c.policy === 'overview') return { follow: false, why: 'overview' };
773
+ if (c.browser && this.live().some((o) => o !== c && o.seat !== null && o.browser === c.browser)) return { follow: false, why: 'seated-here' };
774
+ return { follow: true };
775
+ }
776
+
777
+ /** Tell every watcher whose right to follow changed (a seat taken or left by its own browser). */
778
+ refreshWatchers(except = null) {
779
+ for (const c of this.live()) {
780
+ if (!c.watch) continue;
781
+ const f = this.followFacts(c);
782
+ const why = f.why ?? null;
783
+ if (c === except || (c.follow === f.follow && c.followWhy === why)) { c.follow = f.follow; c.followWhy = why; continue; }
784
+ c.follow = f.follow;
785
+ c.followWhy = why;
786
+ this.send(c, { t: 'watch', ...f });
787
+ }
788
+ }
789
+
546
790
  onClose(c, why) {
547
791
  if (this.clients.get(c.id) !== c) return;
548
792
  this.clients.delete(c.id);
@@ -558,8 +802,11 @@ export class NetRoom {
558
802
  this.hostId = null;
559
803
  this.elect(null, why === 'replaced' ? 'host-replaced' : why === 'silent' ? 'host-stalled' : 'host-left');
560
804
  }
561
- 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;
562
808
  this.seatWaiting();
809
+ if (c.seat !== null) this.refreshWatchers();
563
810
  this.tellWatchers();
564
811
  }
565
812
 
@@ -576,6 +823,9 @@ export class NetRoom {
576
823
  .filter((c) => c.canHost && c.id !== excludeId)
577
824
  .sort((a, b) => Number(a.hidden) - Number(b.hidden)
578
825
  || silent(a) - silent(b)
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))
828
+ || Number(a.watch) - Number(b.watch)
579
829
  || tenure(b) - tenure(a)
580
830
  || (DEVICE_RANK[a.device] ?? 3) - (DEVICE_RANK[b.device] ?? 3)
581
831
  || a.joinedAt - b.joinedAt);
@@ -589,6 +839,8 @@ export class NetRoom {
589
839
  this.hostSince = now;
590
840
  this.preferHost = null;
591
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);
592
844
  next.lastSnapAt = 0;
593
845
  this.stats.promotions += 1;
594
846
  this.stats.elections.push({ at: now, id: next.id, seat: next.seat, why });
@@ -637,21 +889,235 @@ export class NetRoom {
637
889
  if (this.live().some((c) => c.canHost)) this.elect(null, 'host-left');
638
890
  }
639
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
+ }
640
902
  this.reapSeats(now);
641
903
  this.seatWaiting();
642
- 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);
643
906
  this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
644
907
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
645
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;
646
911
  this.seatsDirty = false; this.persistDirty = false;
647
912
  try { this.store?.clear?.(); } catch { /* best effort */ }
648
913
  }
649
914
  this.persist(now);
650
915
  }
651
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
+
652
1118
  /* ------------------------------------------------------------ storage (optional) */
653
1119
 
654
- /** 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. */
655
1121
  persist(now = this.now(), force = false) {
656
1122
  if (!this.store) return;
657
1123
  const due = this.seatsDirty || (this.persistDirty && now - this.lastPersistAt >= this.persistMs);
@@ -666,8 +1132,10 @@ export class NetRoom {
666
1132
  saved(now = this.now()) {
667
1133
  return {
668
1134
  v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null, openedAt: this.openedAt || null,
669
- 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])),
670
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],
671
1139
  };
672
1140
  }
673
1141
 
@@ -681,7 +1149,13 @@ export class NetRoom {
681
1149
  if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
682
1150
  if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
683
1151
  if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
684
- 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;
685
1159
  this.lastCkpt = saved.ckpt ?? null;
686
1160
  this.lastRound = saved.round ?? null;
687
1161
  this.lastRoster = saved.roster ?? null;
@@ -709,7 +1183,8 @@ export class NetRoom {
709
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. */
710
1184
  banOf(c, token = c.token) {
711
1185
  const now = this.now();
712
- 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;
713
1188
  }
714
1189
 
715
1190
  muteOf(c) {
@@ -756,7 +1231,8 @@ export class NetRoom {
756
1231
  const until = now + minutes * 60_000;
757
1232
  // The message is the studio's words; `until` says when (the shell says it in the player's own time).
758
1233
  const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
759
- 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 };
760
1236
  this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
761
1237
  // Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
762
1238
  const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
@@ -855,6 +1331,22 @@ export class NetRoom {
855
1331
  this.tellWatchers();
856
1332
  return { ok: true, op, max: this.maxPlayers };
857
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
+ }
858
1350
  default:
859
1351
  return { ok: false, error: 'op', message: `no control called ${String(op).slice(0, 24)}` };
860
1352
  }
@@ -862,7 +1354,9 @@ export class NetRoom {
862
1354
 
863
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. */
864
1356
  allowedAfter(c, allow) {
865
- 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'));
866
1360
  }
867
1361
 
868
1362
  /** The launch change, now: everyone it leaves out is sent out of the room with the studio's words; the rest play on. */
@@ -904,7 +1398,7 @@ export class NetRoom {
904
1398
  return {
905
1399
  bans: this.bans.filter((b) => b.until > now), mutes: this.mutes.filter((m) => m.until > now),
906
1400
  announcement: this.liveAnnouncement(), closedUntil: this.closedUntil > now ? this.closedUntil : 0, closedWhy: this.closedWhy,
907
- regate: this.regate,
1401
+ regate: this.regate, agentsOut: this.agentsOut,
908
1402
  };
909
1403
  }
910
1404
 
@@ -918,6 +1412,7 @@ export class NetRoom {
918
1412
  this.closedWhy = this.closedUntil ? String(o.closedWhy ?? '') : '';
919
1413
  // A launch change still waiting comes back; one long past is over (whoever it was for has gone).
920
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;
921
1416
  }
922
1417
 
923
1418
  /** The room as its owner sees it: the facts, plus who each player is to the studio (never an address). */
@@ -931,6 +1426,7 @@ export class NetRoom {
931
1426
  openedAt: this.openedAt || null,
932
1427
  closedUntil: this.closedUntil > now ? this.closedUntil : null,
933
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,
934
1430
  clients: f.clients.map((p) => {
935
1431
  const c = byId.get(p.id);
936
1432
  const mute = c ? this.muteOf(c) : null;
@@ -949,8 +1445,9 @@ export class NetRoom {
949
1445
  while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
950
1446
  const slots = Array.isArray(this.lastRoster) ? this.lastRoster : [];
951
1447
  const live = this.live();
1448
+ const agents = live.filter((c) => c.agent && c.seat !== null).length;
952
1449
  return {
953
- t: 'net', v: NET_VERSION, room: this.code, st: now,
1450
+ t: 'net', v: NET_VERSION, rev: NET_REVISION, room: this.code, st: now,
954
1451
  host: this.hostRef(),
955
1452
  openedAt: this.openedAt || null,
956
1453
  announce: this.liveAnnouncement(),
@@ -960,13 +1457,22 @@ export class NetRoom {
960
1457
  roster: this.lastRoster,
961
1458
  snapHz: this.snapTimes.length,
962
1459
  counts: {
963
- 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,
964
1463
  screens: live.filter((c) => c.seat === null).length,
965
1464
  waiting: live.filter((c) => c.waiting).length,
966
- humans: slots.filter((s) => s && !s.bot).length,
967
- bots: slots.filter((s) => s && s.bot).length,
1465
+ watchers: live.filter((c) => c.watch).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),
968
1470
  maxPlayers: this.maxPlayers,
1471
+ humanSeats: this.humanCap(),
969
1472
  },
1473
+ policy: this.policyOut(),
1474
+ ...(this.vote ? { vote: this.voteView() } : {}),
1475
+ caps: [...this.caps],
970
1476
  memory: {
971
1477
  snapBytes: this.lastSnapText ? this.lastSnapText.length : 0,
972
1478
  ckptBytes: this.lastCkpt ? JSON.stringify(this.lastCkpt).length : 0,