@irtio/runtime 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-3DQHT4CM.js → chunk-FA4IOC2Z.js} +1 -1
- package/dist/{chunk-X5S364FY.js → chunk-GKMD3ICD.js} +888 -31
- package/dist/{contract-BhD88PGb.d.ts → contract-B8QSO0MH.d.ts} +86 -2
- package/dist/index.d.ts +39 -8
- package/dist/index.js +18 -2
- package/dist/room-BfALTh7M.d.ts +431 -0
- package/dist/test/index.d.ts +59 -2
- package/dist/test/index.js +142 -7
- package/dist/worker/index.d.ts +40 -2
- package/dist/worker/index.js +35 -4
- package/package.json +5 -4
- package/dist/room-CBsSCueH.d.ts +0 -246
package/dist/test/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RoomCore,
|
|
3
|
+
createVisibilityPolicy,
|
|
4
|
+
initPhysics,
|
|
3
5
|
visibleNames
|
|
4
|
-
} from "../chunk-
|
|
6
|
+
} from "../chunk-GKMD3ICD.js";
|
|
5
7
|
|
|
6
8
|
// src/test/clock.ts
|
|
7
9
|
var FakeClock = class {
|
|
@@ -66,6 +68,32 @@ var HarnessHost = class {
|
|
|
66
68
|
/** Set by the harness: where an outbound frame goes. */
|
|
67
69
|
onSend = () => {
|
|
68
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* The core this host answers. Set by whoever built the pair — the host has to call back into
|
|
73
|
+
* `completeHostCall`/`fireAlarm`, which runs in the same direction `receive()` does.
|
|
74
|
+
*/
|
|
75
|
+
core;
|
|
76
|
+
/** Every `room.save()` / `room.kv.*` the room asked for, in order. */
|
|
77
|
+
hostCalls = [];
|
|
78
|
+
/** Fake ms between a host call and its answer. 0 still means "a later event", not "inline". */
|
|
79
|
+
hostCallDelayMs = 0;
|
|
80
|
+
/** The in-memory player KV, keyed the way the real table's composite primary key is. */
|
|
81
|
+
kv = /* @__PURE__ */ new Map();
|
|
82
|
+
/** Save generations this host minted: `saveId` -> the bytes it was handed. */
|
|
83
|
+
saves = /* @__PURE__ */ new Map();
|
|
84
|
+
/** What `room.save()` serializes. The harness sets it to `() => core.snapshot()` — the bytes
|
|
85
|
+
* without hibernation's side effects, exactly as the supervisor asks for them. */
|
|
86
|
+
serializeForSave;
|
|
87
|
+
/**
|
|
88
|
+
* Override the host-call backend. Return `undefined` to fall through to the built-in in-memory
|
|
89
|
+
* one; return a result to answer it yourself — how the limit and outage tests are written.
|
|
90
|
+
*/
|
|
91
|
+
handleHostCall;
|
|
92
|
+
nextSaveId = 1;
|
|
93
|
+
/** D26: armed alarms, `name` -> due time on the fake clock. Host state, never room state. */
|
|
94
|
+
armedAlarms = /* @__PURE__ */ new Map();
|
|
95
|
+
/** Every alarm that fired, in order, as `name@dueMs`. */
|
|
96
|
+
firedAlarms = [];
|
|
69
97
|
/** `true` once the runtime has asked to hibernate at least once. */
|
|
70
98
|
get slept() {
|
|
71
99
|
return this.sleeps > 0;
|
|
@@ -110,11 +138,76 @@ var HarnessHost = class {
|
|
|
110
138
|
crashed(reason) {
|
|
111
139
|
this.crashes.push(reason);
|
|
112
140
|
}
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// Week 12: host calls (D24 save, D25 KV)
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
hostCall(reqId, call) {
|
|
145
|
+
this.hostCalls.push({ reqId, call });
|
|
146
|
+
this.clock.setTimeout(() => {
|
|
147
|
+
const result = this.handleHostCall?.(call) ?? this.runHostCall(call);
|
|
148
|
+
this.core?.completeHostCall(reqId, result);
|
|
149
|
+
}, this.hostCallDelayMs);
|
|
150
|
+
}
|
|
151
|
+
runHostCall(call) {
|
|
152
|
+
switch (call.kind) {
|
|
153
|
+
case "save": {
|
|
154
|
+
const serialize = this.serializeForSave;
|
|
155
|
+
if (!serialize) {
|
|
156
|
+
return { ok: false, code: "E_SAVE_UNSUPPORTED", message: "no serializer wired" };
|
|
157
|
+
}
|
|
158
|
+
const saveId = String(this.nextSaveId++).padStart(6, "0");
|
|
159
|
+
this.saves.set(saveId, serialize());
|
|
160
|
+
return { ok: true, value: saveId };
|
|
161
|
+
}
|
|
162
|
+
case "kvGet": {
|
|
163
|
+
const value = this.kv.get(kvKey(call.playerId, call.key));
|
|
164
|
+
return value === void 0 ? { ok: true } : { ok: true, value };
|
|
165
|
+
}
|
|
166
|
+
case "kvSet":
|
|
167
|
+
this.kv.set(kvKey(call.playerId, call.key), call.value);
|
|
168
|
+
return { ok: true };
|
|
169
|
+
case "kvDelete":
|
|
170
|
+
this.kv.delete(kvKey(call.playerId, call.key));
|
|
171
|
+
return { ok: true };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
// Week 12: durable alarms (D26)
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
setAlarm(name, atMs) {
|
|
178
|
+
if (atMs === void 0) this.armedAlarms.delete(name);
|
|
179
|
+
else this.armedAlarms.set(name, atMs);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Fires every alarm due at or before `now`, in **name order** (D34 determinism), removing each
|
|
183
|
+
* before it runs so a handler that re-arms its own name arms the *next* one rather than having
|
|
184
|
+
* this pass cancel it straight back out.
|
|
185
|
+
*/
|
|
186
|
+
fireDue(now = this.clock.now()) {
|
|
187
|
+
const due = [...this.armedAlarms.entries()].filter(([, at]) => at <= now).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
188
|
+
const fired = [];
|
|
189
|
+
for (const [name, at] of due) {
|
|
190
|
+
this.armedAlarms.delete(name);
|
|
191
|
+
this.firedAlarms.push(`${name}@${at}`);
|
|
192
|
+
fired.push(name);
|
|
193
|
+
this.core?.fireAlarm(name);
|
|
194
|
+
}
|
|
195
|
+
return fired;
|
|
196
|
+
}
|
|
197
|
+
/** The earliest due time across armed alarms — what the supervisor reports to control. */
|
|
198
|
+
get dueAlarmAt() {
|
|
199
|
+
let best;
|
|
200
|
+
for (const at of this.armedAlarms.values()) if (best === void 0 || at < best) best = at;
|
|
201
|
+
return best;
|
|
202
|
+
}
|
|
113
203
|
/** Logged messages of one level, flattened to strings (handy in assertions). */
|
|
114
204
|
logsOf(level) {
|
|
115
205
|
return this.logs.filter((l) => l.level === level).map((l) => l.args.map(String).join(" "));
|
|
116
206
|
}
|
|
117
207
|
};
|
|
208
|
+
function kvKey(playerId, key) {
|
|
209
|
+
return `${playerId}\0${key}`;
|
|
210
|
+
}
|
|
118
211
|
|
|
119
212
|
// src/test/harness.ts
|
|
120
213
|
import {
|
|
@@ -361,16 +454,25 @@ var FakeClientImpl = class {
|
|
|
361
454
|
}
|
|
362
455
|
/** Flags any collection the frame named that this client's role may not see. */
|
|
363
456
|
checkFrameVisibility(delta) {
|
|
364
|
-
const keep = this.bridge.visible(this.role);
|
|
365
457
|
for (const dc of delta.collections) {
|
|
366
|
-
|
|
458
|
+
const spatial = this.bridge.ext.collection(dc.name).visibility === "spatial-grid";
|
|
459
|
+
const leaked = dc.ops.filter((op) => {
|
|
460
|
+
if (!this.bridge.visible(this.role, this.id, dc.name)) return true;
|
|
461
|
+
if (!spatial) return false;
|
|
462
|
+
if (op.op === "remove") return !entityOf(this.view, dc.name).has(op.id);
|
|
463
|
+
return !this.bridge.visible(this.role, this.id, dc.name, op.id);
|
|
464
|
+
});
|
|
465
|
+
if (leaked.length === 0) continue;
|
|
466
|
+
const ids = leaked.map((o) => o.id);
|
|
467
|
+
const detail = this.bridge.spatialDetail(this.role, this.id, dc.name, ids);
|
|
367
468
|
this.bridge.noteLeak({
|
|
368
469
|
clientId: this.id,
|
|
369
470
|
role: this.role,
|
|
370
471
|
collection: dc.name,
|
|
371
472
|
kind: "frame",
|
|
372
|
-
ids
|
|
373
|
-
tick: delta.tick
|
|
473
|
+
ids,
|
|
474
|
+
tick: delta.tick,
|
|
475
|
+
...detail ? { detail } : {}
|
|
374
476
|
});
|
|
375
477
|
}
|
|
376
478
|
}
|
|
@@ -489,6 +591,9 @@ function deepEqual(a, b) {
|
|
|
489
591
|
for (const k of keys) if (!deepEqual(ra[k], rb[k])) return false;
|
|
490
592
|
return true;
|
|
491
593
|
}
|
|
594
|
+
function detailOf(detail) {
|
|
595
|
+
return detail ? { detail } : {};
|
|
596
|
+
}
|
|
492
597
|
var Harness = class {
|
|
493
598
|
constructor(definition, options) {
|
|
494
599
|
this.definition = definition;
|
|
@@ -501,6 +606,8 @@ var Harness = class {
|
|
|
501
606
|
...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
|
|
502
607
|
...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {}
|
|
503
608
|
});
|
|
609
|
+
this.host.core = this.coreRef;
|
|
610
|
+
this.host.serializeForSave = () => this.coreRef.snapshot();
|
|
504
611
|
this.coreRef.start();
|
|
505
612
|
const self = this;
|
|
506
613
|
this.bridge = {
|
|
@@ -523,7 +630,17 @@ var Harness = class {
|
|
|
523
630
|
noteLeak: (leak) => {
|
|
524
631
|
this.frameLeaks.push(leak);
|
|
525
632
|
},
|
|
526
|
-
visible: (role
|
|
633
|
+
visible: (role, clientId, collection, id) => {
|
|
634
|
+
const desc = this.coreRef.ext.collection(collection);
|
|
635
|
+
const policy = createVisibilityPolicy(this.coreRef.ext, this.coreRef.plain, clientId, role);
|
|
636
|
+
return id === void 0 ? policy.maySeeCollection(desc) : policy.maySeeEntity(desc, id);
|
|
637
|
+
},
|
|
638
|
+
spatialDetail: (role, clientId, collection, ids) => createVisibilityPolicy(
|
|
639
|
+
this.coreRef.ext,
|
|
640
|
+
this.coreRef.plain,
|
|
641
|
+
clientId,
|
|
642
|
+
role
|
|
643
|
+
).describeSpatial(this.coreRef.ext.collection(collection), ids)
|
|
527
644
|
};
|
|
528
645
|
}
|
|
529
646
|
definition;
|
|
@@ -711,7 +828,24 @@ var Harness = class {
|
|
|
711
828
|
const ext = this.coreRef.ext;
|
|
712
829
|
for (const client of this.clientList) {
|
|
713
830
|
const keep = visibleNames(ext, client.role);
|
|
831
|
+
const policy = createVisibilityPolicy(ext, this.coreRef.plain, client.id, client.role);
|
|
714
832
|
for (const c of ext.collections) {
|
|
833
|
+
if (c.visibility === "spatial-grid") {
|
|
834
|
+
const coll = client.view[c.name];
|
|
835
|
+
const ids = coll ? [...coll.ids()].filter((id) => !policy.maySeeEntity(c, id)) : [];
|
|
836
|
+
if (ids.length > 0) {
|
|
837
|
+
leaks.push({
|
|
838
|
+
clientId: client.id,
|
|
839
|
+
role: client.role,
|
|
840
|
+
collection: c.name,
|
|
841
|
+
kind: "view",
|
|
842
|
+
ids,
|
|
843
|
+
tick: this.coreRef.tick,
|
|
844
|
+
...detailOf(policy.describeSpatial(c, ids))
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
715
849
|
if (keep.has(c.name)) continue;
|
|
716
850
|
if (c.kind === "entity") {
|
|
717
851
|
const coll = client.view[c.name];
|
|
@@ -770,5 +904,6 @@ export {
|
|
|
770
904
|
FakeClock,
|
|
771
905
|
HarnessHost,
|
|
772
906
|
createRoomHarness,
|
|
773
|
-
frameTypeName
|
|
907
|
+
frameTypeName,
|
|
908
|
+
initPhysics
|
|
774
909
|
};
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { L as LogLevel,
|
|
1
|
+
import { L as LogLevel, j as RoomStats, a as HostCall, b as HostCallResult, R as RoomCoreApi, h as RoomHost } from '../contract-B8QSO0MH.js';
|
|
2
2
|
import { ErrorCodeName } from '@irtio/protocol';
|
|
3
3
|
import { LeaveReason } from '@irtio/server';
|
|
4
4
|
import '@irtio/schema';
|
|
@@ -40,6 +40,9 @@ type ToWorker = {
|
|
|
40
40
|
clientId: string;
|
|
41
41
|
role?: string;
|
|
42
42
|
name?: string;
|
|
43
|
+
/** Week 13 (D27): the verified JWT subject — becomes `JoinOptions.playerId` and therefore
|
|
44
|
+
* `ctx.playerId`. Absent for key joins, whose playerId stays the client id. */
|
|
45
|
+
playerId?: string;
|
|
43
46
|
reconnecting?: boolean;
|
|
44
47
|
} | {
|
|
45
48
|
t: 'leave';
|
|
@@ -52,14 +55,33 @@ type ToWorker = {
|
|
|
52
55
|
t: 'frame';
|
|
53
56
|
clientId: string;
|
|
54
57
|
bytes: Uint8Array;
|
|
55
|
-
}
|
|
58
|
+
}
|
|
59
|
+
/** `forSave` (D24) asks for the bytes only: no `onSleep`, no timers cleared, nothing
|
|
60
|
+
* rejected — the room keeps running. Absent means hibernation, as it always did. */
|
|
61
|
+
| {
|
|
56
62
|
t: 'serialize';
|
|
57
63
|
reqId: number;
|
|
64
|
+
forSave?: boolean;
|
|
58
65
|
} | {
|
|
59
66
|
t: 'inspect';
|
|
60
67
|
reqId: number;
|
|
61
68
|
} | {
|
|
62
69
|
t: 'stats';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Week 12: the supervisor answering a `hostCall` (a D24 save, a D25 KV operation). It arrives
|
|
73
|
+
* as an ordinary worker message, which is precisely what makes the room's continuation "its
|
|
74
|
+
* own event between ticks" rather than something that ran inside the handler that asked.
|
|
75
|
+
*/
|
|
76
|
+
| {
|
|
77
|
+
t: 'hostResult';
|
|
78
|
+
reqId: number;
|
|
79
|
+
result: HostCallResult;
|
|
80
|
+
}
|
|
81
|
+
/** D26: a durable alarm came due. The supervisor owns the timer; the worker just runs it. */
|
|
82
|
+
| {
|
|
83
|
+
t: 'alarm';
|
|
84
|
+
name: string;
|
|
63
85
|
} | {
|
|
64
86
|
t: 'stop';
|
|
65
87
|
};
|
|
@@ -141,6 +163,22 @@ type FromWorker = {
|
|
|
141
163
|
stats: Omit<RoomStats, 'bytesOutByClient'> & {
|
|
142
164
|
bytesOutByClient: [string, number][];
|
|
143
165
|
};
|
|
166
|
+
}
|
|
167
|
+
/** Week 12: the room asked for host-side async work; the supervisor answers `hostResult`. */
|
|
168
|
+
| {
|
|
169
|
+
t: 'hostCall';
|
|
170
|
+
reqId: number;
|
|
171
|
+
call: HostCall;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* D26: `room.alarm(name, atMs)` / `room.cancelAlarm(name)` (`atMs` absent = cancel). Alarm
|
|
175
|
+
* state lives in the supervisor's `RoomRecord`, never in the hibernation blob — which is why
|
|
176
|
+
* the blob format is still v2.
|
|
177
|
+
*/
|
|
178
|
+
| {
|
|
179
|
+
t: 'setAlarm';
|
|
180
|
+
name: string;
|
|
181
|
+
atMs?: number;
|
|
144
182
|
} | {
|
|
145
183
|
t: 'stopped';
|
|
146
184
|
};
|
package/dist/worker/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
migrateSnapshot
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-FA4IOC2Z.js";
|
|
4
4
|
import {
|
|
5
5
|
RoomCore,
|
|
6
|
-
RoomFullError
|
|
7
|
-
|
|
6
|
+
RoomFullError,
|
|
7
|
+
initPhysics
|
|
8
|
+
} from "../chunk-GKMD3ICD.js";
|
|
8
9
|
|
|
9
10
|
// src/worker/index.ts
|
|
10
11
|
import { isMainThread, parentPort } from "worker_threads";
|
|
@@ -55,6 +56,16 @@ function createWorkerHost(post) {
|
|
|
55
56
|
},
|
|
56
57
|
crashed(reason) {
|
|
57
58
|
post({ t: "crashed", reason });
|
|
59
|
+
},
|
|
60
|
+
hostCall(reqId, call) {
|
|
61
|
+
post({ t: "hostCall", reqId, call });
|
|
62
|
+
},
|
|
63
|
+
setAlarm(name, atMs) {
|
|
64
|
+
if (atMs === void 0) {
|
|
65
|
+
post({ t: "setAlarm", name });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
post({ t: "setAlarm", name, atMs: Date.now() + (atMs - performance.now()) });
|
|
58
69
|
}
|
|
59
70
|
};
|
|
60
71
|
}
|
|
@@ -100,6 +111,17 @@ async function handleMessage(state, host, post, msg) {
|
|
|
100
111
|
post({ t: "initFailed", reason: String(err instanceof Error ? err.stack : err) });
|
|
101
112
|
return;
|
|
102
113
|
}
|
|
114
|
+
if (def.config.physics) {
|
|
115
|
+
try {
|
|
116
|
+
await initPhysics();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
post({
|
|
119
|
+
t: "initFailed",
|
|
120
|
+
reason: `physics engine failed to load: ${String(err instanceof Error ? err.stack ?? err.message : err)}`
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
103
125
|
let snapshot = msg.snapshot;
|
|
104
126
|
let migrated;
|
|
105
127
|
if (snapshot && msg.migrate && msg.migrate.length > 0) {
|
|
@@ -169,6 +191,7 @@ async function handleMessage(state, host, post, msg) {
|
|
|
169
191
|
const joinOptions = {
|
|
170
192
|
...msg.role !== void 0 ? { role: msg.role } : {},
|
|
171
193
|
...msg.name !== void 0 ? { name: msg.name } : {},
|
|
194
|
+
...msg.playerId !== void 0 ? { playerId: msg.playerId } : {},
|
|
172
195
|
...msg.reconnecting !== void 0 ? { reconnecting: msg.reconnecting } : {}
|
|
173
196
|
};
|
|
174
197
|
const result = core.join(msg.clientId, joinOptions);
|
|
@@ -212,7 +235,7 @@ async function handleMessage(state, host, post, msg) {
|
|
|
212
235
|
case "serialize": {
|
|
213
236
|
const core = state.core;
|
|
214
237
|
if (!core) return;
|
|
215
|
-
const bytes = ownBuffer(core.serialize());
|
|
238
|
+
const bytes = ownBuffer(msg.forSave === true ? core.snapshot() : core.serialize());
|
|
216
239
|
post({ t: "serialized", reqId: msg.reqId, bytes }, [bytes.buffer]);
|
|
217
240
|
return;
|
|
218
241
|
}
|
|
@@ -236,6 +259,14 @@ async function handleMessage(state, host, post, msg) {
|
|
|
236
259
|
post(statsPayload(core.stats));
|
|
237
260
|
return;
|
|
238
261
|
}
|
|
262
|
+
case "hostResult": {
|
|
263
|
+
state.core?.completeHostCall(msg.reqId, msg.result);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
case "alarm": {
|
|
267
|
+
state.core?.fireAlarm(msg.name);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
239
270
|
case "stop": {
|
|
240
271
|
state.core?.stop();
|
|
241
272
|
post({ t: "stopped" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
"dist"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@irtio/
|
|
33
|
-
"@irtio/
|
|
31
|
+
"@dimforge/rapier3d-compat": "0.20.0",
|
|
32
|
+
"@irtio/protocol": "0.2.0",
|
|
33
|
+
"@irtio/schema": "0.2.0",
|
|
34
|
+
"@irtio/server": "0.2.0"
|
|
34
35
|
},
|
|
35
36
|
"scripts": {
|
|
36
37
|
"build": "tsup",
|
package/dist/room-CBsSCueH.d.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
import { DirtySet, AnySchema, PlainState, Tracked, State } from '@irtio/schema';
|
|
2
|
-
import { RoomDefinition, RoomMode, Room, Ctx, ResolvedRoomConfig, LeaveReason } from '@irtio/server';
|
|
3
|
-
import { f as RoomHost, h as RoomStats, L as LogLevel, R as RoomCoreApi, b as RoomCoreOptions, d as RoomEventKind, g as RoomInspection, J as JoinOptions, a as JoinResult } from './contract-BhD88PGb.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* `room.random()` — mulberry32 over a u32 seed. Deterministic, tiny, and serializable: the
|
|
7
|
-
* generator's whole state is one u32, so `serialize()`/`restore()` round-trip it exactly.
|
|
8
|
-
*/
|
|
9
|
-
declare class Mulberry32 {
|
|
10
|
-
/** Current internal state (u32). Survives hibernation. */
|
|
11
|
-
state: number;
|
|
12
|
-
constructor(seed: number);
|
|
13
|
-
/** Next float in `[0, 1)`. */
|
|
14
|
-
next(): number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The internal seam between `RoomCore` (core/room.ts) and the modules it delegates to
|
|
19
|
-
* (`loop`, `views`, `writes`, `rpc`, `messages`, `room-api`). Nothing here is public API —
|
|
20
|
-
* `src/contract.ts` is. `RoomCore` implements `RoomInternals` structurally, so the modules
|
|
21
|
-
* take it as a parameter and never import `room.ts` (no cycles).
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
type AnyRecord = Record<string, unknown>;
|
|
25
|
-
/** `${collection}\0${id}` → leaf-path key → the value the runtime wrote for that leaf. */
|
|
26
|
-
type AcceptedWrites = Map<string, Map<string, unknown>>;
|
|
27
|
-
/** One joined client. Presence is the wire truth; this is the runtime's bookkeeping. */
|
|
28
|
-
interface ClientEntry {
|
|
29
|
-
readonly clientId: string;
|
|
30
|
-
role: string;
|
|
31
|
-
name: string;
|
|
32
|
-
connected: boolean;
|
|
33
|
-
/** Pending `CORRECT` mask, encoded from current state at the next flush. */
|
|
34
|
-
correction: DirtySet | undefined;
|
|
35
|
-
/** Leaves this client's own accepted `WRITE`s produced during the current flush window. */
|
|
36
|
-
readonly accepted: AcceptedWrites;
|
|
37
|
-
/**
|
|
38
|
-
* The tick stamped on the last `WRITE` this client sent (the client-local write counter in the
|
|
39
|
-
* delta header — week 8, D19), `0` before its first write. Every `CORRECT` sent to this client
|
|
40
|
-
* carries it as `clientTick`: "your writes through this tick are reflected in these values".
|
|
41
|
-
* An unprompted correction (a server-wins overwrite with no triggering write) echoes it
|
|
42
|
-
* unchanged — for a client that never wrote, that is `0`.
|
|
43
|
-
*/
|
|
44
|
-
lastClientTick: number;
|
|
45
|
-
/**
|
|
46
|
-
* Set at `join`: this client's own record(s) — its presence row and whatever entities its own
|
|
47
|
-
* `onJoin` added, owned by it — as they stood right after join, per collection. Consumed once
|
|
48
|
-
* by the very next `flush()`, which strips exactly the ids whose value (and owner) still match
|
|
49
|
-
* this capture from this client's delta only — an id mutated again before that flush (e.g. a
|
|
50
|
-
* same-window `room.setRole`) is left alone, since the join snapshot never saw that value.
|
|
51
|
-
* Every other client's delta is unaffected.
|
|
52
|
-
*/
|
|
53
|
-
pendingJoinAdds: Map<string, ReadonlyMap<string, JoinAddCapture>> | undefined;
|
|
54
|
-
}
|
|
55
|
-
/** What `join` captured for one of a client's own just-added records, for `flush`'s comparison. */
|
|
56
|
-
interface JoinAddCapture {
|
|
57
|
-
readonly value: unknown;
|
|
58
|
-
readonly owner: string | undefined;
|
|
59
|
-
}
|
|
60
|
-
type GuardResult<T> = {
|
|
61
|
-
readonly ok: true;
|
|
62
|
-
readonly value: T;
|
|
63
|
-
} | {
|
|
64
|
-
readonly ok: false;
|
|
65
|
-
};
|
|
66
|
-
/** One queued inbound frame (tick mode). */
|
|
67
|
-
interface QueuedFrame {
|
|
68
|
-
readonly clientId: string;
|
|
69
|
-
readonly type: number;
|
|
70
|
-
readonly payload: Uint8Array;
|
|
71
|
-
}
|
|
72
|
-
interface LoopApi {
|
|
73
|
-
start(): void;
|
|
74
|
-
stop(): void;
|
|
75
|
-
/** Event mode: an inbound frame or a join re-arms the idle timer. */
|
|
76
|
-
noteActivity(): void;
|
|
77
|
-
/** `room.setTimeout` / `room.setInterval`. Returns a numeric handle. */
|
|
78
|
-
setTimer(ms: number, fn: () => void, repeat: boolean): number;
|
|
79
|
-
clearTimer(handle: number): void;
|
|
80
|
-
/** Drops every room timer (hibernation, stop). */
|
|
81
|
-
clearAllTimers(): void;
|
|
82
|
-
/** Internal one-shot on the host clock (RPC timeouts in event mode). */
|
|
83
|
-
after(ms: number, fn: () => void): void;
|
|
84
|
-
}
|
|
85
|
-
/** What the core modules are allowed to see of `RoomCore`. */
|
|
86
|
-
interface RoomInternals {
|
|
87
|
-
readonly definition: RoomDefinition;
|
|
88
|
-
/** `withBuiltins(definition.schema)` — what every frame and the codec use. */
|
|
89
|
-
readonly ext: AnySchema;
|
|
90
|
-
readonly host: RoomHost;
|
|
91
|
-
readonly roomId: string;
|
|
92
|
-
readonly mode: RoomMode;
|
|
93
|
-
/** The plain state the tracked proxies wrap (what the codec reads). */
|
|
94
|
-
readonly plain: PlainState;
|
|
95
|
-
readonly tracked: Tracked<AnySchema>;
|
|
96
|
-
/** The tracked proxy tree, loosely typed for internal use. */
|
|
97
|
-
readonly anyState: AnyRecord;
|
|
98
|
-
readonly room: Room;
|
|
99
|
-
readonly rng: Mulberry32;
|
|
100
|
-
readonly stats: RoomStats;
|
|
101
|
-
/** Joined clients in join order. */
|
|
102
|
-
readonly clients: Map<string, ClientEntry>;
|
|
103
|
-
readonly loop: LoopApi;
|
|
104
|
-
tick: number;
|
|
105
|
-
stopped: boolean;
|
|
106
|
-
/** Runs a room handler; a throw is logged and counted, never rethrown. */
|
|
107
|
-
guard<T>(name: string, fn: () => T): T | undefined;
|
|
108
|
-
recordEvent(kind: 'join' | 'leave' | 'write' | 'write-rejected' | 'correct' | 'call' | 'reply' | 'msg' | 'error', clientId?: string, detail?: string): void;
|
|
109
|
-
/** `guard` that also reports whether the handler threw (the tick loop needs this). */
|
|
110
|
-
tryRun<T>(name: string, fn: () => T): GuardResult<T>;
|
|
111
|
-
log(level: LogLevel, ...args: unknown[]): void;
|
|
112
|
-
/** Sends one framed protocol frame to a connected client and counts it. */
|
|
113
|
-
send(clientId: string, frame: Uint8Array): void;
|
|
114
|
-
ctxFor(clientId: string, reconnecting?: boolean): Ctx;
|
|
115
|
-
/** The client's pending `CORRECT` dirty set, created on demand. */
|
|
116
|
-
correctionFor(clientId: string): DirtySet | undefined;
|
|
117
|
-
/** Flushes the tracked dirty set: corrections first, then one `DELTA` per distinct view. */
|
|
118
|
-
flush(): void;
|
|
119
|
-
/** Invalidates the cached `room.clients` array. */
|
|
120
|
-
invalidateClients(): void;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Scheduling: the tick-mode fixed-step loop with catch-up, event-mode idle detection, and
|
|
125
|
-
* `room.setTimeout` / `room.setInterval`.
|
|
126
|
-
*
|
|
127
|
-
* Tick mode: one host timeout re-armed every `1000 / tickRate` ms. Each wake accumulates real
|
|
128
|
-
* elapsed time and runs up to `MAX_CATCHUP` ticks; a bigger backlog is dropped and counted as an
|
|
129
|
-
* overrun (logged once per burst). Room timers are tick-granular: `setTimeout(ms)` fires on the
|
|
130
|
-
* first tick at or after `now + ms`.
|
|
131
|
-
*
|
|
132
|
-
* Event mode: no tick timer. Room timers fire on the host clock, each firing advancing the tick
|
|
133
|
-
* and flushing. After `idleMs` with no inbound frames and no connected clients the host is asked
|
|
134
|
-
* to hibernate exactly once (re-armed by the next frame or join).
|
|
135
|
-
*/
|
|
136
|
-
|
|
137
|
-
/** Most ticks one wake may run before the backlog is dropped. */
|
|
138
|
-
declare const MAX_CATCHUP = 5;
|
|
139
|
-
/** Consecutive `tick()` throws before the host is told the room crashed. */
|
|
140
|
-
declare const CRASH_AFTER_THROWS = 3;
|
|
141
|
-
declare class Loop implements LoopApi {
|
|
142
|
-
private readonly core;
|
|
143
|
-
readonly inbound: QueuedFrame[];
|
|
144
|
-
private readonly timers;
|
|
145
|
-
private readonly internal;
|
|
146
|
-
private nextTimerId;
|
|
147
|
-
private tickHandle;
|
|
148
|
-
private idleHandle;
|
|
149
|
-
private running;
|
|
150
|
-
private lastWake;
|
|
151
|
-
private accumulator;
|
|
152
|
-
private overrunLogged;
|
|
153
|
-
private consecutiveThrows;
|
|
154
|
-
private lastActivity;
|
|
155
|
-
private slept;
|
|
156
|
-
constructor(core: RoomInternals);
|
|
157
|
-
get intervalMs(): number;
|
|
158
|
-
start(): void;
|
|
159
|
-
stop(): void;
|
|
160
|
-
enqueue(frame: QueuedFrame): void;
|
|
161
|
-
dropFramesFor(clientId: string): void;
|
|
162
|
-
private drainInbound;
|
|
163
|
-
/** Applies one queued/immediate frame. Returns `false` on a malformed payload. */
|
|
164
|
-
applyFrame(f: QueuedFrame): boolean;
|
|
165
|
-
private scheduleTick;
|
|
166
|
-
private onWake;
|
|
167
|
-
/** One tick: inbound → room timers → `tick(state, dt, room)` → flush. */
|
|
168
|
-
runTick(): void;
|
|
169
|
-
private fireDueTimers;
|
|
170
|
-
/** Applies one frame as its own event: tick++, apply, flush. */
|
|
171
|
-
applyEvent(f: QueuedFrame): boolean;
|
|
172
|
-
noteActivity(): void;
|
|
173
|
-
private armIdle;
|
|
174
|
-
private onIdleCheck;
|
|
175
|
-
setTimer(ms: number, fn: () => void, repeat: boolean): number;
|
|
176
|
-
private armHostTimer;
|
|
177
|
-
clearTimer(handle: number): void;
|
|
178
|
-
clearAllTimers(): void;
|
|
179
|
-
after(ms: number, fn: () => void): void;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* `RoomCore` — the host-agnostic room runtime. It owns the extended schema, the
|
|
184
|
-
* tracked authority state, presence, the loop, and the frame dispatch; everything outside comes
|
|
185
|
-
* through `RoomHost` (`src/contract.ts`). The worker host and the test harness are adapters over
|
|
186
|
-
* exactly this surface and produce byte-identical frames.
|
|
187
|
-
*/
|
|
188
|
-
|
|
189
|
-
declare class RoomCore<S extends AnySchema = AnySchema> implements RoomCoreApi<S> {
|
|
190
|
-
readonly definition: RoomDefinition<S>;
|
|
191
|
-
readonly ext: AnySchema;
|
|
192
|
-
readonly host: RoomHost;
|
|
193
|
-
readonly roomId: string;
|
|
194
|
-
readonly mode: RoomMode;
|
|
195
|
-
readonly plain: PlainState;
|
|
196
|
-
readonly tracked: Tracked<AnySchema>;
|
|
197
|
-
readonly anyState: AnyRecord;
|
|
198
|
-
readonly rng: Mulberry32;
|
|
199
|
-
readonly clients: Map<string, ClientEntry>;
|
|
200
|
-
readonly loop: Loop;
|
|
201
|
-
readonly stats: RoomStats;
|
|
202
|
-
tick: number;
|
|
203
|
-
stopped: boolean;
|
|
204
|
-
private readonly seed;
|
|
205
|
-
private readonly api;
|
|
206
|
-
private readonly internals;
|
|
207
|
-
private started;
|
|
208
|
-
constructor(definition: RoomDefinition<S>, host: RoomHost, options: RoomCoreOptions);
|
|
209
|
-
/** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
|
|
210
|
-
static restore<S2 extends AnySchema>(definition: RoomDefinition<S2>, bytes: Uint8Array, host: RoomHost, options: Omit<RoomCoreOptions, 'restoreFrom'>): RoomCore<S2>;
|
|
211
|
-
get schema(): S;
|
|
212
|
-
get config(): ResolvedRoomConfig<S>;
|
|
213
|
-
get state(): State<S>;
|
|
214
|
-
get room(): Room<S>;
|
|
215
|
-
tryRun<T>(name: string, fn: () => T): GuardResult<T>;
|
|
216
|
-
private readonly events;
|
|
217
|
-
recordEvent(kind: RoomEventKind, clientId?: string, detail?: string): void;
|
|
218
|
-
/** Live JSON view of the room for the dev page / supervisor admin API. */
|
|
219
|
-
inspect(): RoomInspection;
|
|
220
|
-
guard<T>(name: string, fn: () => T): T | undefined;
|
|
221
|
-
log(level: LogLevel, ...args: unknown[]): void;
|
|
222
|
-
start(): void;
|
|
223
|
-
stop(): void;
|
|
224
|
-
serialize(): Uint8Array;
|
|
225
|
-
private get presence();
|
|
226
|
-
private resolveRole;
|
|
227
|
-
join(clientId: string, options?: JoinOptions): JoinResult;
|
|
228
|
-
/** Event mode only: presence/lifecycle changes are their own event. No-op in tick mode. */
|
|
229
|
-
private eventFlush;
|
|
230
|
-
leave(clientId: string, reason: LeaveReason): void;
|
|
231
|
-
markDisconnected(clientId: string): void;
|
|
232
|
-
ctxFor(clientId: string, reconnecting?: boolean): Ctx;
|
|
233
|
-
correctionFor(clientId: string): DirtySet | undefined;
|
|
234
|
-
invalidateClients(): void;
|
|
235
|
-
send(clientId: string, frame: Uint8Array): void;
|
|
236
|
-
private badFrame;
|
|
237
|
-
receive(clientId: string, frame: Uint8Array): void;
|
|
238
|
-
/**
|
|
239
|
-
* Hands the tracked dirty set out: server-wins corrections first, then per connected client its
|
|
240
|
-
* pending `CORRECT` (before the delta, so it sees the correction and then the broadcast) and
|
|
241
|
-
* its view's `DELTA` — encoded once per distinct view.
|
|
242
|
-
*/
|
|
243
|
-
flush(): void;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
export { CRASH_AFTER_THROWS as C, MAX_CATCHUP as M, RoomCore as R, Mulberry32 as a };
|