@homie-rocks/studio 0.45.3 → 0.46.2

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.
@@ -1,28 +1,77 @@
1
+ import { normalizeWorld, denormalizeWorld, durableWorld, ownDurable, checkpointWorld, changes, applyChanges } from './world-journal.mjs';
1
2
  /**
2
- * A whole server-hosted room in SQLite. A small room updates one row; a large one uses pieces in the same synchronous
3
- * transaction. No index besides the integer primary key: the default is one row written per second, not two.
3
+ * Match rooms keep their whole save and its cadence. A lasting world (`persistent`) writes what the game declared
4
+ * durable the moment it changes, as one small journal transaction, and folds the rest of the room into a chunked
5
+ * checkpoint on an interval and on the way down. Restore is the last checkpoint and then the journal after it.
6
+ *
7
+ * The journal is a ring. Row 0 holds the floor: the last sequence number the checkpoint already contains, written in
8
+ * the checkpoint's own transaction. A record's row id is its sequence less the floor, so the records after a
9
+ * checkpoint overwrite the ones before it and a checkpoint never pays a row for each record it folds. A read takes
10
+ * only the rows above the floor, in order, and refuses a gap.
11
+ *
12
+ * Only integer primary keys: no secondary-index write amplification.
4
13
  * The boot row outlives a save being replayed, so two restores of that save can never reuse an epoch.
5
14
  */
