@homie-rocks/studio 0.32.1 → 0.33.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/CHANGELOG.md +67 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +22 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/worker/room.mjs
CHANGED
|
@@ -41,6 +41,12 @@
|
|
|
41
41
|
* on typed text (worker/chat.mjs), waits for the studio's review when there is one (the Table's Workers AI), and fans
|
|
42
42
|
* the line out to every socket and every watching shell. It keeps the last few minutes in memory for a page that just
|
|
43
43
|
* opened, and nothing anywhere else.
|
|
44
|
+
*
|
|
45
|
+
* THE SERVER AS HOST (revision 10, NETPLAY.md section 29): a game whose rules run on the server has no browser host.
|
|
46
|
+
* The Table hands the room a host runtime (`setServerHost`, rules/host.ts). It is not a client: it is not in
|
|
47
|
+
* `clients`, it has no socket, it is never elected away, and stall detection, yield and the caps written for a host
|
|
48
|
+
* that is somebody's phone (`LIMITS`, `RATES`) do not apply to it. The relay calls it with parsed frames (`in`, `ev`,
|
|
49
|
+
* who came and went) and fans out each frame it hands back (`hostFrame`). Everything else here is as it was.
|
|
44
50
|
* =============================================================================
|
|
45
51
|
*/
|
|
46
52
|
import { aiName, skillPreset, stripAi } from './agents.mjs';
|
|
@@ -49,7 +55,7 @@ import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normaliz
|
|
|
49
55
|
|
|
50
56
|
export const NET_VERSION = 1;
|
|
51
57
|
/** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
|
|
52
|
-
export const NET_REVISION =
|
|
58
|
+
export const NET_REVISION = 10;
|
|
53
59
|
/**
|
|
54
60
|
* Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
|
|
55
61
|
* before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
|
|
@@ -376,6 +382,180 @@ export class NetRoom {
|
|
|
376
382
|
this.decidePending = 0;
|
|
377
383
|
this.watchIds = new WeakMap();
|
|
378
384
|
this.defaultChat = null;
|
|
385
|
+
/**
|
|
386
|
+
* Revision 10: the server as host (`setServerHost`), the seats it has been told of (seat → the stay's number), why
|
|
387
|
+
* it could not start (`hostFailed`: the room then refuses joins and says why), and what the Table does when the
|
|
388
|
+
* room forgets everything (`onForget`: it ends the host runtime).
|
|
389
|
+
*/
|
|
390
|
+
this.server = null;
|
|
391
|
+
this.hosted = new Map();
|
|
392
|
+
this.hostFailed = null;
|
|
393
|
+
this.onForget = null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ------------------------------------------------------------ the server as host (revision 10, section 29) */
|
|
397
|
+
|
|
398
|
+
/** The host runtime of a server-hosted room (`{ frame(m), facts() }`), or null. Seats already here are said as joins. */
|
|
399
|
+
setServerHost(h) {
|
|
400
|
+
this.server = h;
|
|
401
|
+
this.hostFailed = null;
|
|
402
|
+
this.hostId = null;
|
|
403
|
+
this.preferHost = null;
|
|
404
|
+
this.hosted.clear();
|
|
405
|
+
if (!h) return;
|
|
406
|
+
this.askedMax = null; this.maxPlayers = this.seatCap;
|
|
407
|
+
// Restored holders also need a later free frame, even if their socket never returns.
|
|
408
|
+
for (const [seat, s] of this.seats) this.hosted.set(seat, s.occ ?? null);
|
|
409
|
+
this.toServer({ t: 'policy', policy: this.policyOut() });
|
|
410
|
+
if (this.rawVocab) this.toServer({ t: 'vocabulary', vocab: this.rawVocab });
|
|
411
|
+
for (const c of this.live()) if (c.seat !== null && !c.watch) this.joinServer(c);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The rules could not start: the room refuses joins and says why. It never falls back to a browser by itself. */
|
|
415
|
+
failServerHost(message) {
|
|
416
|
+
this.server = null;
|
|
417
|
+
this.hostFailed = String(message ?? 'this game\'s rules could not start').slice(0, 200);
|
|
418
|
+
this.log({ ev: 'host-failed', room: this.code, error: this.hostFailed });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** One parsed frame for the host runtime. Rules that throw are caught there; anything thrown here is the runtime's own failure, logged with the room. */
|
|
422
|
+
toServer(m) {
|
|
423
|
+
if (!this.server) return;
|
|
424
|
+
try { this.server.frame(m); } catch (error) { this.stats.failed = (this.stats.failed ?? 0) + 1; this.log({ ev: 'failed', room: this.code, op: 'host-frame', frame: m.t, error: errorLine(error) }); }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
joinServer(c) {
|
|
428
|
+
this.hosted.set(c.seat, this.seats.get(c.seat)?.occ ?? null);
|
|
429
|
+
this.toServer({ t: 'join', peer: this.peer(c) });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A seat the host runtime knows that is no longer held (its hold ran out, the owner removed its player): its body's seat is given up. */
|
|
433
|
+
syncServerSeats() {
|
|
434
|
+
if (!this.server) return;
|
|
435
|
+
for (const [seat, occ] of [...this.hosted]) {
|
|
436
|
+
const s = this.seats.get(seat);
|
|
437
|
+
if (s && (s.occ ?? null) === occ) continue;
|
|
438
|
+
this.hosted.delete(seat);
|
|
439
|
+
this.toServer({ t: 'free', seat });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* One frame from the host runtime, fanned out as a browser host's would be: `snap`, `state`, `round`, `roster`,
|
|
445
|
+
* `caps` and `ev`. `text` is the frame already encoded, when the runtime encoded it, so nothing is encoded twice.
|
|
446
|
+
*/
|
|
447
|
+
hostFrame(m, text = null) {
|
|
448
|
+
if (!this.server || !m || typeof m.t !== 'string') return;
|
|
449
|
+
const now = this.now();
|
|
450
|
+
switch (m.t) {
|
|
451
|
+
case 'snap': return this.hostSnap(null, m, now, text);
|
|
452
|
+
case 'state': return this.hostState(null, m, 0);
|
|
453
|
+
case 'round': return this.hostRound(null, m);
|
|
454
|
+
case 'roster': return this.hostRoster(null, m);
|
|
455
|
+
case 'caps': return this.hostCaps(null, m);
|
|
456
|
+
case 'decide': return this.onDecide(null, m, true);
|
|
457
|
+
case 'ev': {
|
|
458
|
+
const kind = String(m.k ?? '').slice(0, 64);
|
|
459
|
+
if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
|
|
460
|
+
if (d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
|
|
461
|
+
if (kind === 'agent:view' && !this.agentViewOk(m, JSON.stringify(m).length, now, true)) return;
|
|
462
|
+
this.stats.evs += 1;
|
|
463
|
+
const out = { t: 'ev', from: Number.isInteger(m.from) ? m.from : null, k: kind, d: m.d ?? null };
|
|
464
|
+
const line = JSON.stringify(out);
|
|
465
|
+
if (Number.isInteger(m.to)) { for (const o of this.live()) if (o.seat === m.to) this.sendText(o.conn, line); return; }
|
|
466
|
+
for (const o of this.live()) if (!this.lite(o) || this.liteHears(kind)) this.sendText(o.conn, line);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
default: return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A snapshot from the host (a browser's socket `c`, or the server: `c` null), kept for joiners and fanned out. */
|
|
474
|
+
hostSnap(c, m, now, text = null) {
|
|
475
|
+
// The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
|
|
476
|
+
const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
|
|
477
|
+
if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
|
|
478
|
+
// Revision 10: a rules game's snapshot names the room's epoch.
|
|
479
|
+
if (!c && Number.isFinite(m.e)) snap.e = m.e;
|
|
480
|
+
this.lastSnap = snap;
|
|
481
|
+
if (c) c.lastSnapAt = now;
|
|
482
|
+
this.stats.snaps += 1;
|
|
483
|
+
this.snapTimes.push(now);
|
|
484
|
+
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
485
|
+
// A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
|
|
486
|
+
// (the host is alive), and replicas are told which it is.
|
|
487
|
+
const out = text ?? JSON.stringify({ t: 'snap', from: c ? c.seat : null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
|
|
488
|
+
if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
|
|
489
|
+
this.lastSnapText = out;
|
|
490
|
+
for (const o of this.others(c)) {
|
|
491
|
+
if (this.lite(o)) continue; // an agent with no game client draws nothing
|
|
492
|
+
// A congested socket skips a snapshot rather than queueing a stale one.
|
|
493
|
+
if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
|
|
494
|
+
this.sendText(o.conn, out);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** One key of the slow state channel from the host. `bytes`: the frame's size, counted against the room's cap for a browser host. */
|
|
499
|
+
hostState(c, m, bytes) {
|
|
500
|
+
const key = typeof m.k === 'string' ? m.k.slice(0, 64) : '';
|
|
501
|
+
if (!key) return;
|
|
502
|
+
const prev = this.state.get(key);
|
|
503
|
+
if (m.d === null || m.d === undefined) {
|
|
504
|
+
if (!prev) return;
|
|
505
|
+
this.state.delete(key);
|
|
506
|
+
this.stateBytes -= prev.bytes;
|
|
507
|
+
} else {
|
|
508
|
+
if (c && ((!prev && this.state.size >= STATE_CAPS.keys) || this.stateBytes - (prev?.bytes ?? 0) + bytes > STATE_CAPS.bytes)) {
|
|
509
|
+
this.error(c, 'state-full', `state is capped at ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.state.set(key, { d: m.d, bytes });
|
|
513
|
+
this.stateBytes += bytes - (prev?.bytes ?? 0);
|
|
514
|
+
}
|
|
515
|
+
this.stats.states += 1;
|
|
516
|
+
this.persistDirty = true;
|
|
517
|
+
const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
|
|
518
|
+
for (const o of this.others(c)) if (!this.lite(o)) this.sendText(o.conn, out);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
hostRound(c, m) {
|
|
522
|
+
if (!m.round || typeof m.round !== 'object') return;
|
|
523
|
+
// The results name every AI as AI, whatever the host's game says (section 17).
|
|
524
|
+
const round = Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
|
|
525
|
+
const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
|
|
526
|
+
this.lastRound = round;
|
|
527
|
+
// The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
|
|
528
|
+
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);
|
|
529
|
+
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);
|
|
530
|
+
this.stats.rounds += 1;
|
|
531
|
+
this.persistDirty = true;
|
|
532
|
+
for (const o of this.others(c)) this.send(o, { t: 'round', round });
|
|
533
|
+
// A party's first live round with AI seats: the vote on how strong the AI should be (once per 30 minutes).
|
|
534
|
+
if (round.phase === 'live' && wasLive !== Number(round.n)) this.autoVote();
|
|
535
|
+
this.tellWatchers();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
hostRoster(c, m) {
|
|
539
|
+
if (!Array.isArray(m.slots)) return;
|
|
540
|
+
// A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
|
|
541
|
+
this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
|
|
542
|
+
this.persistDirty = true;
|
|
543
|
+
for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
|
|
544
|
+
this.tellWatchers();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
hostCaps(c, m) {
|
|
548
|
+
// The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
|
|
549
|
+
if (!Array.isArray(m.caps)) return;
|
|
550
|
+
if (c) { for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k); this.caps = new Set(c.caps); } else this.caps = new Set(m.caps.filter((k) => CAPS.includes(k)));
|
|
551
|
+
this.persistDirty = true;
|
|
552
|
+
this.tellWatchers();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The room's policy, to everyone in it and to the server host. */
|
|
556
|
+
tellPolicy() {
|
|
557
|
+
this.broadcast({ t: 'policy', policy: this.policyOut() });
|
|
558
|
+
this.toServer({ t: 'policy', policy: this.policyOut() });
|
|
379
559
|
}
|
|
380
560
|
|
|
381
561
|
/* ------------------------------------------------------------ sockets */
|
|
@@ -395,7 +575,7 @@ export class NetRoom {
|
|
|
395
575
|
// An agent (section 17): only the Worker's word, from a verified pass, makes one; its hello cannot.
|
|
396
576
|
agentWord: conn.agent && typeof conn.agent === 'object' ? conn.agent : null, agent: null, caps: new Set(), speechAt: [],
|
|
397
577
|
// A badge the studio's Worker verified (the shop: a supporter's); a hello cannot set one.
|
|
398
|
-
badge: typeof conn.badge === 'string'
|
|
578
|
+
badge: typeof conn.badge === 'string' ? conn.badge : null,
|
|
399
579
|
// Revision 7: the latest view the host showed this AI (what its arguments are checked against), and its pace.
|
|
400
580
|
lastView: null, viewAt: 0, doAt: 0,
|
|
401
581
|
// Revision 9: the game revision this socket's page was served with. The Worker's word (it is in the socket's
|
|
@@ -473,7 +653,7 @@ export class NetRoom {
|
|
|
473
653
|
live() { return [...this.clients.values()].filter((c) => c.helloed); }
|
|
474
654
|
others(c) { return this.live().filter((o) => o !== c); }
|
|
475
655
|
host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
|
|
476
|
-
hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
|
|
656
|
+
hostRef() { if (this.server) return { id: 'server', seat: null }; const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
|
|
477
657
|
roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
|
|
478
658
|
peer(c) {
|
|
479
659
|
// Revision 9: which stay in the seat this is (`occ`), the peer's game revision and what its build can do.
|
|
@@ -513,8 +693,9 @@ export class NetRoom {
|
|
|
513
693
|
*/
|
|
514
694
|
onDecide(c, m, isHost) {
|
|
515
695
|
const n = typeof m.n === 'string' || Number.isInteger(m.n) ? String(m.n).slice(0, 16) : '';
|
|
516
|
-
const
|
|
517
|
-
|
|
696
|
+
const server = this.server;
|
|
697
|
+
const reply = (o) => { const frame = { t: 'decided', n, ...o }; if (c) this.send(c, frame); else if (server && this.server === server) this.toServer(frame); };
|
|
698
|
+
if (!isHost || (c && this.lite(c))) return reply({ ok: false, why: 'not-host' });
|
|
518
699
|
if (typeof this.decider !== 'function') return reply({ ok: false, why: 'off' });
|
|
519
700
|
const now = this.now();
|
|
520
701
|
while (this.decideTimes.length && this.decideTimes[0] <= now - 60_000) this.decideTimes.shift();
|
|
@@ -582,28 +763,18 @@ export class NetRoom {
|
|
|
582
763
|
switch (m.t) {
|
|
583
764
|
case 'snap': {
|
|
584
765
|
if (!isHost) return;
|
|
585
|
-
|
|
586
|
-
if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
|
|
587
|
-
this.lastSnap = snap;
|
|
588
|
-
c.lastSnapAt = now;
|
|
589
|
-
this.stats.snaps += 1;
|
|
590
|
-
this.snapTimes.push(now);
|
|
591
|
-
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
592
|
-
// A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
|
|
593
|
-
// (the host is alive), and replicas are told which it is.
|
|
594
|
-
const out = JSON.stringify({ t: 'snap', from: c.seat, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
|
|
595
|
-
if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
|
|
596
|
-
this.lastSnapText = out;
|
|
597
|
-
for (const o of this.others(c)) {
|
|
598
|
-
if (this.lite(o)) continue; // an agent with no game client draws nothing
|
|
599
|
-
// A congested socket skips a snapshot rather than queueing a stale one.
|
|
600
|
-
if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
|
|
601
|
-
this.sendText(o.conn, out);
|
|
602
|
-
}
|
|
603
|
-
return;
|
|
766
|
+
return this.hostSnap(c, m, now);
|
|
604
767
|
}
|
|
605
768
|
case 'in': {
|
|
606
769
|
if (isHost || c.seat === null || this.lite(c)) return;
|
|
770
|
+
// Revision 10: a rules game's input goes to the server host as it came (`e`, `k`, `s`, `r`); the runtime holds
|
|
771
|
+
// every value to its declared type, so nothing here needs to know the game's fields.
|
|
772
|
+
if (this.server) {
|
|
773
|
+
if (!Array.isArray(m.s)) return;
|
|
774
|
+
this.stats.ins += 1;
|
|
775
|
+
this.toServer({ t: 'in', from: c.seat, e: Number(m.e) || 0, k: Number(m.k) || 0, s: m.s.slice(0, 64), r: Number(m.r) || 0 });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
607
778
|
const h = this.host();
|
|
608
779
|
if (!h) return;
|
|
609
780
|
this.stats.ins += 1;
|
|
@@ -632,12 +803,7 @@ export class NetRoom {
|
|
|
632
803
|
// A line the host relays for an AI (`d.ai`): still only a line of the vocabulary, with fitting arguments.
|
|
633
804
|
if (isHost && d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
|
|
634
805
|
// The host shows one AI the game (`agent:view`): to that AI's seat only, small, at most one every 2 s.
|
|
635
|
-
if (kind === 'agent:view')
|
|
636
|
-
const o = isHost && Number.isInteger(m.to) ? this.live().find((x) => x.seat === m.to && x.agent) : null;
|
|
637
|
-
if (!o || text.length > AGENT_FRAMES.viewBytes || now - o.viewAt < AGENT_FRAMES.viewMs - 250) { this.stats.agentDrops += 1; return; }
|
|
638
|
-
o.viewAt = now;
|
|
639
|
-
o.lastView = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : null;
|
|
640
|
-
}
|
|
806
|
+
if (kind === 'agent:view' && !this.agentViewOk(m, text.length, now, isHost)) return;
|
|
641
807
|
this.stats.evs += 1;
|
|
642
808
|
const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
|
|
643
809
|
if (isHost) {
|
|
@@ -647,7 +813,8 @@ export class NetRoom {
|
|
|
647
813
|
else for (const o of this.others(c)) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
|
|
648
814
|
} else {
|
|
649
815
|
const h = this.host();
|
|
650
|
-
if (
|
|
816
|
+
if (this.server) this.toServer({ ...out, id: c.id });
|
|
817
|
+
else if (h) this.send(h, { ...out, id: c.id });
|
|
651
818
|
// The lite feed (section 18): an AI with no game client hears the party's lines, and an ask made of it.
|
|
652
819
|
if (!c.agent) this.copyToLite(c, kind, out);
|
|
653
820
|
}
|
|
@@ -663,62 +830,19 @@ export class NetRoom {
|
|
|
663
830
|
}
|
|
664
831
|
case 'state': {
|
|
665
832
|
if (!isHost) return;
|
|
666
|
-
|
|
667
|
-
if (!key) return;
|
|
668
|
-
const prev = this.state.get(key);
|
|
669
|
-
if (m.d === null || m.d === undefined) {
|
|
670
|
-
if (!prev) return;
|
|
671
|
-
this.state.delete(key);
|
|
672
|
-
this.stateBytes -= prev.bytes;
|
|
673
|
-
} else {
|
|
674
|
-
const bytes = text.length;
|
|
675
|
-
if ((!prev && this.state.size >= STATE_CAPS.keys) || this.stateBytes - (prev?.bytes ?? 0) + bytes > STATE_CAPS.bytes) {
|
|
676
|
-
this.error(c, 'state-full', `state is capped at ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B`);
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
this.state.set(key, { d: m.d, bytes });
|
|
680
|
-
this.stateBytes += bytes - (prev?.bytes ?? 0);
|
|
681
|
-
}
|
|
682
|
-
this.stats.states += 1;
|
|
683
|
-
this.persistDirty = true;
|
|
684
|
-
const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
|
|
685
|
-
for (const o of this.others(c)) if (!this.lite(o)) this.sendText(o.conn, out);
|
|
686
|
-
return;
|
|
833
|
+
return this.hostState(c, m, text.length);
|
|
687
834
|
}
|
|
688
835
|
case 'round': {
|
|
689
|
-
if (!isHost
|
|
690
|
-
|
|
691
|
-
const round = Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
|
|
692
|
-
const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
|
|
693
|
-
this.lastRound = round;
|
|
694
|
-
// The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
|
|
695
|
-
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);
|
|
696
|
-
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);
|
|
697
|
-
this.stats.rounds += 1;
|
|
698
|
-
this.persistDirty = true;
|
|
699
|
-
for (const o of this.others(c)) this.send(o, { t: 'round', round });
|
|
700
|
-
// A party's first live round with AI seats: the vote on how strong the AI should be (once per 30 minutes).
|
|
701
|
-
if (round.phase === 'live' && wasLive !== Number(round.n)) this.autoVote();
|
|
702
|
-
this.tellWatchers();
|
|
703
|
-
return;
|
|
836
|
+
if (!isHost) return;
|
|
837
|
+
return this.hostRound(c, m);
|
|
704
838
|
}
|
|
705
839
|
case 'roster': {
|
|
706
|
-
if (!isHost
|
|
707
|
-
|
|
708
|
-
this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
|
|
709
|
-
this.persistDirty = true;
|
|
710
|
-
for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
|
|
711
|
-
this.tellWatchers();
|
|
712
|
-
return;
|
|
840
|
+
if (!isHost) return;
|
|
841
|
+
return this.hostRoster(c, m);
|
|
713
842
|
}
|
|
714
843
|
case 'caps': {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
|
|
718
|
-
this.caps = new Set(c.caps);
|
|
719
|
-
this.persistDirty = true;
|
|
720
|
-
this.tellWatchers();
|
|
721
|
-
return;
|
|
844
|
+
if (!isHost) return;
|
|
845
|
+
return this.hostCaps(c, m);
|
|
722
846
|
}
|
|
723
847
|
case 'vote': {
|
|
724
848
|
this.onVote(c, m);
|
|
@@ -778,6 +902,11 @@ export class NetRoom {
|
|
|
778
902
|
this.clients.delete(c.id);
|
|
779
903
|
try { c.conn.close(1008, code); } catch { /* gone */ }
|
|
780
904
|
};
|
|
905
|
+
// Revision 10: this game's rules run on the server and could not start. Nobody is let in, and the reason is said.
|
|
906
|
+
if (this.endedMatch?.until <= this.now()) this.endedMatch = null;
|
|
907
|
+
if (this.endedMatch?.tokens.has(m.token)) return refuse('room-over', 'This match ended. Joining a fresh room.', { rematch: true, ver: this.endedMatch.ver });
|
|
908
|
+
if (this.ended) return refuse('room-over', 'This room ended. Joining a fresh room.', { rematch: true, ver: this.ended.ver });
|
|
909
|
+
if (this.hostFailed) return refuse('host-failed', this.hostFailed);
|
|
781
910
|
// The owner closed this room, or held this player out of it (section 15): refused, with when it ends.
|
|
782
911
|
if (this.closedUntil > this.now()) return refuse('room-closed', this.closedWhy || 'The studio closed this room.', { until: this.closedUntil });
|
|
783
912
|
const ban = this.banOf(c, typeof m.token === 'string' ? m.token : '');
|
|
@@ -804,7 +933,8 @@ export class NetRoom {
|
|
|
804
933
|
c.ver = c.verWord !== undefined ? c.verWord : versionOf(m.ver);
|
|
805
934
|
c.feat = featuresOf(m.feat);
|
|
806
935
|
const lite = Boolean(c.agentWord && c.agentWord.hands === 'host');
|
|
807
|
-
if (!lite) {
|
|
936
|
+
if (!lite && this.server && this.currentVer !== undefined && c.ver !== this.currentVer) return refuse('stale', 'This game was updated. Loading it again.', { ver: this.currentVer, immediate: true });
|
|
937
|
+
if (!lite && !this.server) {
|
|
808
938
|
const here = live.filter((o) => !this.lite(o));
|
|
809
939
|
const cur = this.currentVer;
|
|
810
940
|
if (here.length && (this.gameVer ?? null) !== c.ver) {
|
|
@@ -839,10 +969,22 @@ export class NetRoom {
|
|
|
839
969
|
c.typed = c.agent || this.policy.kids ? '' : typeof m.name === 'string' ? stripAi(m.name.replace(/\s+/g, ' ').trim()).slice(0, 24) : '';
|
|
840
970
|
if (Array.isArray(m.caps)) for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
|
|
841
971
|
// The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
|
|
842
|
-
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)); }
|
|
972
|
+
if (!this.server && Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
|
|
843
973
|
this.reapSeats(now);
|
|
974
|
+
// The verified pass names one AI. Reopening its socket resumes that seat and replaces the old socket.
|
|
975
|
+
const agentStay = c.agent?.pass ? [...this.seats.values()].find(s => s.agent?.pass === c.agent.pass) : null;
|
|
976
|
+
const resumeToken = agentStay?.token ?? (typeof m.token === 'string' ? m.token : '');
|
|
977
|
+
const resumeBan = agentStay && this.banOf(c, resumeToken);
|
|
978
|
+
if (resumeBan) return refuse('kicked', resumeBan.message, { until: resumeBan.until });
|
|
979
|
+
// Pace the verified pass's stay, not its replaceable socket. Refusal cannot evict its current holder.
|
|
980
|
+
if (agentStay) {
|
|
981
|
+
const times = (agentStay.reopenTimes ?? []).filter(at => at > now - 60_000);
|
|
982
|
+
const retryMs = Math.max((times.at(-1) ?? -Infinity) + 4000 - now, times.length >= 8 ? times[0] + 60_000 - now : 0);
|
|
983
|
+
if (retryMs > 0) return refuse('agent-pace', 'this AI seat reopened too recently', { retryMs, final: false });
|
|
984
|
+
agentStay.reopenTimes = [...times, now];
|
|
985
|
+
}
|
|
844
986
|
let full = false;
|
|
845
|
-
if (c.want === 'play' && !this.seatClient(c,
|
|
987
|
+
if (c.want === 'play' && !this.seatClient(c, resumeToken)) {
|
|
846
988
|
// The studio's own house guide (a loopback) makes way for an AI with a pass (the owner's Claude, say).
|
|
847
989
|
const house = c.agent && !c.conn.loopback ? this.live().find((o) => o.agent && o.conn.loopback && o.seat !== null) : null;
|
|
848
990
|
if (house) {
|
|
@@ -872,7 +1014,8 @@ export class NetRoom {
|
|
|
872
1014
|
// A watcher hosts only a room nobody else can host (a player who can is elected first), and never deposes one;
|
|
873
1015
|
// an agent hosts only a room no person can host.
|
|
874
1016
|
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));
|
|
875
|
-
|
|
1017
|
+
// A browser never becomes host of a server-hosted room.
|
|
1018
|
+
if (!this.server && c.canHost && !lastResort && (preferred === undefined || preferred === c.seat)) {
|
|
876
1019
|
if (!h) {
|
|
877
1020
|
role = 'host';
|
|
878
1021
|
why = this.lastSnap || this.lastCkpt ? 'resumed' : 'first';
|
|
@@ -915,6 +1058,8 @@ export class NetRoom {
|
|
|
915
1058
|
...(c.agent ? { agent: { ...c.agent, name: c.name } } : {}),
|
|
916
1059
|
});
|
|
917
1060
|
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
1061
|
+
// The server host hears of every seat taken (a reconnect is the same stay: its body is back, not new).
|
|
1062
|
+
if (this.server && c.seat !== null) { this.syncServerSeats(); this.joinServer(c); }
|
|
918
1063
|
// A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
|
|
919
1064
|
this.refreshWatchers(c);
|
|
920
1065
|
if (deposed) {
|
|
@@ -1003,6 +1148,7 @@ export class NetRoom {
|
|
|
1003
1148
|
this.stats.seated += 1;
|
|
1004
1149
|
this.send(c, { t: 'seat', seat: c.seat, token: c.token, name: c.name, colour: c.colour, role: this.roleOf(c) });
|
|
1005
1150
|
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
1151
|
+
if (this.server) { this.syncServerSeats(); this.joinServer(c); }
|
|
1006
1152
|
this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
|
|
1007
1153
|
this.persist();
|
|
1008
1154
|
this.refreshWatchers();
|
|
@@ -1015,9 +1161,20 @@ export class NetRoom {
|
|
|
1015
1161
|
/** The game's agents.json (the Table reads it once from the build): the only goals and lines an AI here has. */
|
|
1016
1162
|
setVocabulary(raw) {
|
|
1017
1163
|
this.vocab = raw ? vocabularyOf(raw).vocab : null;
|
|
1164
|
+
this.rawVocab = this.vocab ? raw : null;
|
|
1165
|
+
if (this.server) this.toServer({ t: 'vocabulary', vocab: this.rawVocab });
|
|
1018
1166
|
return Boolean(this.vocab);
|
|
1019
1167
|
}
|
|
1020
1168
|
|
|
1169
|
+
/** Browser and server hosts share the same view validation and pacing. */
|
|
1170
|
+
agentViewOk(m, bytes, now, isHost) {
|
|
1171
|
+
const o = isHost && Number.isInteger(m.to) ? this.live().find((x) => x.seat === m.to && x.agent) : null;
|
|
1172
|
+
if (!o || bytes > AGENT_FRAMES.viewBytes || now - o.viewAt < AGENT_FRAMES.viewMs - 250) { this.stats.agentDrops += 1; return false; }
|
|
1173
|
+
o.viewAt = now;
|
|
1174
|
+
o.lastView = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : null;
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1021
1178
|
/** The seats people hold now (a "player" argument names one of them). */
|
|
1022
1179
|
peopleSeats() { return this.seatedHumans().map((c) => c.seat); }
|
|
1023
1180
|
|
|
@@ -1032,14 +1189,14 @@ export class NetRoom {
|
|
|
1032
1189
|
const d = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : {};
|
|
1033
1190
|
if (kind === 'agent:do') {
|
|
1034
1191
|
if (c.id === this.hostId || !this.vocab || bytes > 1024 || now - c.doAt < AGENT_FRAMES.doMs) return false;
|
|
1035
|
-
const goal = this.vocab.goals[d.goal];
|
|
1192
|
+
const goal = (typeof d.goal === 'string' && Object.hasOwn(this.vocab.goals, d.goal) ? this.vocab.goals[d.goal] : null);
|
|
1036
1193
|
if (typeof d.goal !== 'string' || !goal || checkArgs(goal.args, d.args, ctx)) return false;
|
|
1037
1194
|
c.doAt = now;
|
|
1038
1195
|
return true;
|
|
1039
1196
|
}
|
|
1040
1197
|
if (SPEECH.test(kind)) {
|
|
1041
1198
|
const id = SAY.exec(kind)?.[1];
|
|
1042
|
-
const line = id && this.vocab ? this.vocab.lines[id] : null;
|
|
1199
|
+
const line = id && this.vocab ? (Object.hasOwn(this.vocab.lines, id) ? this.vocab.lines[id] : null) : null;
|
|
1043
1200
|
return Boolean(line && talks(this.policy) && bytes <= 1024 && !checkArgs(line.args, d.args, ctx));
|
|
1044
1201
|
}
|
|
1045
1202
|
return !this.lite(c);
|
|
@@ -1052,7 +1209,7 @@ export class NetRoom {
|
|
|
1052
1209
|
*/
|
|
1053
1210
|
aiLineOk(kind, d) {
|
|
1054
1211
|
const id = SAY.exec(kind)?.[1];
|
|
1055
|
-
const line = id && this.vocab ? this.vocab.lines[id] : null;
|
|
1212
|
+
const line = id && this.vocab ? (Object.hasOwn(this.vocab.lines, id) ? this.vocab.lines[id] : null) : null;
|
|
1056
1213
|
if (!line || !talks(this.policy)) return false;
|
|
1057
1214
|
const speaker = Number.isInteger(d.seat) ? this.live().find((o) => o.seat === d.seat && o.agent) : null;
|
|
1058
1215
|
if (Number.isInteger(d.seat) && !speaker) return false;
|
|
@@ -1069,7 +1226,7 @@ export class NetRoom {
|
|
|
1069
1226
|
const ask = ASK.exec(kind)?.[1];
|
|
1070
1227
|
if (ask) {
|
|
1071
1228
|
const seat = out.d && Number.isInteger(out.d.seat) ? out.d.seat : null;
|
|
1072
|
-
if (seat === null || (this.vocab && !this.vocab.asks
|
|
1229
|
+
if (seat === null || (this.vocab && !Object.hasOwn(this.vocab.asks, ask))) return;
|
|
1073
1230
|
for (const o of this.live()) if (o !== c && this.lite(o) && o.seat === seat) this.send(o, out);
|
|
1074
1231
|
return;
|
|
1075
1232
|
}
|
|
@@ -1375,6 +1532,8 @@ export class NetRoom {
|
|
|
1375
1532
|
this.hostId = null;
|
|
1376
1533
|
this.elect(null, why === 'replaced' ? 'host-replaced' : why === 'silent' ? 'host-stalled' : 'host-left');
|
|
1377
1534
|
}
|
|
1535
|
+
// The server host: the socket closed and the seat is held, so the body is away.
|
|
1536
|
+
if (this.server && c.seat !== null) this.toServer({ t: 'leave', id: c.id, seat: c.seat, why });
|
|
1378
1537
|
if (c.seat !== null && !c.agent) this.lastHumanAt = now;
|
|
1379
1538
|
// An agent never keeps a room from forgetting (DESIGN D9): the room is empty when no person is left.
|
|
1380
1539
|
if (!this.live().some((o) => !o.agent) && !this.emptySince) this.emptySince = now;
|
|
@@ -1389,6 +1548,8 @@ export class NetRoom {
|
|
|
1389
1548
|
* is handed the last checkpoint, the last snapshot (newer), the round, the roster and the keyed state.
|
|
1390
1549
|
*/
|
|
1391
1550
|
elect(excludeId, why) {
|
|
1551
|
+
// The server is never elected away.
|
|
1552
|
+
if (this.server) return null;
|
|
1392
1553
|
const now = this.now();
|
|
1393
1554
|
const silent = (c) => Number(now - c.lastSeen > this.silentMs);
|
|
1394
1555
|
const tenure = (c) => Math.floor((now - c.joinedAt) / 30_000);
|
|
@@ -1458,7 +1619,7 @@ export class NetRoom {
|
|
|
1458
1619
|
}
|
|
1459
1620
|
}
|
|
1460
1621
|
// Nobody hosting (a restored room whose old host did not come back in time): elect.
|
|
1461
|
-
if (!this.host() && (!this.preferHost || now >= this.preferHost.until)) {
|
|
1622
|
+
if (!this.server && !this.host() && (!this.preferHost || now >= this.preferHost.until)) {
|
|
1462
1623
|
this.preferHost = null;
|
|
1463
1624
|
if (this.live().some((c) => c.canHost)) this.elect(null, 'host-left');
|
|
1464
1625
|
}
|
|
@@ -1474,31 +1635,37 @@ export class NetRoom {
|
|
|
1474
1635
|
}
|
|
1475
1636
|
}
|
|
1476
1637
|
this.reapSeats(now);
|
|
1638
|
+
this.syncServerSeats();
|
|
1477
1639
|
this.seatWaiting();
|
|
1478
1640
|
// The chat window is minutes long; buckets of senders who went quiet are dropped.
|
|
1479
1641
|
const keepMs = windowMsOf(this.chatRules());
|
|
1480
1642
|
while (this.chatLog.length && now - this.chatLog[0].at > keepMs) this.chatLog.shift();
|
|
1481
1643
|
if (this.chatBuckets.size > 512) this.chatBuckets.clear();
|
|
1482
1644
|
if (this.chatAddress.size > 512) this.chatAddress.clear();
|
|
1483
|
-
if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs)
|
|
1484
|
-
for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
|
|
1485
|
-
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
1486
|
-
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
|
|
1487
|
-
this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
|
|
1488
|
-
// The room is nobody's build again (section 23): the next visitor's is its build.
|
|
1489
|
-
this.gameVer = undefined;
|
|
1490
|
-
// The party's dial and vote are the room's: a new party starts from the server's level.
|
|
1491
|
-
this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
|
|
1492
|
-
// Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
|
|
1493
|
-
this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
|
|
1494
|
-
// A room that keeps history reads it back when somebody comes again (it is in D1, not here).
|
|
1495
|
-
this.hydrated = false;
|
|
1496
|
-
this.seatsDirty = false; this.persistDirty = false;
|
|
1497
|
-
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
1498
|
-
}
|
|
1645
|
+
if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) this.forget();
|
|
1499
1646
|
this.persist(now);
|
|
1500
1647
|
}
|
|
1501
1648
|
|
|
1649
|
+
/** An EMPTY room forgets everything (after `forgetMs`, or when the Table's alarm ends a server-hosted room nobody came back to). */
|
|
1650
|
+
forget() {
|
|
1651
|
+
for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
|
|
1652
|
+
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
1653
|
+
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
|
|
1654
|
+
this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
|
|
1655
|
+
// The room is nobody's build again (section 23): the next visitor's is its build.
|
|
1656
|
+
this.gameVer = undefined;
|
|
1657
|
+
// The party's dial and vote are the room's: a new party starts from the server's level.
|
|
1658
|
+
this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
|
|
1659
|
+
// Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
|
|
1660
|
+
this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
|
|
1661
|
+
// A room that keeps history reads it back when somebody comes again (it is in D1, not here).
|
|
1662
|
+
this.hydrated = false;
|
|
1663
|
+
this.seatsDirty = false; this.persistDirty = false;
|
|
1664
|
+
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
1665
|
+
this.hosted.clear();
|
|
1666
|
+
try { this.onForget?.(); } catch { /* the room is forgotten either way */ }
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1502
1669
|
/* ------------------------------------------------------------ servers, agents and the dial (section 17) */
|
|
1503
1670
|
|
|
1504
1671
|
/**
|
|
@@ -1526,7 +1693,7 @@ export class NetRoom {
|
|
|
1526
1693
|
if (Array.isArray(this.lastRoster)) this.lastRoster = this.labelRoster(this.lastRoster);
|
|
1527
1694
|
this.seatsDirty = true;
|
|
1528
1695
|
this.officeDirty = true;
|
|
1529
|
-
this.
|
|
1696
|
+
this.tellPolicy();
|
|
1530
1697
|
this.tellWatchers();
|
|
1531
1698
|
return { ok: true, policy: this.policyOut(), leaving: this.agentsOut ? agents : 0, ...(this.agentsOut ? { until: this.agentsOut.until } : {}) };
|
|
1532
1699
|
}
|
|
@@ -1694,7 +1861,7 @@ export class NetRoom {
|
|
|
1694
1861
|
v.result = { level: this.level, name: s.name, votes: counted.length, why };
|
|
1695
1862
|
// Saved on the next tick, not within 30 s: a deploy right after the vote keeps the party's choice.
|
|
1696
1863
|
this.seatsDirty = true;
|
|
1697
|
-
this.
|
|
1864
|
+
this.tellPolicy();
|
|
1698
1865
|
} else v.result = { level: this.levelNow(), name: skillPreset(this.levelNow()).name, votes: 0, why };
|
|
1699
1866
|
this.tellVote();
|
|
1700
1867
|
return v.result;
|
|
@@ -1705,7 +1872,7 @@ export class NetRoom {
|
|
|
1705
1872
|
/** Seats, the policy and the party's dial on change; checkpoint/round/roster/state at most every persistMs. Never per frame. */
|
|
1706
1873
|
persist(now = this.now(), force = false) {
|
|
1707
1874
|
if (!this.store) return;
|
|
1708
|
-
const due = this.seatsDirty || (this.persistDirty && now - this.lastPersistAt >= this.persistMs);
|
|
1875
|
+
const due = this.seatsDirty || (!this.server && this.persistDirty && now - this.lastPersistAt >= this.persistMs);
|
|
1709
1876
|
if (!force && !due) return;
|
|
1710
1877
|
this.seatsDirty = false;
|
|
1711
1878
|
this.persistDirty = false;
|
|
@@ -1717,11 +1884,13 @@ export class NetRoom {
|
|
|
1717
1884
|
saved(now = this.now()) {
|
|
1718
1885
|
return {
|
|
1719
1886
|
v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null, openedAt: this.openedAt || null,
|
|
1720
|
-
seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name, s.agent ?? null, s.occ ?? null]),
|
|
1887
|
+
seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name, s.agent ?? null, s.occ ?? null, ...(this.server ? [s.present ? null : s.since] : [])]),
|
|
1721
1888
|
occSeq: this.occSeq, ...(this.gameVer !== undefined ? { gameVer: this.gameVer } : {}),
|
|
1722
1889
|
ckpt: this.lastCkpt, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
|
|
1723
1890
|
// Section 17: a deploy keeps the room's policy, the party's dial and what its game reads.
|
|
1724
1891
|
policy: this.policy, level: this.level, levelBy: this.levelBy, caps: [...this.caps],
|
|
1892
|
+
// The Table saves this seat map and the server's whole world in one transaction.
|
|
1893
|
+
...(this.server ? { hosted: 'server' } : {}),
|
|
1725
1894
|
};
|
|
1726
1895
|
}
|
|
1727
1896
|
|
|
@@ -1733,9 +1902,9 @@ export class NetRoom {
|
|
|
1733
1902
|
restore(saved, graceMs = 3000) {
|
|
1734
1903
|
const now = this.now();
|
|
1735
1904
|
if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
|
|
1736
|
-
if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
|
|
1905
|
+
if (!this.server && saved.hosted !== 'server' && Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
|
|
1737
1906
|
if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
|
|
1738
|
-
for (const [seat, token, name, agent, occ] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false, agent: agent && typeof agent === 'object' ? agent : null, ...(Number.isInteger(occ) ? { occ } : {}) });
|
|
1907
|
+
for (const [seat, token, name, agent, occ, since] of saved.seats ?? []) this.seats.set(seat, { token, name, since: saved.durable && Number.isFinite(since) ? since : now, present: false, agent: agent && typeof agent === 'object' ? agent : null, ...(Number.isInteger(occ) ? { occ } : {}) });
|
|
1739
1908
|
// A stay's number is never given twice while a checkpoint may still name it.
|
|
1740
1909
|
this.occSeq = Math.max(this.occSeq, Number.isInteger(saved.occSeq) ? saved.occSeq : 0, ...[...this.seats.values()].map((x) => x.occ ?? 0));
|
|
1741
1910
|
if ('gameVer' in saved) this.gameVer = versionOf(saved.gameVer);
|
|
@@ -1745,15 +1914,17 @@ export class NetRoom {
|
|
|
1745
1914
|
if (Array.isArray(saved.caps)) this.caps = new Set(saved.caps.filter((k) => CAPS.includes(k)));
|
|
1746
1915
|
// The people coming back get the agents' grace too (an AI is not "alone" in a room a deploy just emptied).
|
|
1747
1916
|
if ((saved.seats ?? []).some((s) => !s[3])) this.lastHumanAt = now;
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
this.
|
|
1751
|
-
|
|
1917
|
+
// A server-hosted room has no browser host to hand a checkpoint to, and what its last match showed is not this one's.
|
|
1918
|
+
const fresh = saved.hosted === 'server' && !saved.durable;
|
|
1919
|
+
this.lastCkpt = fresh ? null : saved.ckpt ?? null;
|
|
1920
|
+
this.lastRound = fresh ? null : saved.round ?? null;
|
|
1921
|
+
this.lastRoster = fresh ? null : saved.roster ?? null;
|
|
1922
|
+
for (const [k, d] of Object.entries(fresh ? {} : saved.state ?? {})) {
|
|
1752
1923
|
const bytes = JSON.stringify({ t: 'state', k, d }).length;
|
|
1753
1924
|
this.state.set(k, { d, bytes });
|
|
1754
1925
|
this.stateBytes += bytes;
|
|
1755
1926
|
}
|
|
1756
|
-
if (Number.isInteger(saved.hostSeat)) this.preferHost = { seat: saved.hostSeat, until: now + graceMs };
|
|
1927
|
+
if (Number.isInteger(saved.hostSeat) && saved.hosted !== 'server') this.preferHost = { seat: saved.hostSeat, until: now + graceMs };
|
|
1757
1928
|
this.emptySince = now;
|
|
1758
1929
|
this.stats.restoredFrom = now - saved.savedAt;
|
|
1759
1930
|
this.log({ ev: 'restored', room: this.code, seats: this.seats.size, ageMs: now - saved.savedAt, hostSeat: saved.hostSeat });
|
|
@@ -1947,6 +2118,8 @@ export class NetRoom {
|
|
|
1947
2118
|
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null; this.openedAt = 0; this.askedMax = null;
|
|
1948
2119
|
this.emptySince = now; this.seatsDirty = false; this.persistDirty = false;
|
|
1949
2120
|
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
2121
|
+
this.hosted.clear();
|
|
2122
|
+
try { this.onForget?.(); } catch { /* the room is closed either way */ }
|
|
1950
2123
|
const msg = JSON.stringify({ t: 'closed', room: this.code, until, message });
|
|
1951
2124
|
for (const w of this.watchers) this.sendText(w, msg);
|
|
1952
2125
|
this.tellWatchers();
|
|
@@ -1987,7 +2160,7 @@ export class NetRoom {
|
|
|
1987
2160
|
this.level = Math.min(this.policy.levelMax, n);
|
|
1988
2161
|
this.levelBy = 'owner';
|
|
1989
2162
|
this.seatsDirty = true;
|
|
1990
|
-
this.
|
|
2163
|
+
this.tellPolicy();
|
|
1991
2164
|
this.tellWatchers();
|
|
1992
2165
|
return { ok: true, op, level: this.level, name: skillPreset(this.level, { kids: this.policy.kids }).name };
|
|
1993
2166
|
}
|
|
@@ -2054,7 +2227,7 @@ export class NetRoom {
|
|
|
2054
2227
|
|
|
2055
2228
|
setSeats(n, perIp = null) {
|
|
2056
2229
|
this.seatCap = Math.max(1, Math.floor(n));
|
|
2057
|
-
this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.askedMax ?? this.seatCap));
|
|
2230
|
+
this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.server ? this.seatCap : this.askedMax ?? this.seatCap));
|
|
2058
2231
|
if (Number.isFinite(perIp) && perIp > 0) this.perIp = perIp;
|
|
2059
2232
|
}
|
|
2060
2233
|
|
|
@@ -2152,6 +2325,8 @@ export class NetRoom {
|
|
|
2152
2325
|
caps: [...this.caps],
|
|
2153
2326
|
// Revision 9: the build this room runs (null: the game names none, or nobody is here) and its stall time.
|
|
2154
2327
|
ver: this.gameVer ?? null, stallMs: this.stallMs,
|
|
2328
|
+
// Revision 10: the rules run on the server, and how its clock is keeping (ticks run, late ticks, the budget).
|
|
2329
|
+
...(this.server ? { hosted: 'server', ticks: this.server.facts?.() ?? null } : this.hostFailed ? { hosted: 'server', hostFailed: this.hostFailed } : {}),
|
|
2155
2330
|
// Revision 7: whether this room's game has a vocabulary its AI may speak (agents.json).
|
|
2156
2331
|
vocab: Boolean(this.vocab),
|
|
2157
2332
|
memory: {
|