@homie-rocks/studio 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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +40 -0
- package/bin/homie-studio.mjs +166 -0
- package/lib/build.mjs +86 -0
- package/lib/check.mjs +123 -0
- package/lib/cloudflare.mjs +155 -0
- package/lib/directory.mjs +19 -0
- package/lib/scaffold.mjs +254 -0
- package/lib/studio.mjs +102 -0
- package/lib/version.mjs +17 -0
- package/netplay/NETPLAY.md +575 -0
- package/netplay/netplay.ts +1309 -0
- package/package.json +44 -0
- package/starters/gem-rush/game.json +9 -0
- package/starters/gem-rush/index.html +17 -0
- package/starters/gem-rush/src/main.ts +719 -0
- package/worker/index.mjs +349 -0
- package/worker/pages.mjs +205 -0
- package/worker/room.mjs +659 -0
package/worker/room.mjs
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* room.mjs — the netplay v1 relay semantics, with no transport in it.
|
|
3
|
+
* =============================================================================
|
|
4
|
+
*
|
|
5
|
+
* This is the reference implementation of what NETPLAY.md asks of the shell's
|
|
6
|
+
* relay. `worker/index.mjs` runs it unchanged inside the site's Table Durable
|
|
7
|
+
* Object, under `wrangler dev` locally and on Cloudflare once deployed. Every socket is a
|
|
8
|
+
* `conn` with `send(text)`, `close(code, reason)`, an optional `buffered()` and
|
|
9
|
+
* an optional `ip`; time comes from `now()`. No Node built-ins: it runs in
|
|
10
|
+
* workerd as it is.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT HOLDS, AND WHERE:
|
|
13
|
+
* memory the latest snapshot, checkpoint, round, roster and keyed state.
|
|
14
|
+
* Nothing is written per frame (NETPLAY.md, cost note).
|
|
15
|
+
* storage (optional `store`) the seat map, written only when a seat is
|
|
16
|
+
* allocated or the host changes, plus the latest checkpoint/round/
|
|
17
|
+
* roster/state at most every 30 s. That is what lets a Worker deploy
|
|
18
|
+
* (which evicts every Durable Object) keep seats, bodies and round.
|
|
19
|
+
* =============================================================================
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const NET_VERSION = 1;
|
|
23
|
+
const PALETTE_SIZE = 12;
|
|
24
|
+
const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
|
|
25
|
+
|
|
26
|
+
/** Size caps (bytes of the JSON text as received). */
|
|
27
|
+
export const LIMITS = Object.freeze({
|
|
28
|
+
hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
|
|
29
|
+
});
|
|
30
|
+
/** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
|
|
31
|
+
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8 });
|
|
32
|
+
/** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
|
|
33
|
+
export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
34
|
+
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
35
|
+
export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
36
|
+
|
|
37
|
+
/** Random bytes as base64url, with no Node Buffer (runs in workerd too). */
|
|
38
|
+
function randomId(n) {
|
|
39
|
+
const raw = new Uint8Array(n);
|
|
40
|
+
crypto.getRandomValues(raw);
|
|
41
|
+
let s = '';
|
|
42
|
+
for (const b of raw) s += String.fromCharCode(b);
|
|
43
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Press counts are summed by the host: clamp them so `{fire: 1e9}` is 8, not a billion loop turns. */
|
|
47
|
+
function clampPresses(p) {
|
|
48
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
|
|
49
|
+
const out = {};
|
|
50
|
+
let n = 0;
|
|
51
|
+
for (const [k, v] of Object.entries(p)) {
|
|
52
|
+
if (n >= 16) break;
|
|
53
|
+
const c = Math.max(0, Math.min(8, Math.floor(Number(v) || 0)));
|
|
54
|
+
if (c) { out[String(k).slice(0, 32)] = c; n += 1; }
|
|
55
|
+
}
|
|
56
|
+
return n ? out : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class NetRoom {
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} o
|
|
62
|
+
* @param {string} o.code room name
|
|
63
|
+
* @param {number} [o.maxPlayers] seats; the first hello of an empty room may lower it (`hello.max`)
|
|
64
|
+
* @param {number} [o.maxScreens] spectator sockets on top of the seats
|
|
65
|
+
* @param {number} [o.perIp] sockets per client address per room (0 = off); the Worker passes CF-Connecting-IP
|
|
66
|
+
* @param {number} [o.stallMs] a host with no snapshot this long while others are present is replaced
|
|
67
|
+
* @param {number} [o.silentMs] a host silent this long (or hidden) is replaced at once by a newcomer
|
|
68
|
+
* @param {number} [o.idleMs] any socket silent this long is closed (the helper pings every 2 s)
|
|
69
|
+
* @param {number} [o.holdMs] a dropped seat is held this long for its token, unless a present visitor needs it
|
|
70
|
+
* @param {number} [o.forgetMs] an EMPTY room forgets everything after this long
|
|
71
|
+
* @param {number} [o.persistMs] the checkpoint/round/state are saved at most this often
|
|
72
|
+
* @param {{save: (o: object) => void, clear?: () => void} | null} [o.store]
|
|
73
|
+
* @param {() => number} [o.now]
|
|
74
|
+
* @param {(line: object) => void} [o.log]
|
|
75
|
+
*/
|
|
76
|
+
constructor({
|
|
77
|
+
code, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
|
|
78
|
+
holdMs = 60_000, forgetMs = 60_000, persistMs = 30_000, store = null, now = Date.now, log = () => {},
|
|
79
|
+
}) {
|
|
80
|
+
this.code = code;
|
|
81
|
+
this.seatCap = maxPlayers;
|
|
82
|
+
this.maxPlayers = maxPlayers;
|
|
83
|
+
this.maxScreens = maxScreens;
|
|
84
|
+
this.perIp = perIp;
|
|
85
|
+
this.stallMs = stallMs;
|
|
86
|
+
this.silentMs = silentMs;
|
|
87
|
+
this.idleMs = idleMs;
|
|
88
|
+
this.holdMs = holdMs;
|
|
89
|
+
this.forgetMs = forgetMs;
|
|
90
|
+
this.persistMs = persistMs;
|
|
91
|
+
this.store = store;
|
|
92
|
+
this.now = now;
|
|
93
|
+
this.log = log;
|
|
94
|
+
/** id → client (including sockets that have not said hello yet) */
|
|
95
|
+
this.clients = new Map();
|
|
96
|
+
/** seat → { token, name, since, present } */
|
|
97
|
+
this.seats = new Map();
|
|
98
|
+
this.watchers = new Set();
|
|
99
|
+
this.hostId = null;
|
|
100
|
+
this.hostSince = 0;
|
|
101
|
+
this.lastJoinAt = 0;
|
|
102
|
+
this.lastSnap = null;
|
|
103
|
+
this.lastSnapText = null;
|
|
104
|
+
this.lastCkpt = null;
|
|
105
|
+
this.lastRound = null;
|
|
106
|
+
this.lastRoster = null;
|
|
107
|
+
/** key → { d, bytes } — slow world state, handed to every joiner and to a promoted host */
|
|
108
|
+
this.state = new Map();
|
|
109
|
+
this.stateBytes = 0;
|
|
110
|
+
this.emptySince = now();
|
|
111
|
+
/** After a restore: the seat that was host keeps first claim on the role for a moment. */
|
|
112
|
+
this.preferHost = null;
|
|
113
|
+
this.seatsDirty = false;
|
|
114
|
+
this.persistDirty = false;
|
|
115
|
+
this.lastPersistAt = 0;
|
|
116
|
+
this.stats = {
|
|
117
|
+
snaps: 0, ins: 0, evs: 0, ckpts: 0, states: 0, rounds: 0, drops: 0, oversize: 0, stFixed: 0, reclaimed: 0, seated: 0,
|
|
118
|
+
kicked: 0, refused: 0, persists: 0, restoredFrom: null, bytesIn: 0, bytesOut: 0, elections: [], promotions: 0,
|
|
119
|
+
};
|
|
120
|
+
this.snapTimes = [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ------------------------------------------------------------ sockets */
|
|
124
|
+
|
|
125
|
+
/** A new relay socket. Returns the two callbacks the transport must call. */
|
|
126
|
+
attach(conn) {
|
|
127
|
+
const now = this.now();
|
|
128
|
+
const c = {
|
|
129
|
+
id: randomId(6), conn, ip: conn.ip ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
|
|
130
|
+
device: 'desk', want: 'play', canHost: true, hidden: false, waiting: false, joinedAt: now, lastSeen: now, lastSnapAt: 0,
|
|
131
|
+
rates: new Map(), drops: [], errAt: 0,
|
|
132
|
+
};
|
|
133
|
+
this.clients.set(c.id, c);
|
|
134
|
+
return {
|
|
135
|
+
id: c.id,
|
|
136
|
+
onMessage: (text) => this.onMessage(c, text),
|
|
137
|
+
onClose: () => this.onClose(c, 'closed'),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
watch(conn) {
|
|
142
|
+
this.watchers.add(conn);
|
|
143
|
+
this.sendText(conn, JSON.stringify(this.facts()));
|
|
144
|
+
return { onClose: () => this.watchers.delete(conn) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ------------------------------------------------------------ wire */
|
|
148
|
+
|
|
149
|
+
sendText(conn, text) {
|
|
150
|
+
try { conn.send(text); this.stats.bytesOut += text.length; } catch { /* closing */ }
|
|
151
|
+
}
|
|
152
|
+
send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
|
|
153
|
+
error(c, code, message) { this.send(c, { t: 'error', code, message }); }
|
|
154
|
+
live() { return [...this.clients.values()].filter((c) => c.helloed); }
|
|
155
|
+
others(c) { return this.live().filter((o) => o !== c); }
|
|
156
|
+
host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
|
|
157
|
+
hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
|
|
158
|
+
roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
|
|
159
|
+
peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c) }; }
|
|
160
|
+
peers() { return this.live().map((c) => this.peer(c)); }
|
|
161
|
+
stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
|
|
162
|
+
|
|
163
|
+
/** Drop the socket from the room and close it. */
|
|
164
|
+
kick(c, why, code = 1008) {
|
|
165
|
+
this.stats.kicked += 1;
|
|
166
|
+
this.onClose(c, why);
|
|
167
|
+
try { c.conn.close(code, why); } catch { /* gone */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
allow(c, t, bytes) {
|
|
171
|
+
// 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).
|
|
172
|
+
const cap = t === 'ev' && c.seat === null && c.id !== this.hostId ? 512 : (LIMITS[t] ?? LIMITS.other);
|
|
173
|
+
if (bytes > cap) { this.stats.oversize += 1; this.error(c, 'too-large', `${t} is ${bytes} B; the cap is ${cap} B`); return false; }
|
|
174
|
+
const now = this.now();
|
|
175
|
+
const limit = t === 'ev' ? EV_RATES[this.roleOf(c)] : (RATES[t] ?? RATES.other);
|
|
176
|
+
const list = c.rates.get(t) ?? [];
|
|
177
|
+
while (list.length && list[0] <= now - 1000) list.shift();
|
|
178
|
+
c.rates.set(t, list);
|
|
179
|
+
if (list.length >= limit) {
|
|
180
|
+
this.stats.drops += 1;
|
|
181
|
+
c.drops.push(now);
|
|
182
|
+
while (c.drops.length && c.drops[0] <= now - 5000) c.drops.shift();
|
|
183
|
+
// Sustained abuse (20/s over a cap for 5 s) ends the socket; anything less is reported once a second.
|
|
184
|
+
if (c.drops.length > 100) { this.error(c, 'flood', 'too many messages over the rate caps'); this.kick(c, 'flood'); return false; }
|
|
185
|
+
if (now - c.errAt >= 1000) { c.errAt = now; this.send(c, { t: 'error', code: 'rate', of: t, message: `${t} over ${limit}/s was dropped` }); }
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
list.push(now);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
onMessage(c, text) {
|
|
193
|
+
if (this.clients.get(c.id) !== c) return;
|
|
194
|
+
text = String(text);
|
|
195
|
+
const now = this.now();
|
|
196
|
+
c.lastSeen = now;
|
|
197
|
+
this.stats.bytesIn += text.length;
|
|
198
|
+
let m;
|
|
199
|
+
try { m = JSON.parse(text); } catch { return; }
|
|
200
|
+
if (!m || typeof m.t !== 'string') return;
|
|
201
|
+
if (!c.helloed) { if (m.t === 'hello' && text.length <= LIMITS.hello) this.hello(c, m); return; }
|
|
202
|
+
if (!this.allow(c, m.t, text.length)) return;
|
|
203
|
+
const isHost = c.id === this.hostId;
|
|
204
|
+
switch (m.t) {
|
|
205
|
+
case 'snap': {
|
|
206
|
+
if (!isHost) return;
|
|
207
|
+
const snap = { k: Number(m.k) || 0, st: this.stamp(m.st), d: m.d ?? null };
|
|
208
|
+
if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
|
|
209
|
+
this.lastSnap = snap;
|
|
210
|
+
c.lastSnapAt = now;
|
|
211
|
+
this.stats.snaps += 1;
|
|
212
|
+
this.snapTimes.push(now);
|
|
213
|
+
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
214
|
+
const out = JSON.stringify({ t: 'snap', from: c.seat, ...snap });
|
|
215
|
+
this.lastSnapText = out;
|
|
216
|
+
for (const o of this.others(c)) {
|
|
217
|
+
// A congested socket skips a snapshot rather than queueing a stale one.
|
|
218
|
+
if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
|
|
219
|
+
this.sendText(o.conn, out);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
case 'in': {
|
|
224
|
+
if (isHost || c.seat === null) return;
|
|
225
|
+
const h = this.host();
|
|
226
|
+
if (!h) return;
|
|
227
|
+
this.stats.ins += 1;
|
|
228
|
+
const out = { t: 'in', from: c.seat, q: Number(m.q) || 0, a: m.a ?? null, h: Array.isArray(m.h) ? m.h.slice(0, 16).map((x) => String(x).slice(0, 32)) : [] };
|
|
229
|
+
if (Number.isFinite(m.r)) out.r = m.r;
|
|
230
|
+
const p = clampPresses(m.p);
|
|
231
|
+
if (p) out.p = p;
|
|
232
|
+
this.send(h, out);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
case 'ev': {
|
|
236
|
+
const kind = String(m.k ?? '').slice(0, 64);
|
|
237
|
+
this.stats.evs += 1;
|
|
238
|
+
const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
|
|
239
|
+
if (isHost) {
|
|
240
|
+
if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && o !== c) this.send(o, out); }
|
|
241
|
+
else if (Number.isInteger(m.to)) { for (const o of this.others(c)) if (o.seat === m.to) this.send(o, out); }
|
|
242
|
+
else for (const o of this.others(c)) this.send(o, out);
|
|
243
|
+
} else {
|
|
244
|
+
const h = this.host();
|
|
245
|
+
if (h) this.send(h, { ...out, id: c.id });
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
case 'ckpt': {
|
|
250
|
+
if (!isHost) return;
|
|
251
|
+
this.lastCkpt = { k: Number(m.k) || 0, st: this.stamp(m.st), d: m.d ?? null, ...(Array.isArray(m.c) ? { c: m.c.slice(0, 64) } : {}) };
|
|
252
|
+
this.stats.ckpts += 1;
|
|
253
|
+
this.persistDirty = true;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case 'state': {
|
|
257
|
+
if (!isHost) return;
|
|
258
|
+
const key = typeof m.k === 'string' ? m.k.slice(0, 64) : '';
|
|
259
|
+
if (!key) return;
|
|
260
|
+
const prev = this.state.get(key);
|
|
261
|
+
if (m.d === null || m.d === undefined) {
|
|
262
|
+
if (!prev) return;
|
|
263
|
+
this.state.delete(key);
|
|
264
|
+
this.stateBytes -= prev.bytes;
|
|
265
|
+
} else {
|
|
266
|
+
const bytes = text.length;
|
|
267
|
+
if ((!prev && this.state.size >= STATE_CAPS.keys) || this.stateBytes - (prev?.bytes ?? 0) + bytes > STATE_CAPS.bytes) {
|
|
268
|
+
this.error(c, 'state-full', `state is capped at ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.state.set(key, { d: m.d, bytes });
|
|
272
|
+
this.stateBytes += bytes - (prev?.bytes ?? 0);
|
|
273
|
+
}
|
|
274
|
+
this.stats.states += 1;
|
|
275
|
+
this.persistDirty = true;
|
|
276
|
+
const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
|
|
277
|
+
for (const o of this.others(c)) this.sendText(o.conn, out);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
case 'round': {
|
|
281
|
+
if (!isHost || !m.round || typeof m.round !== 'object') return;
|
|
282
|
+
this.lastRound = m.round;
|
|
283
|
+
this.stats.rounds += 1;
|
|
284
|
+
this.persistDirty = true;
|
|
285
|
+
for (const o of this.others(c)) this.send(o, { t: 'round', round: m.round });
|
|
286
|
+
this.tellWatchers();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
case 'roster': {
|
|
290
|
+
if (!isHost || !Array.isArray(m.slots)) return;
|
|
291
|
+
this.lastRoster = m.slots.slice(0, 64);
|
|
292
|
+
this.persistDirty = true;
|
|
293
|
+
for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
|
|
294
|
+
this.tellWatchers();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
case 'ping': {
|
|
298
|
+
const wasHidden = c.hidden;
|
|
299
|
+
c.hidden = m.hid === true;
|
|
300
|
+
this.send(c, { t: 'pong', c: m.c, st: now });
|
|
301
|
+
// A host whose tab went to the background will stop ticking: hand the round on now.
|
|
302
|
+
if (isHost && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
case 'yield': {
|
|
306
|
+
if (isHost) this.yieldHost(c, 'host-yielded');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
case 'bye': {
|
|
310
|
+
this.onClose(c, 'bye');
|
|
311
|
+
try { c.conn.close(1000, 'bye'); } catch { /* gone */ }
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
default:
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The host's `st` is its estimate of relay time. Keep it (its spacing is clean, which interpolation needs) unless
|
|
321
|
+
* it is implausible: a snapshot stamped in the future would freeze every replica's buffer for good.
|
|
322
|
+
*/
|
|
323
|
+
stamp(st) {
|
|
324
|
+
const now = this.now();
|
|
325
|
+
const n = Number(st);
|
|
326
|
+
if (Number.isFinite(n) && n <= now + 250 && n >= now - 2000) return n;
|
|
327
|
+
this.stats.stFixed += 1;
|
|
328
|
+
return now;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
hello(c, m) {
|
|
332
|
+
if (Number(m.v) !== NET_VERSION) {
|
|
333
|
+
this.error(c, 'version', `this relay speaks netplay v${NET_VERSION}`);
|
|
334
|
+
this.clients.delete(c.id);
|
|
335
|
+
try { c.conn.close(1000, 'version'); } catch { /* gone */ }
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const live = this.live();
|
|
339
|
+
const refuse = (code, message) => {
|
|
340
|
+
this.stats.refused += 1;
|
|
341
|
+
this.error(c, code, message);
|
|
342
|
+
this.clients.delete(c.id);
|
|
343
|
+
try { c.conn.close(1008, code); } catch { /* gone */ }
|
|
344
|
+
};
|
|
345
|
+
if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
|
|
346
|
+
if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
|
|
347
|
+
const now = this.now();
|
|
348
|
+
c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
|
|
349
|
+
c.want = m.want === 'screen' ? 'screen' : 'play';
|
|
350
|
+
c.canHost = m.canHost !== false;
|
|
351
|
+
c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
|
|
352
|
+
c.typed = typeof m.name === 'string' ? m.name.replace(/\s+/g, ' ').trim().slice(0, 24) : '';
|
|
353
|
+
// The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
|
|
354
|
+
if (Number.isInteger(m.max) && !this.seats.size && !live.length) this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max));
|
|
355
|
+
this.reapSeats(now);
|
|
356
|
+
let full = false;
|
|
357
|
+
if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) { full = true; c.waiting = true; }
|
|
358
|
+
if (c.seat === null) c.name = c.typed || (c.want === 'screen' ? 'Screen' : 'Watcher');
|
|
359
|
+
c.helloed = true;
|
|
360
|
+
c.joinedAt = now;
|
|
361
|
+
this.emptySince = 0;
|
|
362
|
+
this.lastJoinAt = now;
|
|
363
|
+
|
|
364
|
+
let role = c.seat === null ? 'screen' : 'replica';
|
|
365
|
+
let why = 'joined';
|
|
366
|
+
let deposed = null;
|
|
367
|
+
const h = this.host();
|
|
368
|
+
const preferred = this.preferHost && now < this.preferHost.until ? this.preferHost.seat : undefined;
|
|
369
|
+
if (c.canHost && (preferred === undefined || preferred === c.seat)) {
|
|
370
|
+
if (!h) {
|
|
371
|
+
role = 'host';
|
|
372
|
+
why = this.lastSnap || this.lastCkpt ? 'resumed' : 'first';
|
|
373
|
+
if (preferred !== undefined) this.preferHost = null;
|
|
374
|
+
} else if (h.hidden || now - h.lastSeen > this.silentMs) {
|
|
375
|
+
// The host is a frozen phone (socket open, nothing sent): the newcomer runs the round now, not after a stall.
|
|
376
|
+
deposed = h;
|
|
377
|
+
role = 'host';
|
|
378
|
+
why = h.hidden ? 'host-hidden' : 'host-stalled';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (role === 'host') {
|
|
382
|
+
this.hostId = c.id;
|
|
383
|
+
this.hostSince = now;
|
|
384
|
+
c.lastSnapAt = 0;
|
|
385
|
+
this.seatsDirty = true;
|
|
386
|
+
this.stats.elections.push({ at: now, id: c.id, seat: c.seat, why });
|
|
387
|
+
if (this.stats.elections.length > 32) this.stats.elections.shift();
|
|
388
|
+
}
|
|
389
|
+
this.send(c, {
|
|
390
|
+
t: 'welcome', v: NET_VERSION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
391
|
+
role, why, host: this.hostRef(), peers: this.peers(), st: now, max: this.maxPlayers,
|
|
392
|
+
round: this.lastRound, roster: this.lastRoster, snap: this.lastSnap, state: this.stateObject(),
|
|
393
|
+
...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
|
|
394
|
+
...(full ? { full: true } : {}),
|
|
395
|
+
});
|
|
396
|
+
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
397
|
+
if (deposed) {
|
|
398
|
+
this.send(deposed, { t: 'role', role: deposed.seat === null ? 'screen' : 'replica', why, host: this.hostRef(), peers: this.peers() });
|
|
399
|
+
for (const o of this.others(c)) if (o !== deposed) this.send(o, { t: 'host', host: this.hostRef(), why });
|
|
400
|
+
}
|
|
401
|
+
this.log({ ev: 'hello', room: this.code, id: c.id, seat: c.seat, device: c.device, want: c.want, role, why, full, resumed: typeof m.token === 'string' && m.token === c.token });
|
|
402
|
+
this.persist(now); // a new seat (or a new host) is written now, not on the next tick: a deploy can come any moment
|
|
403
|
+
this.tellWatchers();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Give `c` a seat: its token's seat, a free one, or the seat absent longest. False when every seat is present. */
|
|
407
|
+
seatClient(c, token) {
|
|
408
|
+
const seat = this.seatFor(token);
|
|
409
|
+
if (seat === null) return false;
|
|
410
|
+
const s = this.seats.get(seat);
|
|
411
|
+
// Same seat open twice (two tabs, or a reload racing its own close): the newer socket wins.
|
|
412
|
+
for (const o of [...this.clients.values()]) {
|
|
413
|
+
if (o !== c && o.helloed && o.seat === seat) {
|
|
414
|
+
this.error(o, 'replaced', 'this seat opened elsewhere');
|
|
415
|
+
this.onClose(o, 'replaced');
|
|
416
|
+
try { o.conn.close(1000, 'replaced'); } catch { /* gone */ }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
c.seat = seat;
|
|
420
|
+
c.token = s.token;
|
|
421
|
+
c.waiting = false;
|
|
422
|
+
s.present = true;
|
|
423
|
+
const name = c.typed || s.name || `Player ${seat + 1}`;
|
|
424
|
+
if (name !== s.name) { s.name = name; this.seatsDirty = true; }
|
|
425
|
+
c.name = name;
|
|
426
|
+
c.colour = seat % PALETTE_SIZE;
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
seatFor(token) {
|
|
431
|
+
if (token) for (const [seat, s] of this.seats) if (s.token === token) return seat;
|
|
432
|
+
const fresh = () => ({ token: randomId(18), name: '', since: this.now(), present: true });
|
|
433
|
+
for (let seat = 0; seat < this.maxPlayers; seat += 1) {
|
|
434
|
+
if (this.seats.has(seat)) continue;
|
|
435
|
+
this.seats.set(seat, fresh());
|
|
436
|
+
this.seatsDirty = true;
|
|
437
|
+
return seat;
|
|
438
|
+
}
|
|
439
|
+
// Every seat is present or held. A visitor who is here beats one who left: reclaim the seat absent longest.
|
|
440
|
+
let oldest = null;
|
|
441
|
+
for (const [seat, s] of this.seats) if (!s.present && seat < this.maxPlayers && (!oldest || s.since < oldest[1].since)) oldest = [seat, s];
|
|
442
|
+
if (!oldest) return null;
|
|
443
|
+
this.seats.set(oldest[0], fresh());
|
|
444
|
+
this.seatsDirty = true;
|
|
445
|
+
this.stats.reclaimed += 1;
|
|
446
|
+
return oldest[0];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
reapSeats(now) {
|
|
450
|
+
for (const [seat, s] of this.seats) {
|
|
451
|
+
if (!s.present && now - s.since > this.holdMs) { this.seats.delete(seat); this.seatsDirty = true; }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Spectators who wanted to play take any seat that is free or reclaimable, oldest first. */
|
|
456
|
+
seatWaiting() {
|
|
457
|
+
for (const c of this.live()) {
|
|
458
|
+
if (!c.waiting || c.seat !== null) continue;
|
|
459
|
+
if (!this.seatClient(c, '')) return;
|
|
460
|
+
this.stats.seated += 1;
|
|
461
|
+
this.send(c, { t: 'seat', seat: c.seat, token: c.token, name: c.name, colour: c.colour, role: this.roleOf(c) });
|
|
462
|
+
for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
|
|
463
|
+
this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
|
|
464
|
+
this.persist();
|
|
465
|
+
this.tellWatchers();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
onClose(c, why) {
|
|
470
|
+
if (this.clients.get(c.id) !== c) return;
|
|
471
|
+
this.clients.delete(c.id);
|
|
472
|
+
if (!c.helloed) return;
|
|
473
|
+
const now = this.now();
|
|
474
|
+
if (c.seat !== null) {
|
|
475
|
+
const s = this.seats.get(c.seat);
|
|
476
|
+
if (s && s.token === c.token) { s.present = false; s.since = now; }
|
|
477
|
+
}
|
|
478
|
+
for (const o of this.others(c)) this.send(o, { t: 'leave', id: c.id, seat: c.seat, why });
|
|
479
|
+
this.log({ ev: 'leave', room: this.code, id: c.id, seat: c.seat, why });
|
|
480
|
+
if (c.id === this.hostId) {
|
|
481
|
+
this.hostId = null;
|
|
482
|
+
this.elect(null, why === 'replaced' ? 'host-replaced' : why === 'silent' ? 'host-stalled' : 'host-left');
|
|
483
|
+
}
|
|
484
|
+
if (!this.live().length) this.emptySince = now;
|
|
485
|
+
this.seatWaiting();
|
|
486
|
+
this.tellWatchers();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Choose a host. Visible and responsive first; then tenure in 30 s steps (a newcomer cannot jump the queue by
|
|
491
|
+
* calling itself a desktop); then desk > tv > phone as a tie-break; then whoever came first. The chosen browser
|
|
492
|
+
* is handed the last checkpoint, the last snapshot (newer), the round, the roster and the keyed state.
|
|
493
|
+
*/
|
|
494
|
+
elect(excludeId, why) {
|
|
495
|
+
const now = this.now();
|
|
496
|
+
const silent = (c) => Number(now - c.lastSeen > this.silentMs);
|
|
497
|
+
const tenure = (c) => Math.floor((now - c.joinedAt) / 30_000);
|
|
498
|
+
const cands = this.live()
|
|
499
|
+
.filter((c) => c.canHost && c.id !== excludeId)
|
|
500
|
+
.sort((a, b) => Number(a.hidden) - Number(b.hidden)
|
|
501
|
+
|| silent(a) - silent(b)
|
|
502
|
+
|| tenure(b) - tenure(a)
|
|
503
|
+
|| (DEVICE_RANK[a.device] ?? 3) - (DEVICE_RANK[b.device] ?? 3)
|
|
504
|
+
|| a.joinedAt - b.joinedAt);
|
|
505
|
+
const next = cands[0] ?? null;
|
|
506
|
+
if (!next) {
|
|
507
|
+
this.hostId = null;
|
|
508
|
+
for (const o of this.live()) this.send(o, { t: 'host', host: null, why });
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
this.hostId = next.id;
|
|
512
|
+
this.hostSince = now;
|
|
513
|
+
this.preferHost = null;
|
|
514
|
+
this.seatsDirty = true;
|
|
515
|
+
next.lastSnapAt = 0;
|
|
516
|
+
this.stats.promotions += 1;
|
|
517
|
+
this.stats.elections.push({ at: now, id: next.id, seat: next.seat, why });
|
|
518
|
+
if (this.stats.elections.length > 32) this.stats.elections.shift();
|
|
519
|
+
this.send(next, {
|
|
520
|
+
t: 'role', role: 'host', why, host: this.hostRef(), peers: this.peers(),
|
|
521
|
+
ckpt: this.lastCkpt, snap: this.lastSnap, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
|
|
522
|
+
});
|
|
523
|
+
for (const o of this.others(next)) this.send(o, { t: 'host', host: this.hostRef(), why });
|
|
524
|
+
this.log({ ev: 'elect', room: this.code, id: next.id, seat: next.seat, why, ckptAge: this.lastCkpt ? now - this.lastCkpt.st : null });
|
|
525
|
+
this.persist(now);
|
|
526
|
+
this.tellWatchers();
|
|
527
|
+
return next;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Move the host role off `c` if anybody else can take it. */
|
|
531
|
+
yieldHost(c, why) {
|
|
532
|
+
if (c.id !== this.hostId) return;
|
|
533
|
+
const next = this.elect(c.id, why);
|
|
534
|
+
if (!next) { this.hostId = c.id; return; } // nobody else: keep it
|
|
535
|
+
this.send(c, { t: 'role', role: c.seat === null ? 'screen' : 'replica', why, host: this.hostRef(), peers: this.peers() });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Call every ~250 ms (setInterval locally, an alarm or the socket traffic on a Durable Object). */
|
|
539
|
+
tick() {
|
|
540
|
+
const now = this.now();
|
|
541
|
+
// Sockets that never said hello, or went silent (a slept phone, a half-open link): close them, and a seat
|
|
542
|
+
// becomes a bot instead of a frozen body.
|
|
543
|
+
for (const c of [...this.clients.values()]) {
|
|
544
|
+
if (!c.helloed) { if (now - c.joinedAt > 5000) { this.clients.delete(c.id); try { c.conn.close(4000, 'no-hello'); } catch { /* gone */ } } continue; }
|
|
545
|
+
if (now - c.lastSeen > this.idleMs) this.kick(c, 'silent', 4000);
|
|
546
|
+
}
|
|
547
|
+
// A host that stopped sending snapshots while others are present.
|
|
548
|
+
const h = this.host();
|
|
549
|
+
if (h && this.live().length > 1) {
|
|
550
|
+
const since = Math.max(h.lastSnapAt, this.hostSince, this.lastJoinAt);
|
|
551
|
+
if (now - since > this.stallMs) {
|
|
552
|
+
const next = this.elect(h.id, 'host-stalled');
|
|
553
|
+
if (next) this.send(h, { t: 'role', role: h.seat === null ? 'screen' : 'replica', why: 'host-stalled', host: this.hostRef(), peers: this.peers() });
|
|
554
|
+
else { this.hostId = h.id; this.hostSince = now; }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// Nobody hosting (a restored room whose old host did not come back in time): elect.
|
|
558
|
+
if (!this.host() && (!this.preferHost || now >= this.preferHost.until)) {
|
|
559
|
+
this.preferHost = null;
|
|
560
|
+
if (this.live().some((c) => c.canHost)) this.elect(null, 'host-left');
|
|
561
|
+
}
|
|
562
|
+
this.reapSeats(now);
|
|
563
|
+
this.seatWaiting();
|
|
564
|
+
if (!this.live().length && this.emptySince && now - this.emptySince > this.forgetMs) {
|
|
565
|
+
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
566
|
+
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
|
|
567
|
+
this.emptySince = 0;
|
|
568
|
+
this.seatsDirty = false; this.persistDirty = false;
|
|
569
|
+
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
570
|
+
}
|
|
571
|
+
this.persist(now);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/* ------------------------------------------------------------ storage (optional) */
|
|
575
|
+
|
|
576
|
+
/** Seats on change; checkpoint/round/roster/state at most every persistMs. Never per frame. */
|
|
577
|
+
persist(now = this.now(), force = false) {
|
|
578
|
+
if (!this.store) return;
|
|
579
|
+
const due = this.seatsDirty || (this.persistDirty && now - this.lastPersistAt >= this.persistMs);
|
|
580
|
+
if (!force && !due) return;
|
|
581
|
+
this.seatsDirty = false;
|
|
582
|
+
this.persistDirty = false;
|
|
583
|
+
this.lastPersistAt = now;
|
|
584
|
+
this.stats.persists += 1;
|
|
585
|
+
try { this.store.save(this.saved(now)); } catch (err) { this.log({ ev: 'persist-failed', room: this.code, error: String(err) }); }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
saved(now = this.now()) {
|
|
589
|
+
return {
|
|
590
|
+
v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null,
|
|
591
|
+
seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name]),
|
|
592
|
+
ckpt: this.lastCkpt, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* After an eviction (a deploy): seats keep their tokens (so every reconnecting browser gets its own seat back),
|
|
598
|
+
* the round, roster, state and last checkpoint come back, and the seat that was host gets 3 s to reclaim the role
|
|
599
|
+
* before anyone else is elected. Saves older than 2 minutes are ignored: that room is over.
|
|
600
|
+
*/
|
|
601
|
+
restore(saved, graceMs = 3000) {
|
|
602
|
+
const now = this.now();
|
|
603
|
+
if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
|
|
604
|
+
if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
|
|
605
|
+
for (const [seat, token, name] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false });
|
|
606
|
+
this.lastCkpt = saved.ckpt ?? null;
|
|
607
|
+
this.lastRound = saved.round ?? null;
|
|
608
|
+
this.lastRoster = saved.roster ?? null;
|
|
609
|
+
for (const [k, d] of Object.entries(saved.state ?? {})) {
|
|
610
|
+
const bytes = JSON.stringify({ t: 'state', k, d }).length;
|
|
611
|
+
this.state.set(k, { d, bytes });
|
|
612
|
+
this.stateBytes += bytes;
|
|
613
|
+
}
|
|
614
|
+
if (Number.isInteger(saved.hostSeat)) this.preferHost = { seat: saved.hostSeat, until: now + graceMs };
|
|
615
|
+
this.emptySince = now;
|
|
616
|
+
this.stats.restoredFrom = now - saved.savedAt;
|
|
617
|
+
this.log({ ev: 'restored', room: this.code, seats: this.seats.size, ageMs: now - saved.savedAt, hostSeat: saved.hostSeat });
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* ------------------------------------------------------------ the shell's view */
|
|
622
|
+
|
|
623
|
+
facts() {
|
|
624
|
+
const now = this.now();
|
|
625
|
+
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
626
|
+
const slots = Array.isArray(this.lastRoster) ? this.lastRoster : [];
|
|
627
|
+
const live = this.live();
|
|
628
|
+
return {
|
|
629
|
+
t: 'net', v: NET_VERSION, room: this.code, st: now,
|
|
630
|
+
host: this.hostRef(),
|
|
631
|
+
clients: live.map((c) => ({ ...this.peer(c), hidden: c.hidden, waiting: c.waiting })),
|
|
632
|
+
round: this.lastRound,
|
|
633
|
+
roster: this.lastRoster,
|
|
634
|
+
snapHz: this.snapTimes.length,
|
|
635
|
+
counts: {
|
|
636
|
+
players: live.filter((c) => c.seat !== null).length,
|
|
637
|
+
screens: live.filter((c) => c.seat === null).length,
|
|
638
|
+
waiting: live.filter((c) => c.waiting).length,
|
|
639
|
+
humans: slots.filter((s) => s && !s.bot).length,
|
|
640
|
+
bots: slots.filter((s) => s && s.bot).length,
|
|
641
|
+
maxPlayers: this.maxPlayers,
|
|
642
|
+
},
|
|
643
|
+
memory: {
|
|
644
|
+
snapBytes: this.lastSnapText ? this.lastSnapText.length : 0,
|
|
645
|
+
ckptBytes: this.lastCkpt ? JSON.stringify(this.lastCkpt).length : 0,
|
|
646
|
+
ckptAgeMs: this.lastCkpt ? now - this.lastCkpt.st : null,
|
|
647
|
+
stateKeys: this.state.size,
|
|
648
|
+
stateBytes: this.stateBytes,
|
|
649
|
+
},
|
|
650
|
+
stats: { ...this.stats, elections: this.stats.elections.slice(-8) },
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
tellWatchers() {
|
|
655
|
+
if (!this.watchers.size) return;
|
|
656
|
+
const text = JSON.stringify(this.facts());
|
|
657
|
+
for (const w of this.watchers) this.sendText(w, text);
|
|
658
|
+
}
|
|
659
|
+
}
|