6
- export function roomStore(storage) {
15
+ export function roomStore(storage, { persistent = false } = {}) {
7
16
  const sql = storage.sql;
8
17
  sql.exec('CREATE TABLE IF NOT EXISTS save (id INTEGER PRIMARY KEY, data BLOB NOT NULL)');
9
18
  sql.exec('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
19
+ let journalReady = false;
20
+ /** Opened once: a store that writes before it reads still continues the sequence on disk, so its checkpoint retires what is there. */
21
+ function journal() {
22
+ if (journalReady) return;
23
+ sql.exec('CREATE TABLE IF NOT EXISTS world_journal (id INTEGER PRIMARY KEY, seq INTEGER NOT NULL, data BLOB NOT NULL)');
24
+ floor = sql.exec('SELECT seq FROM world_journal WHERE id = 0').toArray()[0]?.seq ?? 0;
25
+ sequence = Math.max(floor, sql.exec('SELECT MAX(seq) AS seq FROM world_journal WHERE id > 0').toArray()[0]?.seq ?? 0);
26
+ journalReady = true;
27
+ }
28
+ // `base` is the world as last read or checkpointed, kept whole until a commit says which fields are durable;
29
+ // `durable` is the projection of the last world a commit saw, and is what the next one is compared with.
30
+ let sequence = 0, floor = 0, base = null, durable = null, checkpoint = null, checkpointAt = 0, checkpointTick = 0, committedInputs = null;
31
+ let lasting = persistent;
32
+ if (persistent) { const old = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); if (!old?.persistent) sql.exec('INSERT INTO boots(id,data) VALUES (1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', JSON.stringify({ epoch: 0, count: 0, ...old, persistent: true })); }
10
33
  const encoder = new TextEncoder();
11
34
  const decoder = new TextDecoder('utf-8', { fatal: true });
12
35
  const pieceBytes = 1_000_000;
36
+ const inputState = v => ({inputs:v.host?.inputs ?? [],queues:v.host?.queues ?? []});
37
+ /** What is on disk now equals `value`: the next commit is compared with it. */
38
+ function settle(value) {
39
+ base = normalizeWorld(structuredClone(value)); durable = null;
40
+ checkpoint = JSON.stringify(checkpointWorld(base)); checkpointAt = value.at ?? 0; checkpointTick = base.host?.core?.tick ?? 0; committedInputs = inputState(base);
41
+ }
13
42
  return {
14
43
  read() {
15
- if (this.boots()?.retired === true) return null;
44
+ const boot = this.boots();
45
+ if (boot?.retired === true) return null;
46
+ const maxBytes = boot?.persistent ? Infinity : 16_000_000;
16
47
  const total = sql.exec('SELECT SUM(length(data)) AS bytes FROM save').toArray()[0]?.bytes ?? 0;
17
- if (total > 16_000_000) throw new Error('save exceeds 16 MB');
48
+ if (total > maxBytes) throw new Error('save exceeds 16 MB');
18
49
  const rows = sql.exec('SELECT id, data FROM save ORDER BY id').toArray();
19
50
  if (!rows.length) return null;
20
- if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > 16_000_000) throw new Error('save pieces missing or save exceeds 16 MB');
51
+ if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > maxBytes) throw new Error('save pieces missing or save exceeds 16 MB');
21
52
  const pieces = rows.map((r) => new Uint8Array(r.data));
22
53
  const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
23
54
  let at = 0;
24
55
  for (const p of pieces) { bytes.set(p, at); at += p.length; }
25
- const value = JSON.parse(decoder.decode(bytes));
56
+ let value = JSON.parse(decoder.decode(bytes));
57
+ if (lasting) journal();
58
+ sequence = floor;
59
+ const journals = lasting ? sql.exec('SELECT id, seq, data FROM world_journal WHERE id > 0 AND seq > ? ORDER BY seq', floor).toArray() : [];
60
+ if (journals.length) {
61
+ let normalized = normalizeWorld(value);
62
+ let pending = '';
63
+ const stream = new TextDecoder('utf-8', {fatal:true});
64
+ for (const row of journals) {
65
+ if (row.seq !== sequence + 1 || row.id !== row.seq - floor) throw new Error('World journal has a gap');
66
+ pending += stream.decode(new Uint8Array(row.data), {stream:true}); sequence = row.seq;
67
+ let end;
68
+ while ((end = pending.indexOf('\n')) >= 0) { normalized = applyChanges(normalized, JSON.parse(pending.slice(0,end))); pending = pending.slice(end+1); }
69
+ }
70
+ pending += stream.decode();
71
+ if (pending) throw new Error('Incomplete world journal transaction');
72
+ value = denormalizeWorld(normalized);
73
+ }
74
+ if (lasting) settle(value);
26
75
  if (typeof value.host === 'string') {
27
76
  const hostText = value.host;
28
77
  value.host = JSON.parse(hostText);
@@ -30,19 +79,72 @@ export function roomStore(storage) {
30
79
  }
31
80
  return value;
32
81
  },
82
+ /**
83
+ * A lasting world's tick. Nothing durable changed and no checkpoint is due: no write. Something durable changed:
84
+ * one journal transaction holding just that. The interval has passed (or `force`) and the room differs from its
85
+ * checkpoint: the checkpoint, which takes the journal with it.
86
+ */
87
+ commit(value, { force = false, seconds = 30, tickHz = 20, fields }) {
88
+ journal();
89
+ if (!base) { this.write(value); return; }
90
+ const next = normalizeWorld(value);
91
+ durable ??= ownDurable(base, fields);
92
+ const moved = changes(durable, durableWorld(next, fields));
93
+ // The comparison is over sparse field maps. What is written is the full validated representation at each
94
+ // path, so a new entity is recorded with its initial pose.
95
+ const patches = moved.map((patch) => {
96
+ if (patch.length === 1) return patch;
97
+ let full = next;
98
+ for (const key of patch[0]) full = full[key];
99
+ return [patch[0], full];
100
+ });
101
+ if (force || value.at - checkpointAt >= seconds * 1000 || next.host.core.tick - checkpointTick >= seconds * tickHz) {
102
+ if (patches.length || JSON.stringify(checkpointWorld(next)) !== checkpoint) { this.write(value); return; }
103
+ // Still the checkpoint's own room: it is asked again an interval from now, not on every tick until then.
104
+ checkpointAt = value.at; checkpointTick = next.host.core.tick;
105
+ }
106
+ if (patches.length) {
107
+ // Input acknowledgements and pending pulses share the value transaction.
108
+ // A restart must not consume a checkpoint's already-spent pulse again.
109
+ const inputs = inputState(next);
110
+ changes(committedInputs, inputs, ['host'], patches);
111
+ // Advance logical time and RNG with a commitment, never once per tick.
112
+ for (const key of ['tick','rng','nextId','seq']) patches.push([['host','core',key],next.host.core[key]]);
113
+ patches.push([['at'],next.at]);
114
+ // One transaction may span several bounded rows. A read folds the whole
115
+ // transaction; no prefix can become visible across a crash.
116
+ const bytes = encoder.encode(JSON.stringify(patches) + '\n');
117
+ let last = sequence;
118
+ storage.transactionSync(() => {
119
+ for (let at = 0; at < bytes.length; at += pieceBytes) {
120
+ last += 1;
121
+ sql.exec('INSERT INTO world_journal (id, seq, data) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET seq = excluded.seq, data = excluded.data', last - floor, last, bytes.slice(at,at+pieceBytes));
122
+ }
123
+ });
124
+ sequence = last;
125
+ committedInputs = structuredClone(inputs);
126
+ }
127
+ // Brought up to this tick by what changed in it (each value copied), never by copying the room.
128
+ durable = applyChanges(durable, moved);
129
+ },
33
130
  write(value) {
131
+ if (lasting) journal();
34
132
  const bytes = encoder.encode(JSON.stringify(value));
35
- if (bytes.length > 16_000_000) throw new Error('save exceeds 16 MB');
133
+ if (bytes.length > 16_000_000 && !lasting) throw new Error('save exceeds 16 MB');
36
134
  storage.transactionSync(() => {
37
135
  let id = 0;
38
136
  for (let at = 0; at < bytes.length; at += pieceBytes) sql.exec('INSERT INTO save (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', id++, bytes.slice(at, at + pieceBytes));
39
137
  sql.exec('DELETE FROM save WHERE id >= ?', id);
138
+ // The checkpoint holds every record so far: raising the floor retires them without touching their rows.
139
+ if (lasting && sequence !== floor) sql.exec("INSERT INTO world_journal (id, seq, data) VALUES (0, ?, x'') ON CONFLICT(id) DO UPDATE SET seq = excluded.seq", sequence);
40
140
  });
141
+ floor = sequence;
142
+ if (lasting) settle(value);
41
143
  },
42
- boots() { return JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); },
43
- boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify(value)); },
144
+ boots() { const value = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); lasting ||= value?.persistent === true; return value; },
145
+ boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify({ ...value, ...(lasting ? { persistent: true } : {}) })); },
44
146
  retire() { this.boot({ retired: true }); },
