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