@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.
- package/CHANGELOG.md +102 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +65 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build-identity.mjs +43 -0
- package/lib/build.mjs +1 -1
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +14 -8
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +18 -5
- package/package.json +4 -2
- package/rules/core.ts +12 -6
- package/rules/host.ts +132 -17
- package/rules/interest.mjs +320 -51
- package/rules/rules.ts +32 -9
- package/rules/timing.mjs +20 -0
- package/worker/build-id.mjs +2 -0
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +241 -25
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +85 -16
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
- package/worker/world-wire.mjs +110 -0
package/worker/room-store.mjs
CHANGED
|
@@ -1,28 +1,77 @@
|
|
|
1
|
+
import { normalizeWorld, denormalizeWorld, durableWorld, ownDurable, checkpointWorld, changes, applyChanges } from './world-journal.mjs';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
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 >
|
|
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) >
|
|
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
|
-
|
|
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() {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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());
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|