45
- clear() { storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); }); },
147
+ clear() { if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
46
148
  };
47
149
  }
48
150
 
package/worker/room.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { STUDIO_BUILD } from './build-id.mjs';
1
2
  import { interestSnapshot, snapshotEncoder, scheduledView } from '../rules/interest.mjs';
2
3
  /*
3
4
  * room.mjs — the netplay v1 relay semantics, with no transport in it.
@@ -260,8 +261,9 @@ export class NetRoom {
260
261
  constructor({
261
262
  code, rules = false, tickHz = 20, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
262
263
  holdMs = 60_000, forgetMs = 60_000, persistMs = 30_000, store = null, now = Date.now, log = () => {},
263
- agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
264
+ rates = null, agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
264
265
  }) {
266
+ this.rates = rates;
265
267
  this.agentsAloneMs = agentsAloneMs;
266
268
  this.voteMs = voteMs;
267
269
  this.voteCooldownMs = voteCooldownMs;
@@ -386,6 +388,7 @@ export class NetRoom {
386
388
  /** The host runtime of a server-hosted room (`{ frame(m), facts() }`), or null. Seats already here are said as joins. */
387
389
  setServerHost(h) {
388
390
  this.server = h;
391
+ if (h?.rates) this.rates = h.rates;
389
392
  this.hostFailed = null;
390
393
  this.hostId = null;
391
394
  this.preferHost = null;
@@ -474,12 +477,24 @@ export class NetRoom {
474
477
  const out = text ?? JSON.stringify({ t: 'snap', from: c ? c.seat : null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
475
478
  if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
476
479
  this.lastSnapText = out;
480
+ // A server-hosted room behind Gates hands each Gate ONE world a tick; the Gate selects, encodes and paces
481
+ // every player it holds (worker/gate.mjs). This object's tick does no per-player selection or serialisation:
482
+ // a player costs it one comparison here. A game with fields only their owner may see is the exception: the
483
+ // room alone knows who may see what, so it keeps each player's view to itself and the Gates only carry it.
484
+ const shared = !c && this.server && !this.server.privateDelivery;
485
+ const groups = shared ? new Set() : null;
477
486
  for (const o of this.others(c)) {
478
487
  if (this.lite(o)) continue; // an agent with no game client draws nothing
488
+ if (shared && o.conn.snapshotGroup) {
489
+ if (o.conn.snapshotSeat !== o.seat) { o.conn.snapshotSeat = o.seat; o.conn.subscribe(o.seat); }
490
+ groups.add(o.conn.snapshotGroup);
491
+ continue;
492
+ }
479
493
  // A congested socket skips a snapshot rather than queueing a stale one.
480
494
  if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
481
495
  this.sendText(o.conn, this.hasViewSchedule() ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
482
496
  }
497
+ if (groups) for (const group of groups) group.snapshot(snap, this.server.viewSettings ?? {}, this.tickHz);
483
498
  }
484
499
 
485
500
  hasViewSchedule() {
@@ -490,6 +505,12 @@ export class NetRoom {
490
505
  /** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
491
506
  playerSnapshot(client, snap, welcome = false) {
492
507
  if (!snap || !this.hasViewSchedule()) return snap;
508
+ // Relayed clients get a complete welcome, then Gates own their delivery
509
+ // baselines. Retaining a welcome-only encoder here pins a second set of
510
+ // per-player rows for the lifetime of every seat, without ever using it.
511
+ if (welcome && client.conn.snapshotGroup && !this.server.privateDelivery) {
512
+ return scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz)(snap, client.seat);
513
+ }
493
514
  client.viewSchedule ??= scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz);
494
515
  let selected = client.viewSchedule(snap, client.seat);
495
516
  if(this.server?.projectSnapshot) selected=this.server.projectSnapshot(selected,client.seat);
@@ -601,7 +622,7 @@ export class NetRoom {
601
622
  this.clients.set(c.id, c);
602
623
  return {
603
624
  id: c.id,
604
- onMessage: (text) => this.guard(c, 'message', () => this.onMessage(c, text), text),
625
+ onMessage: (text, receivedAt) => this.guard(c, 'message', () => this.onMessage(c, text, receivedAt), text),
605
626
  /** `via`: how the transport learned it (`close`, or `error`: the socket failed, which is how a lost network arrives). */
606
627
  onClose: (via = 'close') => this.guard(c, 'close', () => this.onClose(c, 'closed', via)),
607
628
  };
@@ -664,7 +685,9 @@ export class NetRoom {
664
685
  sendText(conn, text) {
665
686
  try { conn.send(text); this.stats.bytesOut += this.rules ? byteLength(text) : text.length; } catch { /* closing */ }
666
687
  }
667
- send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
688
+ send(c, msg) {
689
+ this.sendText(c.conn, JSON.stringify(msg));
690
+ }
668
691
  error(c, code, message, extra = {}) { this.send(c, { t: 'error', code, message, ...extra }); }
669
692
  live() { return [...this.clients.values()].filter((c) => c.helloed); }
670
693
  others(c) { return this.live().filter((o) => o !== c); }
@@ -754,7 +777,7 @@ export class NetRoom {
754
777
  return true;
755
778
  }
756
779
 
757
- allow(c, t, bytes, view = false) {
780
+ allow(c, t, bytes, view = false, receivedAt, kind = null) {
758
781
  // A spectator screen has little to say: its events are capped at 512 B (16 screens x 2/s x 512 B at most reach the host).
759
782
  const cap = t === 'ev' && c.seat === null && (view || c.id !== this.hostId) ? 512 : capOf(t, this.seatCap);
760
783
  const rulesOutput = !view && this.rules && c.rules && c.id === this.hostId;
@@ -765,10 +788,14 @@ export class NetRoom {
765
788
  if (rulesOutput) this.stopBrowserRules(c, 'size', `This game's rules exceeded the browser room size cap: ${detail}. Join again for a fresh room.`);
766
789
  return false;
767
790
  }
768
- const now = this.now();
791
+ // Trusted Gate ingress time measures the sender, not a delayed batch's
792
+ // arrival at the Table. Direct sockets keep the Table clock. Monotonicity
793
+ // prevents a relay clock adjustment from resetting an existing rate window.
794
+ const now = Number.isFinite(receivedAt) ? (c.ingressRateAt = Math.max(c.ingressRateAt ?? receivedAt, receivedAt)) : this.now();
769
795
  if (rulesOutput) {
770
796
  c.rulesBuckets ??= new Map();
771
- const rate = rulesRates(this.tickHz, this.seatCap)[t];
797
+ const rate = this.rates?.[t] ?? rulesRates(this.tickHz, this.seatCap)[t];
798
+ if (!(Number.isFinite(rate) && rate > 0)) return true;
772
799
  if (!takeRulesToken(c.rulesBuckets, t, rate, now)) return true;
773
800
  this.stats.drops += 1;
774
801
  if (t === 'snap') return false;
@@ -779,7 +806,16 @@ export class NetRoom {
779
806
  else { this.hostId = c.id; this.stopBrowserRules(c, 'rate', 'No player can host this room after its host failed. Join again for a fresh room.'); }
780
807
  return false;
781
808
  }
782
- const limit = t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
809
+ // Homie paces nothing a player sends a game whose rules run on the server: the rules hold every input to
810
+ // its declared type and their own budget, and a batch a slow network delivered late is not a flood (0.45.3
811
+ // dropped honest input at 60 a second in a room of 1,000). A studio that wants a pace names one
812
+ // (`room.rates` in game.json, messages a second per player; 0 lifts one). What players say to each other
813
+ // (speech, chat, reactions) keeps its pace, and so does every room a player's own browser hosts, where the
814
+ // caps are what stands between that phone and the rest of the room.
815
+ const social = t === 'say' || t === 'react' || t === 'ev' && SPEECH.test(kind ?? '');
816
+ const preset = this.server && !social ? null : t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
817
+ const limit = this.rates?.[t] ?? preset;
818
+ if (!(Number.isFinite(limit) && limit > 0)) return true;
783
819
  const rateKey = view ? `${t}:view` : t;
784
820
  const list = c.rates.get(rateKey) ?? [];
785
821
  while (list.length && list[0] <= now - 1000) list.shift();
@@ -797,7 +833,7 @@ export class NetRoom {
797
833
  return true;
798
834
  }
799
835
 
800
- onMessage(c, text) {
836
+ onMessage(c, text, receivedAt) {
801
837
  if (this.clients.get(c.id) !== c) return;
802
838
  text = String(text);
803
839
  const now = this.now();
@@ -809,7 +845,7 @@ export class NetRoom {
809
845
  if (!m || typeof m.t !== 'string') return;
810
846
  if (!c.helloed) { if (m.t === 'hello' && bytes <= LIMITS.hello) this.hello(c, m); return; }
811
847
  const view = this.rules && !(c.rules && c.id === this.hostId && m.rules === true && RULES_FRAMES.includes(m.t));
812
- if (!this.allow(c, m.t, bytes, view)) return;
848
+ if (!this.allow(c, m.t, bytes, view, receivedAt, m.t === 'ev' ? String(m.k ?? '') : null)) return;
813
849
  const isHost = c.id === this.hostId && !view;
814
850
  switch (m.t) {
815
851
  case 'rules-end': {
@@ -1816,9 +1852,9 @@ export class NetRoom {
1816
1852
  }
1817
1853
 
1818
1854
  /** The seat a slot names (its own, or the one its agent sits in), and what holds it. */
1819
- holderOf(seat) {
1855
+ holderOf(seat, holders) {
1820
1856
  if (!Number.isInteger(seat)) return { kind: 'none' };
1821
- const c = this.live().find((o) => o.seat === seat);
1857
+ const c = holders ? holders.get(seat) : this.live().find((o) => o.seat === seat);
1822
1858
  const s = this.seats.get(seat);
1823
1859
  if (c && c.agent) return { kind: 'agent', agent: c.agent, name: c.name };
1824
1860
  if (c) return { kind: 'human', name: c.name };
@@ -1832,21 +1868,29 @@ export class NetRoom {
1832
1868
  * 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
1833
1869
  * named with an AI mark. A host's game cannot present an AI as a person.
1834
1870
  */
1871
+ holderIndex() {
1872
+ const holders = new Map();
1873
+ for (const client of this.live()) if (!holders.has(client.seat)) holders.set(client.seat, client);
1874
+ return holders;
1875
+ }
1876
+
1835
1877
  labelRoster(slots) {
1836
- return slots.map((raw) => this.labelOne(raw, false)).filter(Boolean);
1878
+ const holders = this.holderIndex();
1879
+ return slots.map((raw) => this.labelOne(raw, false, holders)).filter(Boolean);
1837
1880
  }
1838
1881
 
1839
1882
  /** A round's results, labelled the same way (`agent: true` on every AI's row). */
1840
1883
  labelResults(rows) {
1841
- return rows.slice(0, this.server || this.rules ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
1884
+ const holders = this.holderIndex();
1885
+ return rows.slice(0, this.server || this.rules ? this.seatCap : 64).map((raw) => this.labelOne(raw, true, holders)).filter(Boolean);
1842
1886
  }
1843
1887
 
1844
- labelOne(raw, result) {
1888
+ labelOne(raw, result, holders) {
1845
1889
  if (!raw || typeof raw !== 'object') return null;
1846
1890
  if (this.rules) {
1847
1891
  if (!Number.isInteger(raw.slot) || raw.slot < 0 || raw.slot >= this.seatCap) return null;
1848
1892
  raw = { ...raw, seat: raw.slot };
1849
- const holder = this.holderOf(raw.slot);
1893
+ const holder = this.holderOf(raw.slot, holders);
1850
1894
  if (holder.kind !== 'human' && holder.kind !== 'agent') {
1851
1895
  if (raw.slot >= this.maxPlayers - this.reserve())
1852
1896
  raw.agent = { seat: null, role: raw.slot >= this.maxPlayers - this.policy.guides ? 'guide' : 'party', hands: 'host' };
@@ -1856,7 +1900,7 @@ export class NetRoom {
1856
1900
  const out = { ...raw };
1857
1901
  const seat = Number.isInteger(raw.seat) ? raw.seat : null;
1858
1902
  const agentSeat = !result && raw.agent && Number.isInteger(raw.agent.seat) ? raw.agent.seat : null;
1859
- const who = this.holderOf(seat ?? agentSeat);
1903
+ const who = this.holderOf(seat ?? agentSeat, holders);
1860
1904
  const role = (a) => (['party', 'guide', 'player'].includes(a?.role) ? a.role : 'party');
1861
1905
  if (who.kind === 'agent') {
1862
1906
  out.name = who.name || aiName(raw.name);
@@ -2403,6 +2447,9 @@ export class NetRoom {
2403
2447
  const live = this.live();
2404
2448
  const agents = live.filter((c) => c.agent && c.seat !== null).length;
2405
2449
  return {
2450
+ transport: [...new Set(this.live().map(c => c.conn.transportFacts).filter(Boolean))].map(f => f()),
2451
+ toolkit: STUDIO_BUILD,
2452
+ gates: this.gateFacts?.() ?? [],
2406
2453
  t: 'net', v: NET_VERSION, rev: NET_REVISION, room: this.code, st: now,
2407
2454
  host: this.hostRef(),
2408
2455
  openedAt: this.openedAt || null,
@@ -2448,6 +2495,28 @@ export class NetRoom {
2448
2495
  };
2449
2496
  }
2450
2497
 
2498
+ /** What a measurement of a big room reads each second: its counts, clock and links, and nothing per player. */
2499
+ diagnosticFacts() {
2500
+ const now = this.now(), live = this.live();
2501
+ while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
2502
+ return {
2503
+ toolkit: STUDIO_BUILD, room: this.code, st: now,
2504
+ transport: [...new Set(live.map(c => c.conn.transportFacts).filter(Boolean))].map(f => f()),
2505
+ counts: {
2506
+ players: live.filter((c) => c.seat !== null && !c.agent).length,
2507
+ agents: live.filter((c) => c.agent && c.seat !== null).length,
2508
+ screens: live.filter((c) => c.seat === null).length,
2509
+ waiting: live.filter((c) => c.waiting).length,
2510
+ shells: this.watchers.size,
2511
+ maxPlayers: this.maxPlayers,
2512
+ },
2513
+ snapHz: this.snapTimes.length,
2514
+ ...(this.server ? { hosted: 'server', ticks: this.server.facts?.() ?? null } : {}),
2515
+ stats: { ins: this.stats.ins, snaps: this.stats.snaps, drops: this.stats.drops, oversize: this.stats.oversize },
2516
+ snapBytes: this.lastSnapText ? this.lastSnapText.length : 0,
2517
+ };
2518
+ }
2519
+
2451
2520
  tellWatchers() {
2452
2521
  if (!this.watchers.size) return;
2453
2522
  const text = JSON.stringify(this.facts());
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.45.3';
2
+ export const STUDIO_VERSION_TAG = '0.46.2';
@@ -0,0 +1,109 @@
1
+ /** Absolute assignments, never increments: replay cannot mint value twice.
2
+ * Entity IDs are keys so removing one entity never rewrites the rest of a world.
3
+ * The snapshot format remains the host's validated format at the read boundary.
4
+ */
5
+ const collections = [
6
+ [['host','core','ents'], row => row[0]], [['host','core','spawns'], row => row[0]],
7
+ [['host','characters'], row => row.account], [['host','names'], row => row[0]],
8
+ [['host','inputs'], row => row[0]], [['host','queues'], row => row[0]],
9
+ [['host','core','seats'], row => row[0]], [['net','seats'], row => row[0]],
10
+ [['host','core','queue'], row => `${row[3]}:${row[4]}`],
11
+ [['host','core','areas'], row => row[2]], [['host','core','asks'], row => row.n],
12
+ ];
13
+ function collectionsOf(copy, encode) {
14
+ for (const [path, identity] of collections) {
15
+ let parent = copy;
16
+ for (const key of path.slice(0,-1)) parent = parent?.[key];
17
+ const key = path.at(-1), rows = parent?.[key];
18
+ if (rows) parent[key] = encode ? Object.fromEntries(rows.map(row => [`k${identity(row)}`,row])) : Object.values(rows);
19
+ }
20
+ return copy;
21
+ }
22
+ /** Keyed collections on a copied spine. The rows themselves are shared with `value`: callers that keep the result clone first. */
23
+ export function normalizeWorld(value) {
24
+ const host = typeof value.host === 'string' ? JSON.parse(value.host) : value.host;
25
+ const copy = { ...value, host: { ...host, ...(host.core ? { core: { ...host.core } } : {}) }, ...(value.net ? { net: { ...value.net } } : {}) };
26
+ return collectionsOf(copy, true);
27
+ }
28
+ export function denormalizeWorld(value) {
29
+ return collectionsOf(structuredClone(value), false);
30
+ }
31
+ const isField = path => path[4] === 17 && path.length >= 6 || path[2] === 'shared' && path.length >= 4;
32
+ /**
33
+ * One walk of both values, no serialising of what did not change: a tick's cost follows the durable state, and a
34
+ * record's size follows what changed in it. Arrays are diffed by cell, packed maps and lists included.
35
+ * `path` is where the two values sit; a patch is `[path, value]`, or `[path]` for a key that is gone.
36
+ */
37
+ export function changes(before, after, path = [], out = []) {
38
+ walk(before, after, [...path], out);
39
+ return out;
40
+ }
41
+ /** `at` is one stack for the whole walk, copied only into a patch: nothing is allocated for what is equal. */
42
+ function walk(before, after, at, out) {
43
+ if (before === after) return;
44
+ if (!before || !after || typeof before !== 'object' || typeof after !== 'object' || Array.isArray(before) !== Array.isArray(after)) { out.push([[...at], after]); return; }
45
+ const start = out.length;
46
+ if (Array.isArray(after)) {
47
+ // By index, as numbers: a cell past the shorter array's end is new, and the length patch drops what is gone.
48
+ for (let i = 0; i < after.length; i += 1) { at.push(i); walk(before[i], after[i], at, out); at.pop(); }
49
+ if (before.length !== after.length) out.push([[...at, 'length'], after.length]);
50
+ } else {
51
+ for (const key in before) if (!Object.hasOwn(after, key)) out.push([[...at, key]]);
52
+ for (const key in after) { at.push(key); walk(before[key], after[key], at, out); at.pop(); }
53
+ }
54
+ // A packed inventory whose every slot changes is smaller as one assignment.
55
+ // Never coalesce a projected entity or room (that would pull movement in).
56
+ if (out.length > start && isField(at) && JSON.stringify([at, after]).length < JSON.stringify(out.slice(start)).length) out.splice(start, out.length - start, [[...at], after]);
57
+ }
58
+ export function applyChanges(value, patches) {
59
+ for (const [path, next] of patches) {
60
+ if (!path.length) { value = structuredClone(next); continue; }
61
+ let target = value;
62
+ for (const key of path.slice(0,-1)) target = target[key];
63
+ const key = path.at(-1);
64
+ if (next === undefined) delete target[key];
65
+ else if (Array.isArray(target) && key === 'length') target.length = next;
66
+ else Object.defineProperty(target, key, {value:structuredClone(next),writable:true,enumerable:true,configurable:true});
67
+ }
68
+ return value;
69
+ }
70
+ /** Durable fields plus the causal machinery that can create/consume value.
71
+ * Movement, inputs, pose history and undeclared fields wait for a checkpoint.
72
+ * Takes a normalised world and leaves it untouched. The view it returns shares its leaves with that world: it is for
73
+ * comparing now, and `ownDurable` makes the copy that is kept.
74
+ */
75
+ const POSE = [3, 7, 8, 9, 10, 15, 16, 18, 19];
76
+ export function durableWorld(value, fields) {
77
+ const { at, ...v } = value, { inputs, queues, guideViews, agents, ...h } = v.host, { tick, rng, nextId, seq, history, collision, guideViews: views, ...c } = h.core;
78
+ if (v.net) { const { savedAt, ckpt, state, round, roster, ...net } = v.net; v.net = net; }
79
+ for (const key of ['ents', 'spawns']) {
80
+ const rows = {};
81
+ for (const [id, row] of Object.entries(c[key] ?? {})) {
82
+ const list = fields.kinds[row[2]] ?? [];
83
+ // Undeclared non-player entities (for example visual projectiles) belong
84
+ // to checkpoints. Seat-bearing bodies remain part of the ownership protocol.
85
+ if (!list.length && row[11] < 0) continue;
86
+ // Preserve membership and ownership; pose/motion rewind to the checkpoint.
87
+ const kept = row.slice();
88
+ for (const i of POSE) if (i < kept.length) kept[i] = null;
89
+ kept[17] = {};
90
+ for (const i of list) if (row[17][i] !== undefined) kept[17][i] = row[17][i];
91
+ rows[id] = kept;
92
+ }
93
+ c[key] = rows;
94
+ }
95
+ const shared = {};
96
+ for (const i of fields.shared) if (c.shared?.[i] !== undefined) shared[i] = c.shared[i];
97
+ c.shared = shared; h.core = c; v.host = h;
98
+ return v;
99
+ }
100
+ /** A durable view that shares nothing with the world it was taken from. */
101
+ export const ownDurable = (value, fields) => JSON.parse(JSON.stringify(durableWorld(value, fields)));
102
+ /** Ignore clocks when deciding if an unchanged room needs a checkpoint. */
103
+ export function checkpointWorld(value) {
104
+ const v = structuredClone(value);
105
+ delete v.at; if(v.net) delete v.net.savedAt;
106
+ if(v.host?.core) { delete v.host.core.tick; delete v.host.core.history; delete v.host.core.collision; }
107
+ for(const [,q] of Object.values(v.host?.inputs ?? {})) delete q.lastFrameTick;
108
+ return v;
109
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The world between the room and a Gate (0.46.2).
3
+ *
4
+ * The room used to send every Gate the whole world as JSON each tick: 150 KB for a thousand players, to sixteen
5
+ * Gates, each of which parsed it. Most of that text was six numbers a body (where it is, how fast, which way), and
6
+ * the rest (its fields, its seat) changes rarely. So the motion goes as one block of numbers, the same block for
7
+ * every Gate, and each Gate is told only the rest of a body when it has changed since that Gate last heard: about
8
+ * 33 KB a tick for a thousand players, and one string to read. The numbers are exact: 32-bit when every one of
9
+ * them is (a rules host's are), 64-bit when any is not. The block travels inside the frame, as base64: a frame
10
+ * of bytes beside the text would have to arrive just before it, and not every path between two objects keeps a
11
+ * binary frame and the text after it together (a local run of 300 players lost four links that way).
12
+ *
13
+ * `worldSource()` belongs to the room, one for all its Gate links. `worldSink()` belongs to a Gate.
14
+ */
15
+ const sameValue = (a, b) => {
16
+ if (a === b) return true;
17
+ if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (!sameValue(a[i], b[i])) return false; return true; }
18
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
19
+ return JSON.stringify(a) === JSON.stringify(b);
20
+ };
21
+ /** Everything of a body but its motion: the same unless a field, a revision, a seat or a driver changed. */
22
+ const sameTail = (a, b) => {
23
+ if (a.length !== b.length || a[1] !== b[1] || a[2] !== b[2] || a[6] !== b[6]) return false;
24
+ for (let i = 7; i < a.length; i++) if (!sameValue(a[i], b[i])) return false;
25
+ return true;
26
+ };
27
+ const base64 = (bytes) => { let text = ''; for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(text); };
28
+ const bytesOf = (text) => { const raw = atob(text), out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; };
29
+ const numbers = (v) => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
30
+
31
+ export function worldSource() {
32
+ const known = new Map();
33
+ let seq = 0, orderSeq = 0, restSeq = 0, ids = null, restText = null, last = null, lastOut = null;
34
+ return {
35
+ /** Once a world, whichever link asks first: the block of motion, and what changed since the world before. */
36
+ encode(snap) {
37
+ if (snap === last) return lastOut;
38
+ const entities = snap.d?.[1];
39
+ // A world this cannot carry exactly (no bodies array, a body without three vectors of one size) goes as it is.
40
+ const dims = entities?.[0]?.[3]?.length ?? 2;
41
+ if (!Array.isArray(entities) || !entities.every((e) => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string' && numbers(e[3]) && numbers(e[4]) && numbers(e[5]) && e[3].length === dims && e[4].length === dims && e[5].length === dims)) { last = snap; lastOut = null; return null; }
42
+ seq += 1;
43
+ const n = entities.length, width = dims * 3, wide = new Float64Array(n * width), order = new Array(n), changed = [];
44
+ let same = ids !== null && ids.length === n, narrow = true;
45
+ for (let i = 0; i < n; i++) {
46
+ const e = entities[i], id = e[0]; order[i] = id;
47
+ if (same && ids[i] !== id) same = false;
48
+ for (let a = 0; a < dims; a++) { wide[i * width + a] = e[3][a]; wide[i * width + dims + a] = e[4][a]; wide[i * width + 2 * dims + a] = e[5][a]; }
49
+ const held = known.get(id);
50
+ if (!held) { known.set(id, { row: e, changed: seq }); changed.push(i); }
51
+ else { if (held.row !== e && !sameTail(held.row, e)) { held.changed = seq; changed.push(i); } held.row = e; }
52
+ }
53
+ if (!same) {
54
+ orderSeq = seq;
55
+ if (known.size > n) { const live = new Set(order); for (const id of known.keys()) if (!live.has(id)) known.delete(id); }
56
+ }
57
+ ids = order;
58
+ for (let i = 0; i < wide.length; i++) if (Math.fround(wide[i]) !== wide[i]) { narrow = false; break; }
59
+ const text = snap.d.length > 2 ? JSON.stringify(snap.d.slice(2)) : '';
60
+ if (text !== restText) { restText = text; restSeq = seq; }
61
+ last = snap;
62
+ const motion = narrow ? Float32Array.from(wide).buffer : wide.buffer;
63
+ lastOut = { seq, n, dims, narrow, motion, block: base64(new Uint8Array(motion)), entities, changed, control: new Map((snap.c ?? []).map((row) => [row[0], row])), snap };
64
+ return lastOut;
65
+ },
66
+ /** What one Gate needs beside the block: the bodies whose rest changed since world `synced`, and its own players' control rows. */
67
+ header(world, synced, seats) {
68
+ const { snap, entities } = world, tails = [];
69
+ const tail = (i) => { const e = entities[i].slice(); e[3] = e[4] = e[5] = 0; tails.push([i, e]); };
70
+ if (synced === world.seq - 1) for (const i of world.changed) tail(i);
71
+ else for (let i = 0; i < entities.length; i++) if (known.get(entities[i][0]).changed > synced) tail(i);
72
+ const c = [];
73
+ for (const seat of seats) { const row = world.control.get(seat); if (row) c.push(row); }
74
+ return {
75
+ k: snap.k, st: snap.st, ...(snap.e !== undefined ? { e: snap.e } : {}), round: snap.d[0], c,
76
+ w: { seq: world.seq, n: world.n, dims: world.dims, narrow: world.narrow, ...(orderSeq > synced ? { ids } : {}), tails, ...(restSeq > synced ? { rest: snap.d.slice(2) } : {}) },
77
+ };
78
+ },
79
+ };
80
+ }
81
+
82
+ export function worldSink() {
83
+ let ids = [], tails = [], rest = [];
84
+ return {
85
+ /** The world a header and its block describe, as the room held it. Throws when they do not fit each other. */
86
+ decode(header, block64) {
87
+ const w = header.w;
88
+ if (w.ids) {
89
+ const held = new Map(ids.map((id, i) => [id, tails[i]]));
90
+ ids = w.ids; tails = ids.map((id) => held.get(id));
91
+ }
92
+ for (const [i, tail] of w.tails) tails[i] = tail;
93
+ if (w.rest) rest = w.rest;
94
+ const width = w.dims * 3, bytes = typeof block64 === 'string' ? bytesOf(block64) : null;
95
+ if (!bytes || ids.length !== w.n || bytes.length !== w.n * width * (w.narrow ? 4 : 8)) throw new Error(`world block does not fit its header (${bytes?.length} bytes for ${w.n} bodies, ${ids.length} known)`);
96
+ const block = bytes.buffer;
97
+ const a = w.narrow ? new Float32Array(block) : new Float64Array(block), entities = new Array(w.n), d = w.dims;
98
+ for (let i = 0; i < w.n; i++) {
99
+ const tail = tails[i];
100
+ if (!tail) throw new Error('world names a body this Gate was never told of');
101
+ const row = tail.slice(), o = i * width;
102
+ row[3] = d === 2 ? [a[o], a[o + 1]] : [a[o], a[o + 1], a[o + 2]];
103
+ row[4] = d === 2 ? [a[o + d], a[o + d + 1]] : [a[o + d], a[o + d + 1], a[o + d + 2]];
104
+ row[5] = d === 2 ? [a[o + 2 * d], a[o + 2 * d + 1]] : [a[o + 2 * d], a[o + 2 * d + 1], a[o + 2 * d + 2]];
105
+ entities[i] = row;
106
+ }
107
+ return { k: header.k, st: header.st, ...(header.e !== undefined ? { e: header.e } : {}), d: [header.round, entities, ...rest], c: header.c };
108
+ },
109
+ };
110
+ }