@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/README.md +43 -6
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +48 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +50 -3
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +55 -0
- package/netplay/NETPLAY.md +317 -21
- package/netplay/netplay.ts +649 -33
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/hud.ts +10 -7
- package/port/index.ts +5 -2
- package/port/room.ts +48 -10
- package/port/skill.ts +28 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +71 -0
- package/starters/ember-vale/src/main.ts +58 -12
- package/starters/gem-rush/src/main.ts +128 -33
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +353 -68
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +622 -18
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +547 -41
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +166 -10
- package/worker/stats.mjs +9 -3
- package/worker/version.mjs +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
c.
|
|
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
|
-
|
|
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 : '')) {
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
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 (!
|
|
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().
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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,
|