tickcli-js 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +360 -2
- package/client/game.js +1003 -0
- package/client/game3d.js +109 -0
- package/client/relay.js +196 -0
- package/client/room.js +632 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -0
- package/dist/hub.d.ts +619 -0
- package/dist/hub.js +1777 -0
- package/dist/hub.js.map +1 -0
- package/dist/node.d.ts +106 -0
- package/dist/node.js +301 -0
- package/dist/node.js.map +1 -0
- package/dist/remote.d.ts +75 -0
- package/dist/remote.js +214 -0
- package/dist/remote.js.map +1 -0
- package/dist/serve.d.ts +42 -0
- package/dist/serve.js +231 -0
- package/dist/serve.js.map +1 -0
- package/examples/games/arena.html +108 -0
- package/examples/games/arena3d.html +110 -0
- package/examples/games/chess.html +132 -0
- package/examples/games/cup.html +52 -0
- package/examples/games/delve.html +137 -0
- package/examples/games/dungeon.html +110 -0
- package/examples/games/neon-paint.html +79 -0
- package/examples/games/own-engine.html +41 -0
- package/examples/games/race.html +83 -0
- package/examples/games/shooter.html +95 -0
- package/examples/games/sky-duel.html +136 -0
- package/examples/games/space-squad.html +3627 -0
- package/examples/games/tag.html +51 -0
- package/examples/games/tictactoe.html +53 -0
- package/package.json +101 -4
- package/src/cli.ts +59 -0
- package/src/hub.ts +1521 -0
- package/src/node.ts +245 -0
- package/src/remote.ts +236 -0
- package/src/serve.ts +224 -0
package/dist/hub.js
ADDED
|
@@ -0,0 +1,1777 @@
|
|
|
1
|
+
// tickcli hub: live rooms for browser games. Pure logic: no sockets, an injected clock, so every rule is unit-testable; start() runs the
|
|
2
|
+
// 10 ms sub-tick on a timer (tests call tick() themselves). node.ts connects it to WebSocket sockets (ws); any other transport only needs the Conn interface below.
|
|
3
|
+
//
|
|
4
|
+
// Who decides what: each player writes only their own state (me), one player's browser (the host) writes the shared state, and this hub owns membership,
|
|
5
|
+
// order, the match number, the change counter, the host term and the clock. The host's writes carry its term; a write from an older term or an older
|
|
6
|
+
// connection is refused. The shared state lives here, so a new host continues from this copy, never from its own memory.
|
|
7
|
+
//
|
|
8
|
+
// Saving is optional and goes through a port (SavePort, below): the hub never touches storage itself. With a port and a slot (a stable game key the app
|
|
9
|
+
// decides, not the page hash, so editing a game keeps its saves) a person has a PROFILE (stats, items, level: their own, 16 KB) and a game that asks for
|
|
10
|
+
// it (multiplayer.save) has a WORLD save (the host's shared state, written every few seconds and when the match ends; Continue starts a new room from it).
|
|
11
|
+
// Without a port nothing is saved: a restart ends every match (close 1012), players reconnect and start a new one.
|
|
12
|
+
//
|
|
13
|
+
// Writes merge deeply: {hp: {a: 80}} changes only a's hp; null removes a key; lists are replaced whole. Shared keys starting with "_" belong to the game
|
|
14
|
+
// shell (teams) and survive a new match.
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
export const PROTOCOL = 1;
|
|
17
|
+
/** Close codes (also in docs/security.md). 1012 = restart. */
|
|
18
|
+
export const CLOSE = {
|
|
19
|
+
restart: 1012, roomError: 1011, busy: 1013, silent: 4009, notAllowed: 4001, full: 4002, replaced: 4003, tooFast: 4004, badMessage: 4005, removed: 4006, gameUpdated: 4007, tooSlow: 4008, idle: 4010,
|
|
20
|
+
};
|
|
21
|
+
export const DEFAULT_LIMITS = {
|
|
22
|
+
maxPlayers: 16, maxRooms: 500, maxRoomsPerScope: 50, maxConnections: 2000, maxPending: 200, maxPerAddress: 20, joinsPerMinute: 30,
|
|
23
|
+
messageBytes: 16 * 1024, playerStateBytes: 2048, sharedStateBytes: 64 * 1024, stateRate: 50, sharedRate: 20, actionRate: 5, timeRate: 2,
|
|
24
|
+
pendingMs: 5000, seatHoldMs: 30_000, evictAfterMs: 10_000, awayHoldMs: 300_000, hostSilenceMs: 5000, silentMs: 15_000, acceptRate: 200, roomOutBytes: 250 * 1024, maxOutMbit: 100, actionTimeoutMs: 5000, slowBytes: 64 * 1024, dropBytes: 256 * 1024, rateBytes: 16 * 1024, rateUpMs: 10_000, listRate: 1, idleMs: 15 * 60_000, idleWarnMs: 60_000, listIdleMs: 5 * 60_000, tickMs: 50,
|
|
25
|
+
profileBytes: 16 * 1024, profileEveryMs: 3000, worldEveryMs: 5000, saveDays: 90,
|
|
26
|
+
};
|
|
27
|
+
const CODE_ABC = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I/L
|
|
28
|
+
const CODE_RE = /^[A-HJ-NP-Z2-9]{4,6}$/;
|
|
29
|
+
const NAME_RE = /^[a-z_][a-z0-9_-]{0,23}$/i; // a leading underscore is the game shell's own (_team, _seat, _shot)
|
|
30
|
+
const BAD_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
31
|
+
/** The shape limits of everything a page writes (also in room.js, which checks before sending, and in the README). */
|
|
32
|
+
export const SHAPE = { text: 200, list: 64, keys: 64, depth: 4, key: 40 };
|
|
33
|
+
/** Plain JSON within the shape limits: depth, keys, text and list lengths, finite numbers. Returns false for anything else. */
|
|
34
|
+
export function plain(v, depth = 0) { return badPath(v, depth) === null; }
|
|
35
|
+
/** Where a value breaks the shape limits: the path of the first bad part ("mobs.12.name", "" for the value itself), or null when it is fine. */
|
|
36
|
+
export function badPath(v, depth = 0, at = "") {
|
|
37
|
+
if (v === null || typeof v === "boolean")
|
|
38
|
+
return null;
|
|
39
|
+
if (typeof v === "number")
|
|
40
|
+
return Number.isFinite(v) ? null : at;
|
|
41
|
+
if (typeof v === "string")
|
|
42
|
+
return v.length <= SHAPE.text ? null : at;
|
|
43
|
+
if (depth >= SHAPE.depth)
|
|
44
|
+
return at;
|
|
45
|
+
if (Array.isArray(v)) {
|
|
46
|
+
if (v.length > SHAPE.list)
|
|
47
|
+
return at;
|
|
48
|
+
for (let i = 0; i < v.length; i++) {
|
|
49
|
+
const b = badPath(v[i], depth + 1, at ? `${at}.${i}` : String(i));
|
|
50
|
+
if (b !== null)
|
|
51
|
+
return b;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (typeof v !== "object" || Object.getPrototypeOf(v) !== Object.prototype)
|
|
56
|
+
return at;
|
|
57
|
+
const keys = Object.keys(v);
|
|
58
|
+
if (keys.length > SHAPE.keys)
|
|
59
|
+
return at;
|
|
60
|
+
for (const k of keys) {
|
|
61
|
+
const here = at ? `${at}.${k}` : k;
|
|
62
|
+
if (k.length > SHAPE.key || BAD_KEYS.has(k))
|
|
63
|
+
return here;
|
|
64
|
+
const b = badPath(v[k], depth + 1, here);
|
|
65
|
+
if (b !== null)
|
|
66
|
+
return b;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/** A checked spec, or null. */
|
|
71
|
+
export function parseSpec(raw, maxPlayers = DEFAULT_LIMITS.maxPlayers) {
|
|
72
|
+
if (!raw || typeof raw !== "object")
|
|
73
|
+
return null;
|
|
74
|
+
const r = raw;
|
|
75
|
+
const sv = Number(r.saveVersion ?? 1);
|
|
76
|
+
if (!Number.isInteger(sv) || sv < 1 || sv > 1e6)
|
|
77
|
+
return null;
|
|
78
|
+
const max = Number(r.max ?? 4);
|
|
79
|
+
if (!Number.isInteger(max) || max < 1 || max > maxPlayers)
|
|
80
|
+
return null;
|
|
81
|
+
const player = {};
|
|
82
|
+
const p = r.player ?? {};
|
|
83
|
+
if (!p || typeof p !== "object" || Array.isArray(p))
|
|
84
|
+
return null;
|
|
85
|
+
const keys = Object.keys(p);
|
|
86
|
+
if (keys.length > 16)
|
|
87
|
+
return null;
|
|
88
|
+
for (const k of keys) {
|
|
89
|
+
const range = p[k];
|
|
90
|
+
if (!NAME_RE.test(k) || BAD_KEYS.has(k) || !Array.isArray(range) || range.length !== 2)
|
|
91
|
+
return null;
|
|
92
|
+
const [lo, hi] = range.map(Number);
|
|
93
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo >= hi || Math.abs(lo) > 1e9 || Math.abs(hi) > 1e9)
|
|
94
|
+
return null;
|
|
95
|
+
player[k] = [lo, hi];
|
|
96
|
+
}
|
|
97
|
+
return { max, player, save: r.save === true, saveVersion: sv };
|
|
98
|
+
}
|
|
99
|
+
/** The world save a room reads and writes: the shared one, or (Play alone) its owner's own. */
|
|
100
|
+
const worldOf = (room) => (room.owner ? `alone:${room.owner}` : null);
|
|
101
|
+
const stable = (spec) => JSON.stringify({ max: spec.max, save: spec.save, sv: spec.saveVersion, player: Object.keys(spec.player).sort().map((k) => [k, spec.player[k]]) });
|
|
102
|
+
const bytes = (v) => Buffer.byteLength(JSON.stringify(v));
|
|
103
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
104
|
+
/** Apply a patch to state: objects merge key by key, null removes, anything else (lists too) replaces. Returns a new object; `into` is not changed. */
|
|
105
|
+
export function applyPatch(into, patch) {
|
|
106
|
+
const out = { ...into };
|
|
107
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
108
|
+
if (v === null)
|
|
109
|
+
delete out[k];
|
|
110
|
+
else if (isObj(v))
|
|
111
|
+
out[k] = applyPatch(isObj(out[k]) ? out[k] : {}, v);
|
|
112
|
+
else
|
|
113
|
+
out[k] = v;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** The part of a patch that changes `cur`: leaves equal to the current value are left out, a null for a key that is not there too, lists equal by
|
|
118
|
+
* their JSON text. Null = nothing changes. So a game may write whole objects every step: only what changed is applied, counted and sent. */
|
|
119
|
+
export function changedOnly(cur, patch) {
|
|
120
|
+
let out = null;
|
|
121
|
+
for (const k of Object.keys(patch)) {
|
|
122
|
+
const v = patch[k], has = Object.prototype.hasOwnProperty.call(cur, k), c = has ? cur[k] : undefined;
|
|
123
|
+
let keep;
|
|
124
|
+
if (v === null) {
|
|
125
|
+
if (!has)
|
|
126
|
+
continue;
|
|
127
|
+
keep = null;
|
|
128
|
+
}
|
|
129
|
+
else if (isObj(v)) {
|
|
130
|
+
if (isObj(c)) {
|
|
131
|
+
const d = changedOnly(c, v);
|
|
132
|
+
if (!d)
|
|
133
|
+
continue;
|
|
134
|
+
keep = d;
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
keep = stripNulls(v);
|
|
138
|
+
}
|
|
139
|
+
else if (Array.isArray(v)) {
|
|
140
|
+
if (Array.isArray(c) && JSON.stringify(c) === JSON.stringify(v))
|
|
141
|
+
continue;
|
|
142
|
+
keep = v;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
if (c === v && (v !== 0 || Object.is(c, v)))
|
|
146
|
+
continue;
|
|
147
|
+
keep = v;
|
|
148
|
+
}
|
|
149
|
+
(out ??= {})[k] = keep;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
const stripNulls = (v) => {
|
|
154
|
+
const o = {};
|
|
155
|
+
for (const [k, x] of Object.entries(v))
|
|
156
|
+
if (x !== null)
|
|
157
|
+
o[k] = isObj(x) ? stripNulls(x) : x;
|
|
158
|
+
return o;
|
|
159
|
+
};
|
|
160
|
+
const jlen = (v) => Buffer.byteLength(JSON.stringify(v));
|
|
161
|
+
/** How many bytes the JSON text of `cur` grows by when `patch` (already reduced by changedOnly) is applied: only the changed leaves are measured. */
|
|
162
|
+
export function sizeDelta(cur, patch) {
|
|
163
|
+
let d = 0, n0 = 0, n1 = 0;
|
|
164
|
+
for (const _ in cur)
|
|
165
|
+
n0++;
|
|
166
|
+
n1 = n0;
|
|
167
|
+
for (const k of Object.keys(patch)) {
|
|
168
|
+
const v = patch[k], has = Object.prototype.hasOwnProperty.call(cur, k), c = has ? cur[k] : undefined, kl = jlen(k) + 1;
|
|
169
|
+
if (v === null) {
|
|
170
|
+
if (has) {
|
|
171
|
+
d -= kl + jlen(c);
|
|
172
|
+
n1--;
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (isObj(v) && isObj(c)) {
|
|
177
|
+
d += sizeDelta(c, v);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const nv = isObj(v) ? stripNulls(v) : v;
|
|
181
|
+
d += jlen(nv) - (has ? jlen(c) : 0);
|
|
182
|
+
if (!has) {
|
|
183
|
+
d += kl;
|
|
184
|
+
n1++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return d + Math.max(0, n1 - 1) - Math.max(0, n0 - 1);
|
|
188
|
+
}
|
|
189
|
+
/** Combine two patches that go out together (nulls kept, so removals still reach the others). */
|
|
190
|
+
export function joinPatch(into, patch) {
|
|
191
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
192
|
+
if (isObj(v) && isObj(into[k]))
|
|
193
|
+
joinPatch(into[k], v);
|
|
194
|
+
else
|
|
195
|
+
into[k] = isObj(v) ? structuredClone(v) : v;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
class Bucket {
|
|
199
|
+
tokens;
|
|
200
|
+
at;
|
|
201
|
+
rate;
|
|
202
|
+
constructor(rate, now) { this.rate = rate; this.tokens = rate * 2; this.at = now; }
|
|
203
|
+
take(now) {
|
|
204
|
+
this.tokens = Math.min(this.rate * 2, this.tokens + ((now - this.at) / 1000) * this.rate);
|
|
205
|
+
this.at = now;
|
|
206
|
+
if (this.tokens < 1)
|
|
207
|
+
return false;
|
|
208
|
+
this.tokens -= 1;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const emptyFrame = () => ({ players: new Map(), pt: new Map(), shared: null, sc: 0, fx: [] });
|
|
213
|
+
/** Folds `f` into `into` (later values win, as when a page applies both in order). */
|
|
214
|
+
function foldFrame(into, f) {
|
|
215
|
+
for (const [id, p] of f.players) {
|
|
216
|
+
const cur = into.players.get(id);
|
|
217
|
+
if (cur)
|
|
218
|
+
joinPatch(cur, p);
|
|
219
|
+
else
|
|
220
|
+
into.players.set(id, structuredClone(p));
|
|
221
|
+
}
|
|
222
|
+
for (const [id, t] of f.pt)
|
|
223
|
+
into.pt.set(id, t);
|
|
224
|
+
if (f.shared) {
|
|
225
|
+
if (!into.shared)
|
|
226
|
+
into.shared = { patch: {}, term: f.shared.term };
|
|
227
|
+
joinPatch(into.shared.patch, f.shared.patch);
|
|
228
|
+
into.shared.term = f.shared.term;
|
|
229
|
+
into.sc = f.sc;
|
|
230
|
+
}
|
|
231
|
+
if (f.fx.length)
|
|
232
|
+
into.fx.push(...f.fx);
|
|
233
|
+
}
|
|
234
|
+
/** Messages that show a person is there (resetting a room's idle time): moving, actions, effects, start, end, kick, rename, stay. */
|
|
235
|
+
const ACTIVE = new Set(["me", "act", "fx", "start", "end", "kick", "name", "stay"]);
|
|
236
|
+
/** Update rates a reader can be on: every state frame, every second one, every fourth one. */
|
|
237
|
+
const RATES = [1, 2, 4];
|
|
238
|
+
/** One socket's life in the hub: pending (not yet identified) -> identified -> joined a room -> closed. */
|
|
239
|
+
export class Session {
|
|
240
|
+
who = null;
|
|
241
|
+
scope = "";
|
|
242
|
+
game = "";
|
|
243
|
+
slot = "";
|
|
244
|
+
room = null;
|
|
245
|
+
seat = null;
|
|
246
|
+
dead = false;
|
|
247
|
+
lagging = false;
|
|
248
|
+
opened;
|
|
249
|
+
state;
|
|
250
|
+
acts;
|
|
251
|
+
/** clock pings: their own small bucket, and a count before sign-in */
|
|
252
|
+
clock;
|
|
253
|
+
early = 0;
|
|
254
|
+
strikes = 0;
|
|
255
|
+
/** when this socket was last told too_fast */
|
|
256
|
+
slowAt = 0;
|
|
257
|
+
/** when the last message came in (a visible page sends a clock ping every 5 s) */
|
|
258
|
+
lastRx;
|
|
259
|
+
/** shared writes: a bucket of their own (room.js sends at most 12.5 a second plus split parts) */
|
|
260
|
+
sharedB;
|
|
261
|
+
/** Update rate: this reader gets every div-th state frame (1, 2 or 4; effects and changes in between are merged into it, nothing is lost).
|
|
262
|
+
* netDiv is the hub's own reading of the socket (its send queue), askDiv what the page asked for (rate {div}: a slow link or device seen by
|
|
263
|
+
* the page); the slower of the two is used, taken over only when every rate's frame has just gone out (so nobody misses a change). */
|
|
264
|
+
div = 1;
|
|
265
|
+
netDiv = 1;
|
|
266
|
+
askDiv = 1;
|
|
267
|
+
/** when netDiv last went down, and since when the send queue has been near empty */
|
|
268
|
+
downAt = 0;
|
|
269
|
+
clearAt = 0;
|
|
270
|
+
/** the page's round trip to the hub (ms, its own best recent ping, sent along with its clock pings); 0 = not known */
|
|
271
|
+
rtt = 0;
|
|
272
|
+
/** room-list requests */
|
|
273
|
+
listB;
|
|
274
|
+
/** what the page understands (join f: ["fxb", ...]): "fxb" = effects inside the delta frame. Pages without it get one fx message per effect. */
|
|
275
|
+
feats = new Set();
|
|
276
|
+
hub;
|
|
277
|
+
conn;
|
|
278
|
+
addr;
|
|
279
|
+
constructor(hub, conn, addr) {
|
|
280
|
+
this.hub = hub;
|
|
281
|
+
this.conn = conn;
|
|
282
|
+
this.addr = addr;
|
|
283
|
+
this.opened = hub.now();
|
|
284
|
+
this.lastRx = this.opened;
|
|
285
|
+
this.state = new Bucket(hub.limits.stateRate, this.opened);
|
|
286
|
+
this.acts = new Bucket(hub.limits.actionRate, this.opened);
|
|
287
|
+
this.clock = new Bucket(hub.limits.timeRate, this.opened);
|
|
288
|
+
this.sharedB = new Bucket(hub.limits.sharedRate, this.opened);
|
|
289
|
+
this.listB = new Bucket(hub.limits.listRate, this.opened);
|
|
290
|
+
this.clearAt = this.opened;
|
|
291
|
+
}
|
|
292
|
+
/** The server decided who this is (ticket or guest pass) and which namespace and game version they may join. */
|
|
293
|
+
identify(scope, game, who, slot = "") {
|
|
294
|
+
if (this.dead || this.who)
|
|
295
|
+
return;
|
|
296
|
+
this.who = who;
|
|
297
|
+
this.scope = scope;
|
|
298
|
+
this.game = game;
|
|
299
|
+
this.slot = slot;
|
|
300
|
+
this.hub.pending--;
|
|
301
|
+
}
|
|
302
|
+
receive(text) { this.hub.receive(this, text); }
|
|
303
|
+
/** The socket closed (any reason). */
|
|
304
|
+
closed() { this.hub.drop(this); }
|
|
305
|
+
send(m) {
|
|
306
|
+
if (this.dead)
|
|
307
|
+
return;
|
|
308
|
+
const text = JSON.stringify({ v: PROTOCOL, ...m });
|
|
309
|
+
this.hub.sent(Buffer.byteLength(text), 1, this.room);
|
|
310
|
+
try {
|
|
311
|
+
this.conn.send(text);
|
|
312
|
+
}
|
|
313
|
+
catch { /* the close handler cleans up */ }
|
|
314
|
+
}
|
|
315
|
+
end(code, reason) {
|
|
316
|
+
if (this.dead)
|
|
317
|
+
return;
|
|
318
|
+
this.hub.drop(this);
|
|
319
|
+
try {
|
|
320
|
+
this.conn.close(code, reason);
|
|
321
|
+
}
|
|
322
|
+
catch { /* already gone */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** A restart's hand-over of live rooms (exportRooms / importRooms). The format number changes when the shape does; another format is ignored. */
|
|
326
|
+
export const SNAPSHOT_FORMAT = 1;
|
|
327
|
+
export class RoomHub {
|
|
328
|
+
limits;
|
|
329
|
+
now;
|
|
330
|
+
boot = crypto.randomBytes(6).toString("base64url");
|
|
331
|
+
secret;
|
|
332
|
+
rooms = new Map(); // by scope|code
|
|
333
|
+
sessions = new Set();
|
|
334
|
+
pending = 0;
|
|
335
|
+
perAddr = new Map();
|
|
336
|
+
joins = new Map();
|
|
337
|
+
refused = {};
|
|
338
|
+
timer = null;
|
|
339
|
+
sub = 0;
|
|
340
|
+
acceptAt = 0;
|
|
341
|
+
accepted = 0;
|
|
342
|
+
houseAt = 0;
|
|
343
|
+
/** games whose room failed once (logged once each) */
|
|
344
|
+
erred = new Set();
|
|
345
|
+
closing = false;
|
|
346
|
+
saves;
|
|
347
|
+
writing = new Set();
|
|
348
|
+
/** the newest write of each save ("scope|slot|who"): a load waits for that one only, not for other rooms' saves */
|
|
349
|
+
writingKey = new Map();
|
|
350
|
+
loading = new Set();
|
|
351
|
+
/** plain counters for stats() (saves_ok, ...) */
|
|
352
|
+
counts = {};
|
|
353
|
+
/** meters: totals, the totals at the last one-second mark, the per-second rates, tick times, world writes in the last minute */
|
|
354
|
+
meter = { framesOut: 0, bytesOut: 0, msgsIn: 0 };
|
|
355
|
+
mark = { at: 0, framesOut: 0, bytesOut: 0, msgsIn: 0 };
|
|
356
|
+
rates = { framesOut: 0, bytesOut: 0, msgsIn: 0 };
|
|
357
|
+
tickTimes = new Float64Array(1200);
|
|
358
|
+
tickCount = 0;
|
|
359
|
+
worldWrites = [];
|
|
360
|
+
/** event-loop delay from the host process (main.ts sets it once a minute) */
|
|
361
|
+
loop = null;
|
|
362
|
+
/** the same over the last 10 s (main.ts), for the half-rate switch */
|
|
363
|
+
loopRecent = null;
|
|
364
|
+
/** over the data cap or a late loop: bigger rooms at half rate, no new rooms */
|
|
365
|
+
pressure = false;
|
|
366
|
+
/** world save leases: "scope|slot|who" -> the room key that may write it */
|
|
367
|
+
leases = new Map();
|
|
368
|
+
/** shutdown() hands the live rooms to this (a restart writes them to disk; importRooms() in the next process takes them back) */
|
|
369
|
+
onSnapshot;
|
|
370
|
+
/** the room list (rooms {}) is answered; off: the answer is {off: true} */
|
|
371
|
+
listRooms;
|
|
372
|
+
/** room lists made in the last 250 ms, per namespace, game and save slot (many pages asking at once cost one build) */
|
|
373
|
+
lists = new Map();
|
|
374
|
+
/** rooms allowed in one namespace when it differs from limits.maxRoomsPerScope (a host app's per-customer cap, tickcli-js/remote) */
|
|
375
|
+
roomsPerScope;
|
|
376
|
+
constructor(opts) {
|
|
377
|
+
this.roomsPerScope = opts.roomsPerScope ?? null;
|
|
378
|
+
this.saves = opts.saves ?? null;
|
|
379
|
+
this.listRooms = opts.listRooms !== false;
|
|
380
|
+
this.onSnapshot = opts.onSnapshot ?? null;
|
|
381
|
+
this.secret = Buffer.from(opts.secret);
|
|
382
|
+
this.now = opts.now ?? Date.now;
|
|
383
|
+
this.limits = { ...DEFAULT_LIMITS, ...(opts.limits ?? {}) };
|
|
384
|
+
}
|
|
385
|
+
/** A short public id for a seat in one namespace; the seat key itself (a user id or guest pass id) is never shown to others. */
|
|
386
|
+
publicId(scope, seat) {
|
|
387
|
+
return crypto.createHmac("sha256", this.secret).update(`${scope}|${seat}`).digest("base64url").slice(0, 10);
|
|
388
|
+
}
|
|
389
|
+
/** The save slot a socket plays under; without a store there are no slots. */
|
|
390
|
+
slotOf(s) { return this.saves ? s.slot : ""; }
|
|
391
|
+
refuse(why) { this.refused[why] = (this.refused[why] || 0) + 1; }
|
|
392
|
+
count(what, n = 1) { this.counts[what] = (this.counts[what] || 0) + n; }
|
|
393
|
+
/** A new socket. Null = refused (the caller closes it with the code given). */
|
|
394
|
+
connect(conn, addr) {
|
|
395
|
+
const L = this.limits;
|
|
396
|
+
if (this.closing)
|
|
397
|
+
return { refused: CLOSE.restart, reason: "server restarting" };
|
|
398
|
+
if (this.sessions.size >= L.maxConnections) {
|
|
399
|
+
this.refuse("connections");
|
|
400
|
+
return { refused: CLOSE.full, reason: "server is full" };
|
|
401
|
+
}
|
|
402
|
+
// busy (not full): the page tries again with its normal backoff, so a reconnect wave after a restart spreads out instead of ending on a "full" screen
|
|
403
|
+
if (this.pending >= L.maxPending) {
|
|
404
|
+
this.refuse("pending");
|
|
405
|
+
return { refused: CLOSE.busy, reason: "server is busy, try again" };
|
|
406
|
+
}
|
|
407
|
+
const now = this.now();
|
|
408
|
+
if (now - this.acceptAt >= 1000) {
|
|
409
|
+
this.acceptAt = now;
|
|
410
|
+
this.accepted = 0;
|
|
411
|
+
}
|
|
412
|
+
if (++this.accepted > L.acceptRate) {
|
|
413
|
+
this.refuse("accept_rate");
|
|
414
|
+
return { refused: CLOSE.busy, reason: "server is busy, try again" };
|
|
415
|
+
}
|
|
416
|
+
if ((this.perAddr.get(addr) || 0) >= L.maxPerAddress) {
|
|
417
|
+
this.refuse("address");
|
|
418
|
+
return { refused: CLOSE.tooFast, reason: "too many connections" };
|
|
419
|
+
}
|
|
420
|
+
const s = new Session(this, conn, addr);
|
|
421
|
+
this.sessions.add(s);
|
|
422
|
+
this.pending++;
|
|
423
|
+
this.perAddr.set(addr, (this.perAddr.get(addr) || 0) + 1);
|
|
424
|
+
return s;
|
|
425
|
+
}
|
|
426
|
+
/** Meter: `n` copies of a text of `len` bytes (UTF-8, as on the wire) went out (to one room's sockets when given). */
|
|
427
|
+
sent(len, n, room) {
|
|
428
|
+
this.meter.framesOut += n;
|
|
429
|
+
this.meter.bytesOut += len * n;
|
|
430
|
+
if (room)
|
|
431
|
+
room.outBytes += len * n;
|
|
432
|
+
}
|
|
433
|
+
receive(s, text) {
|
|
434
|
+
if (s.dead)
|
|
435
|
+
return;
|
|
436
|
+
this.meter.msgsIn++;
|
|
437
|
+
const now = this.now();
|
|
438
|
+
s.lastRx = now;
|
|
439
|
+
if (typeof text !== "string")
|
|
440
|
+
return this.bad(s, "not text");
|
|
441
|
+
if (Buffer.byteLength(text) > this.limits.messageBytes) {
|
|
442
|
+
// a size mistake by a game is answered, not punished with a closed connection; a socket that never signed in is closed
|
|
443
|
+
if (!s.who)
|
|
444
|
+
return this.bad(s, "message too large");
|
|
445
|
+
this.refuse("too_big");
|
|
446
|
+
this.err(s, "too_big");
|
|
447
|
+
return this.strike(s);
|
|
448
|
+
}
|
|
449
|
+
let m;
|
|
450
|
+
try {
|
|
451
|
+
m = JSON.parse(text);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return this.bad(s, "not JSON");
|
|
455
|
+
}
|
|
456
|
+
if (!m || typeof m !== "object" || Array.isArray(m) || m.v !== PROTOCOL || typeof m.t !== "string")
|
|
457
|
+
return this.bad(s, "wrong protocol version");
|
|
458
|
+
const t = m.t;
|
|
459
|
+
if (t === "time") {
|
|
460
|
+
// clock pings have their own small bucket (the relay sends one every 5 s), and only 2 before the socket proves who it is
|
|
461
|
+
if (!s.who ? ++s.early > 2 : !s.clock.take(now))
|
|
462
|
+
return this.strike(s);
|
|
463
|
+
if (typeof m.r === "number" && Number.isFinite(m.r))
|
|
464
|
+
s.rtt = Math.max(1, Math.min(10_000, Math.round(m.r))); // the page's own best round trip (display only)
|
|
465
|
+
return s.send({ t: "time", c: typeof m.c === "number" && Number.isFinite(m.c) ? m.c : 0, s: now });
|
|
466
|
+
}
|
|
467
|
+
if (!s.who)
|
|
468
|
+
return this.bad(s, "sign in first");
|
|
469
|
+
if (t === "join")
|
|
470
|
+
return this.join(s, m);
|
|
471
|
+
if (t === "rooms") {
|
|
472
|
+
if (!s.listB.take(now))
|
|
473
|
+
return this.strike(s);
|
|
474
|
+
return s.send(this.roomList(s, m));
|
|
475
|
+
}
|
|
476
|
+
const room = s.room, seat = s.seat;
|
|
477
|
+
if (!room || !seat)
|
|
478
|
+
return this.bad(s, "join first");
|
|
479
|
+
if (ACTIVE.has(t) || (t === "vis" && m.hidden !== true))
|
|
480
|
+
this.active(room);
|
|
481
|
+
if (t === "act")
|
|
482
|
+
return this.act(s, room, seat, m);
|
|
483
|
+
if (t === "result") {
|
|
484
|
+
if (!s.state.take(now))
|
|
485
|
+
return this.strike(s);
|
|
486
|
+
return this.result(room, seat, m);
|
|
487
|
+
}
|
|
488
|
+
if (!s.state.take(now) || (t === "shared" && !s.sharedB.take(now))) {
|
|
489
|
+
// a dropped state write leaves the sender's copy different from everyone else's: say so (once a second at most), the page asks for a resync
|
|
490
|
+
if ((t === "me" || t === "shared") && now - s.slowAt >= 1000) {
|
|
491
|
+
s.slowAt = now;
|
|
492
|
+
this.err(s, "too_fast");
|
|
493
|
+
}
|
|
494
|
+
return this.strike(s);
|
|
495
|
+
}
|
|
496
|
+
s.strikes = Math.max(0, s.strikes - 1);
|
|
497
|
+
switch (t) {
|
|
498
|
+
case "me": return this.me(s, room, seat, m);
|
|
499
|
+
case "shared": return this.sharedWrite(s, room, seat, m);
|
|
500
|
+
case "fx": return this.fx(room, seat, m, s);
|
|
501
|
+
case "alive": return this.alive(room, seat, m);
|
|
502
|
+
case "vis": return this.vis(room, seat, m);
|
|
503
|
+
case "name": return this.rename(s, room, seat, m);
|
|
504
|
+
case "profile": return void this.sendProfile(s, room, seat);
|
|
505
|
+
case "sync": return s.send(this.snapshot(room, seat, "sync")); // a page whose write was refused asks for the server's copy
|
|
506
|
+
case "save": return this.saveProfile(s, room, seat, m);
|
|
507
|
+
case "world": return this.worldMsg(s, room, seat, m);
|
|
508
|
+
case "start": return this.startMatch(s, room, seat, m.resume === true);
|
|
509
|
+
case "end": return this.endMatch(s, room, seat);
|
|
510
|
+
case "kick": return this.kick(s, room, seat, m);
|
|
511
|
+
case "stay": return; // the person pressed "keep this room" (counted as activity above)
|
|
512
|
+
case "rate": {
|
|
513
|
+
const d = Number(m.div);
|
|
514
|
+
s.askDiv = RATES.includes(d) ? d : 1;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
case "leave":
|
|
518
|
+
this.removeSeat(room, seat);
|
|
519
|
+
return s.end(1000, "left"); // a person who chose to go does not linger as "reconnecting"
|
|
520
|
+
default: return this.bad(s, "unknown message");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
bad(s, why) { this.refuse("bad_message"); s.end(CLOSE.badMessage, why); }
|
|
524
|
+
/** Over the rate: the message is dropped; a sender that keeps pushing far past it is disconnected. */
|
|
525
|
+
strike(s) {
|
|
526
|
+
this.refuse("rate");
|
|
527
|
+
if (++s.strikes > 60) {
|
|
528
|
+
this.refuse("too_fast");
|
|
529
|
+
s.end(CLOSE.tooFast, "too many messages");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
err(s, code, ref) { s.send({ t: "error", code, ...(ref === undefined ? {} : { ref }) }); }
|
|
533
|
+
// ---- joining ----------------------------------------------------------------------------------------------------------------------------
|
|
534
|
+
join(s, m) {
|
|
535
|
+
const L = this.limits, now = this.now();
|
|
536
|
+
if (s.room)
|
|
537
|
+
return this.bad(s, "already in a room");
|
|
538
|
+
const spec = parseSpec(m.spec, L.maxPlayers);
|
|
539
|
+
if (!spec)
|
|
540
|
+
return this.bad(s, "bad multiplayer option");
|
|
541
|
+
const hits = (this.joins.get(s.addr) || []).filter((x) => now - x < 60_000);
|
|
542
|
+
if (hits.length >= L.joinsPerMinute) {
|
|
543
|
+
this.refuse("joins");
|
|
544
|
+
return s.end(CLOSE.tooFast, "too many joins, wait a minute");
|
|
545
|
+
}
|
|
546
|
+
hits.push(now);
|
|
547
|
+
this.joins.set(s.addr, hits);
|
|
548
|
+
if (this.joins.size > 10_000)
|
|
549
|
+
for (const [k, v] of this.joins)
|
|
550
|
+
if (!v.length || now - v[v.length - 1] > 60_000)
|
|
551
|
+
this.joins.delete(k);
|
|
552
|
+
const who = s.who;
|
|
553
|
+
if (who.guest && typeof m.name === "string") {
|
|
554
|
+
const n = cleanName(m.name);
|
|
555
|
+
if (n)
|
|
556
|
+
who.name = n;
|
|
557
|
+
}
|
|
558
|
+
const specKey = stable(spec);
|
|
559
|
+
let room;
|
|
560
|
+
const want = typeof m.code === "string" ? m.code.toUpperCase() : "";
|
|
561
|
+
if (want) {
|
|
562
|
+
if (!CODE_RE.test(want))
|
|
563
|
+
return this.bad(s, "bad room code");
|
|
564
|
+
room = this.rooms.get(`${s.scope}|${want}`);
|
|
565
|
+
// The game was edited since this room started (a join always carries the page's current version): the code goes to the current version, so
|
|
566
|
+
// everyone who reloads lands together; the old room carries on for whoever is still in it under a hidden code, and they are told.
|
|
567
|
+
if (room && room.game !== s.game && room.slot === this.slotOf(s) && !(room.owner && room.owner !== who.seat)) {
|
|
568
|
+
this.retire(room);
|
|
569
|
+
room = undefined;
|
|
570
|
+
}
|
|
571
|
+
if (room && (room.game !== s.game || room.slot !== this.slotOf(s) || room.specKey !== specKey)) {
|
|
572
|
+
this.refuse("game_updated");
|
|
573
|
+
return s.end(CLOSE.gameUpdated, "this room plays another version of the game, reload");
|
|
574
|
+
}
|
|
575
|
+
if (room?.owner && room.owner !== who.seat) {
|
|
576
|
+
this.refuse("full");
|
|
577
|
+
return s.end(CLOSE.full, "this room is full");
|
|
578
|
+
} // someone's game alone
|
|
579
|
+
if (!room)
|
|
580
|
+
room = this.create(s, spec, specKey, want, true, m.alone === true ? who.seat : "") ?? undefined;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
// asking for a private room (Play alone, Private room) never matches a public one, even with a free seat
|
|
584
|
+
if (m.private !== true && m.alone !== true)
|
|
585
|
+
for (const r of this.rooms.values()) {
|
|
586
|
+
if (r.scope === s.scope && r.game === s.game && r.slot === this.slotOf(s) && r.specKey === specKey && !r.priv && !r.banned.has(who.seat) && (r.seats.has(who.seat) || r.seats.size < r.spec.max)) {
|
|
587
|
+
room = r;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!room)
|
|
592
|
+
room = this.create(s, spec, specKey, "", m.private === true || m.alone === true, m.alone === true ? who.seat : "") ?? undefined;
|
|
593
|
+
}
|
|
594
|
+
if (!room)
|
|
595
|
+
return; // refused in create()
|
|
596
|
+
if (room.banned.has(who.seat)) {
|
|
597
|
+
this.refuse("removed");
|
|
598
|
+
return s.end(CLOSE.removed, "the host removed you from this room");
|
|
599
|
+
}
|
|
600
|
+
let seat = room.seats.get(who.seat);
|
|
601
|
+
if (seat) {
|
|
602
|
+
// The same person again (refresh, second tab, dropped connection): take the seat over in one step; the old connection is closed and its
|
|
603
|
+
// late messages are refused (it is dead before it closes).
|
|
604
|
+
const old = seat.session;
|
|
605
|
+
if (old && old !== s) {
|
|
606
|
+
this.refuse("replaced");
|
|
607
|
+
old.seat = null;
|
|
608
|
+
old.room = null;
|
|
609
|
+
old.end(CLOSE.replaced, "opened in another tab");
|
|
610
|
+
}
|
|
611
|
+
seat.name = who.name;
|
|
612
|
+
// A new page counts its actions from 1 again: start the seat's count over, and forget actions the old page sent (their answers would reach
|
|
613
|
+
// the new page under ids it reuses).
|
|
614
|
+
seat.lastAct = 0;
|
|
615
|
+
for (const [aid, a] of room.actions)
|
|
616
|
+
if (a.from === seat)
|
|
617
|
+
room.actions.delete(aid);
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
if (room.seats.size >= room.spec.max) {
|
|
621
|
+
// a seat held for someone who dropped out gives way to a person who is here, once they have been gone evictAfterMs (a blink keeps the
|
|
622
|
+
// seat): a page that was put away first, then the longest gone; the room keeps its other players
|
|
623
|
+
let gone = null;
|
|
624
|
+
const better = (x, y) => x.hidden !== y.hidden ? x.hidden : x.offlineAt < y.offlineAt;
|
|
625
|
+
for (const x of room.seats.values())
|
|
626
|
+
if (!x.session && x.offlineAt && now - x.offlineAt >= L.evictAfterMs && (!gone || better(x, gone)))
|
|
627
|
+
gone = x;
|
|
628
|
+
if (gone && room.seats.size > 1)
|
|
629
|
+
this.removeSeat(room, gone);
|
|
630
|
+
if (room.seats.size >= room.spec.max) {
|
|
631
|
+
this.refuse("full");
|
|
632
|
+
return s.end(CLOSE.full, "this room is full");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
seat = { key: who.seat, id: this.publicId(s.scope, who.seat), name: who.name, guest: who.guest, state: {}, session: null, hidden: false, stalled: false, since: now, offlineAt: 0, lastAct: 0, prof: null, profLoad: null, profDirty: false, profAt: 0, stateBytes: 2, kept: false, profV: 1 };
|
|
636
|
+
room.seats.set(seat.key, seat);
|
|
637
|
+
room.byId.set(seat.id, seat);
|
|
638
|
+
}
|
|
639
|
+
if (Array.isArray(m.f))
|
|
640
|
+
for (const f of m.f.slice(0, 16))
|
|
641
|
+
if (typeof f === "string" && f.length <= 8)
|
|
642
|
+
s.feats.add(f);
|
|
643
|
+
// what was waiting goes out first, so a newcomer gets no effect from before they joined and nobody gets a change after the "joined"
|
|
644
|
+
this.flush(room);
|
|
645
|
+
seat.session = s;
|
|
646
|
+
seat.offlineAt = 0;
|
|
647
|
+
seat.hidden = m.hidden === true;
|
|
648
|
+
seat.stalled = false;
|
|
649
|
+
s.room = room;
|
|
650
|
+
s.seat = seat;
|
|
651
|
+
const kept = seat.kept;
|
|
652
|
+
seat.kept = false;
|
|
653
|
+
// Others hear "joined" (and a host change, if any); the newcomer gets one welcome with the full current state, host and term included.
|
|
654
|
+
this.broadcast(room, { t: "joined", player: view(seat) }, s);
|
|
655
|
+
this.elect(room, s);
|
|
656
|
+
s.send({ ...this.snapshot(room, seat, "welcome"), ...(kept ? { kept: true } : {}) });
|
|
657
|
+
this.active(room);
|
|
658
|
+
}
|
|
659
|
+
/** A person did something in the room: its idle time starts over (a warning already sent is taken back). */
|
|
660
|
+
active(room) {
|
|
661
|
+
room.activeAt = this.now();
|
|
662
|
+
if (room.idleWarned) {
|
|
663
|
+
room.idleWarned = false;
|
|
664
|
+
this.broadcast(room, { t: "idle", closesAt: 0 });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/** Nobody did anything for idleMs: saves are written, every page hears 4010 (game.js offers a new room), the room is gone. */
|
|
668
|
+
closeIdle(room) {
|
|
669
|
+
this.count("rooms_idle");
|
|
670
|
+
this.flush(room);
|
|
671
|
+
this.saveWorld(room, true);
|
|
672
|
+
for (const seat of room.seats.values()) {
|
|
673
|
+
this.flushProfile(room, seat, true);
|
|
674
|
+
seat.session?.end(CLOSE.idle, "closed: nobody played for a while");
|
|
675
|
+
}
|
|
676
|
+
this.closeRoom(room);
|
|
677
|
+
}
|
|
678
|
+
/** Moves a room running an older version of its game off its code (see join): a new hidden code, private, its save lease kept; its pages hear
|
|
679
|
+
* `newer` (game.js: press restart to play the new version). */
|
|
680
|
+
retire(room) {
|
|
681
|
+
const old = room.key;
|
|
682
|
+
let code = "";
|
|
683
|
+
for (let i = 0; i < 50 && (!code || this.rooms.has(`${room.scope}|${code}`)); i++)
|
|
684
|
+
code = Array.from(crypto.randomBytes(4), (b) => CODE_ABC[b % CODE_ABC.length]).join("");
|
|
685
|
+
this.rooms.delete(old);
|
|
686
|
+
room.code = code;
|
|
687
|
+
room.key = `${room.scope}|${code}`;
|
|
688
|
+
room.priv = true;
|
|
689
|
+
this.rooms.set(room.key, room);
|
|
690
|
+
if (room.lease && this.leases.get(room.lease) === old)
|
|
691
|
+
this.leases.set(room.lease, room.key);
|
|
692
|
+
this.count("rooms_retired");
|
|
693
|
+
for (const x of room.seats.values())
|
|
694
|
+
x.session?.send({ t: "newer" });
|
|
695
|
+
}
|
|
696
|
+
create(s, spec, specKey, code, priv, owner = "") {
|
|
697
|
+
const L = this.limits;
|
|
698
|
+
if (this.rooms.size >= L.maxRooms) {
|
|
699
|
+
this.refuse("rooms");
|
|
700
|
+
s.end(CLOSE.full, "the server has no free rooms, try again soon");
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
if (this.pressure) {
|
|
704
|
+
this.refuse("busy_rooms");
|
|
705
|
+
s.end(CLOSE.busy, "the server is busy, try again soon");
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
let inScope = 0;
|
|
709
|
+
for (const r of this.rooms.values())
|
|
710
|
+
if (r.scope === s.scope)
|
|
711
|
+
inScope++;
|
|
712
|
+
if (inScope >= (this.roomsPerScope?.(s.scope) ?? L.maxRoomsPerScope)) {
|
|
713
|
+
this.refuse("scope_rooms");
|
|
714
|
+
s.end(CLOSE.full, "too many rooms are open for this game, try again soon");
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
if (!code) {
|
|
718
|
+
for (let i = 0; i < 50 && (!code || this.rooms.has(`${s.scope}|${code}`)); i++)
|
|
719
|
+
code = Array.from(crypto.randomBytes(4), (b) => CODE_ABC[b % CODE_ABC.length]).join("");
|
|
720
|
+
}
|
|
721
|
+
const room = {
|
|
722
|
+
key: `${s.scope}|${code}`, scope: s.scope, game: s.game, spec, specKey, code, priv, slot: this.slotOf(s), owner, world: !!this.saves && !!s.slot && spec.save,
|
|
723
|
+
lease: "", saveBusy: false, worldState: "off", loadTries: 0, loadAt: 0, startWait: null, sid: crypto.randomBytes(6).toString("base64url"), saved: null, wrev: 0, savedWrev: 0, savedAt: 0, noSave: false, resumed: false, seats: new Map(), byId: new Map(), banned: new Set(),
|
|
724
|
+
match: 1, rev: 0, term: 0, host: null, hostAliveAt: 0, started: false, played: false, shared: {}, pendPlayers: new Map(), pendShared: null, pendPT: new Map(), pendSC: 0, pendAt: 0,
|
|
725
|
+
actions: new Map(), nextAction: 1, tickN: 0, outBytes: 0, outMark: 0, outRate: 0, phase: crypto.randomInt(10), outAvg: 0, slowF: 1, slowTold: false, pendFx: [], pendFxBytes: 0, sharedBytes: 2, sizeChecks: 0, created: this.now(), flushN: 0, tiers: null, activeAt: this.now(), idleWarned: false,
|
|
726
|
+
};
|
|
727
|
+
this.rooms.set(room.key, room);
|
|
728
|
+
if (room.world) {
|
|
729
|
+
// One room at a time plays a given save: a second room of the same game (another group, a second tab alone) plays unsaved, so the two never
|
|
730
|
+
// write over each other. The lease goes when the room closes; the next new room then continues the save.
|
|
731
|
+
const lk = `${room.scope}|${room.slot}|${worldOf(room) ?? ""}`;
|
|
732
|
+
// a room whose players are all gone (seats only held for a comeback, e.g. someone who left their game alone and came straight back) hands
|
|
733
|
+
// the save on: it is written first, then that room closes
|
|
734
|
+
const holder = this.rooms.get(this.leases.get(lk) ?? "");
|
|
735
|
+
if (holder && ![...holder.seats.values()].some((x) => x.session)) {
|
|
736
|
+
this.saveWorld(holder, true);
|
|
737
|
+
for (const x of holder.seats.values())
|
|
738
|
+
this.flushProfile(holder, x, true);
|
|
739
|
+
this.closeRoom(holder);
|
|
740
|
+
}
|
|
741
|
+
else if (!holder)
|
|
742
|
+
this.leases.delete(lk);
|
|
743
|
+
if (this.leases.has(lk)) {
|
|
744
|
+
room.world = false;
|
|
745
|
+
room.saveBusy = true;
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
this.leases.set(lk, room.key);
|
|
749
|
+
room.lease = lk;
|
|
750
|
+
room.worldState = "loading";
|
|
751
|
+
this.loadWorld(room);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return room;
|
|
755
|
+
}
|
|
756
|
+
snapshot(room, seat, t) {
|
|
757
|
+
return {
|
|
758
|
+
t, you: seat.id, boot: this.boot, ver: room.game.slice(0, 16), code: room.code, private: room.priv, max: room.spec.max, match: room.match, rev: room.rev, term: room.term,
|
|
759
|
+
host: room.host?.id ?? null, started: room.started, players: [...room.seats.values()].map(view), shared: room.shared, now: this.now(),
|
|
760
|
+
...(seat.kept ? { kept: true } : {}), tick: room.seats.size > 8 ? this.limits.tickMs * 2 : this.limits.tickMs,
|
|
761
|
+
saved: room.saved ? { at: room.saved.at, v: room.saved.v } : null, resumed: room.resumed, ...(room.saveBusy ? { saveBusy: true } : {}), ...(room.worldState === "failed" ? { saveError: true } : {}),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
// ---- host -----------------------------------------------------------------------------------------------------------------------------
|
|
765
|
+
/** Host = the longest-connected player who is online and visible. None = the match waits. Every change starts a new term. */
|
|
766
|
+
elect(room, quiet) {
|
|
767
|
+
const ok = (x) => !!x && !!x.session && !x.hidden && !x.stalled;
|
|
768
|
+
if (ok(room.host))
|
|
769
|
+
return;
|
|
770
|
+
let best = null;
|
|
771
|
+
for (const x of room.seats.values())
|
|
772
|
+
if (ok(x) && (!best || x.since < best.since))
|
|
773
|
+
best = x;
|
|
774
|
+
if (best === room.host)
|
|
775
|
+
return;
|
|
776
|
+
this.flush(room, quiet); // changes written under the old term go out before the new term is announced
|
|
777
|
+
room.host = best;
|
|
778
|
+
room.term++;
|
|
779
|
+
room.hostAliveAt = this.now();
|
|
780
|
+
// Actions the old host never answered fail now, so no player waits on a host that is gone.
|
|
781
|
+
for (const [aid, a] of room.actions) {
|
|
782
|
+
a.from.session?.send({ t: "result", id: a.ref, ok: false, error: "host changed" });
|
|
783
|
+
room.actions.delete(aid);
|
|
784
|
+
}
|
|
785
|
+
this.broadcast(room, { t: "host", term: room.term, player: best?.id ?? null }, quiet);
|
|
786
|
+
}
|
|
787
|
+
alive(room, seat, m) {
|
|
788
|
+
if (room.host === seat && m.term === room.term)
|
|
789
|
+
room.hostAliveAt = this.now();
|
|
790
|
+
else if (seat.stalled) {
|
|
791
|
+
seat.stalled = false; /* its game loop runs again */
|
|
792
|
+
this.broadcast(room, { t: "online", player: view(seat) });
|
|
793
|
+
this.elect(room);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
vis(room, seat, m) {
|
|
797
|
+
const hidden = m.hidden === true;
|
|
798
|
+
if (seat.hidden === hidden)
|
|
799
|
+
return;
|
|
800
|
+
seat.hidden = hidden;
|
|
801
|
+
// a hidden page gets no state frames (a phone in the background on mobile data): one fresh copy when it is back, before the next frame
|
|
802
|
+
if (!hidden && seat.session && !seat.session.lagging)
|
|
803
|
+
seat.session.send(this.snapshot(room, seat, "sync"));
|
|
804
|
+
this.broadcast(room, { t: "online", player: view(seat) });
|
|
805
|
+
this.elect(room);
|
|
806
|
+
}
|
|
807
|
+
// ---- state ----------------------------------------------------------------------------------------------------------------------------
|
|
808
|
+
me(s, room, seat, m) {
|
|
809
|
+
const p = m.s;
|
|
810
|
+
if (!p || typeof p !== "object" || Array.isArray(p))
|
|
811
|
+
return this.err(s, "bad_value");
|
|
812
|
+
const bad = badPath(p);
|
|
813
|
+
if (bad !== null)
|
|
814
|
+
return this.err(s, "bad_value", bad);
|
|
815
|
+
for (const [k, v] of Object.entries(p)) {
|
|
816
|
+
const range = room.spec.player[k];
|
|
817
|
+
if (range && v !== null && (typeof v !== "number" || v < range[0] || v > range[1]))
|
|
818
|
+
return this.err(s, "out_of_range", k);
|
|
819
|
+
}
|
|
820
|
+
const ch = changedOnly(seat.state, p);
|
|
821
|
+
if (!ch)
|
|
822
|
+
return; // nothing changed: nothing applied, counted or sent
|
|
823
|
+
const size = this.sized(seat.stateBytes + sizeDelta(seat.state, ch), this.limits.playerStateBytes, () => applyPatch(seat.state, ch), room);
|
|
824
|
+
if (size > this.limits.playerStateBytes)
|
|
825
|
+
return this.err(s, "too_big");
|
|
826
|
+
seat.state = applyPatch(seat.state, ch);
|
|
827
|
+
seat.stateBytes = size;
|
|
828
|
+
room.rev++;
|
|
829
|
+
const pend = room.pendPlayers.get(seat.id) ?? {};
|
|
830
|
+
joinPatch(pend, ch);
|
|
831
|
+
room.pendPlayers.set(seat.id, pend);
|
|
832
|
+
room.pendPT.set(seat.id, this.stamp(m.c));
|
|
833
|
+
}
|
|
834
|
+
sharedWrite(s, room, seat, m) {
|
|
835
|
+
if (room.host !== seat)
|
|
836
|
+
return this.err(s, "not_host");
|
|
837
|
+
if (m.term !== room.term)
|
|
838
|
+
return this.err(s, "old_term");
|
|
839
|
+
const p = m.p;
|
|
840
|
+
if (!p || typeof p !== "object" || Array.isArray(p))
|
|
841
|
+
return this.err(s, "bad_value");
|
|
842
|
+
const bad = badPath(p);
|
|
843
|
+
if (bad !== null)
|
|
844
|
+
return this.err(s, "bad_value", bad);
|
|
845
|
+
room.hostAliveAt = this.now();
|
|
846
|
+
const ch = changedOnly(room.shared, p);
|
|
847
|
+
if (!ch)
|
|
848
|
+
return; // a write that changes nothing: no new revision, no world save, nothing sent
|
|
849
|
+
const size = this.sized(room.sharedBytes + sizeDelta(room.shared, ch), this.limits.sharedStateBytes, () => applyPatch(room.shared, ch), room);
|
|
850
|
+
if (size > this.limits.sharedStateBytes)
|
|
851
|
+
return this.err(s, "too_big");
|
|
852
|
+
room.shared = applyPatch(room.shared, ch);
|
|
853
|
+
room.sharedBytes = size;
|
|
854
|
+
room.rev++;
|
|
855
|
+
room.wrev++;
|
|
856
|
+
if (!room.pendShared)
|
|
857
|
+
room.pendShared = { patch: {}, term: room.term };
|
|
858
|
+
joinPatch(room.pendShared.patch, ch);
|
|
859
|
+
room.pendSC = this.stamp(m.c);
|
|
860
|
+
}
|
|
861
|
+
/** The sender's sample time of a write (its shared clock, whole ms), trusted within 500 ms back and 50 ms ahead; else now. Pages draw others
|
|
862
|
+
* at these times, so updates sent 80 ms apart are drawn 80 ms apart whatever the fan-out tick did to them. */
|
|
863
|
+
stamp(c) {
|
|
864
|
+
const now = this.now();
|
|
865
|
+
return typeof c === "number" && Number.isFinite(c) && c >= now - 500 && c <= now + 50 ? Math.round(c) : now;
|
|
866
|
+
}
|
|
867
|
+
/** A state size after a write: the running count, or the exact one when it is within 10% of the limit and every 256th time (a drift is counted). */
|
|
868
|
+
sized(running, limit, next, room) {
|
|
869
|
+
if (running < limit * 0.9 && ++room.sizeChecks % 256)
|
|
870
|
+
return running;
|
|
871
|
+
const exact = bytes(next());
|
|
872
|
+
if (exact !== running)
|
|
873
|
+
this.refuse("size_drift");
|
|
874
|
+
return exact;
|
|
875
|
+
}
|
|
876
|
+
fx(room, seat, m, s) {
|
|
877
|
+
if (typeof m.name !== "string" || !NAME_RE.test(m.name) || (m.data !== undefined && !plain(m.data)))
|
|
878
|
+
return this.err(s, "bad_value");
|
|
879
|
+
const len = bytes(m.data ?? null);
|
|
880
|
+
if (len > 1024)
|
|
881
|
+
return this.err(s, "bad_value");
|
|
882
|
+
// effects ride the next frame (one frame a tick instead of one per shot); a full queue goes out at once, nothing is dropped
|
|
883
|
+
room.pendFx.push({ from: seat.id, name: m.name, data: m.data ?? null });
|
|
884
|
+
room.pendFxBytes += len + m.name.length + 32;
|
|
885
|
+
if (room.pendFx.length >= 128 || room.pendFxBytes >= 16 * 1024)
|
|
886
|
+
this.flush(room);
|
|
887
|
+
}
|
|
888
|
+
rename(s, room, seat, m) {
|
|
889
|
+
if (!seat.guest)
|
|
890
|
+
return this.err(s, "not_allowed");
|
|
891
|
+
const n = cleanName(m.name);
|
|
892
|
+
if (!n)
|
|
893
|
+
return this.err(s, "bad_value");
|
|
894
|
+
seat.name = n;
|
|
895
|
+
this.broadcast(room, { t: "online", player: view(seat) });
|
|
896
|
+
}
|
|
897
|
+
// ---- actions: any player asks, the host decides, the asker gets one answer -------------------------------------------------------------
|
|
898
|
+
act(s, room, seat, m) {
|
|
899
|
+
const ref = m.id;
|
|
900
|
+
if (typeof ref !== "number" || !Number.isInteger(ref) || ref < 1)
|
|
901
|
+
return this.bad(s, "bad action id");
|
|
902
|
+
if (ref <= seat.lastAct)
|
|
903
|
+
return; // a duplicate: already answered once
|
|
904
|
+
seat.lastAct = ref;
|
|
905
|
+
if (!s.acts.take(this.now())) {
|
|
906
|
+
this.refuse("actions");
|
|
907
|
+
return s.send({ t: "result", id: ref, ok: false, error: "too_fast" });
|
|
908
|
+
}
|
|
909
|
+
if (typeof m.name !== "string" || !NAME_RE.test(m.name) || (m.data !== undefined && !plain(m.data)) || bytes(m.data ?? null) > 1024)
|
|
910
|
+
return s.send({ t: "result", id: ref, ok: false, error: "bad_value" });
|
|
911
|
+
// The game's own actions wait for the match; the shell's (a leading underscore: switch team, take a seat) are for the lobby too.
|
|
912
|
+
const lobbyOk = m.name.startsWith("_");
|
|
913
|
+
if (!room.host?.session || (!room.started && !lobbyOk))
|
|
914
|
+
return s.send({ t: "result", id: ref, ok: false, error: room.host?.session ? "not_started" : "no_host" });
|
|
915
|
+
const aid = room.nextAction++;
|
|
916
|
+
room.actions.set(aid, { from: seat, ref, at: this.now() });
|
|
917
|
+
room.host.session.send({ t: "action", id: aid, from: seat.id, name: m.name, data: m.data ?? null });
|
|
918
|
+
}
|
|
919
|
+
result(room, seat, m) {
|
|
920
|
+
if (room.host !== seat || typeof m.id !== "number")
|
|
921
|
+
return;
|
|
922
|
+
const a = room.actions.get(m.id);
|
|
923
|
+
if (!a)
|
|
924
|
+
return; // answered or timed out already
|
|
925
|
+
room.actions.delete(m.id);
|
|
926
|
+
const error = typeof m.error === "string" ? m.error.slice(0, 80) : undefined;
|
|
927
|
+
a.from.session?.send({ t: "result", id: a.ref, ok: m.ok === true, ...(m.ok === true || !error ? {} : { error }) });
|
|
928
|
+
}
|
|
929
|
+
// ---- match ----------------------------------------------------------------------------------------------------------------------------
|
|
930
|
+
startMatch(s, room, seat, resume = false) {
|
|
931
|
+
if (room.host !== seat)
|
|
932
|
+
return this.err(s, "not_host");
|
|
933
|
+
if (room.started)
|
|
934
|
+
return;
|
|
935
|
+
// the save is still loading: wait for it (3 s at most), so a host who taps Start early is still asked Continue or New game
|
|
936
|
+
if (room.worldState === "loading") {
|
|
937
|
+
room.startWait = { resume, at: this.now() };
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
this.beginMatch(room, resume);
|
|
941
|
+
}
|
|
942
|
+
beginMatch(room, resume) {
|
|
943
|
+
room.startWait = null;
|
|
944
|
+
room.noSave = false;
|
|
945
|
+
if (resume && room.saved) {
|
|
946
|
+
// Continue: the saved world becomes the shared state (the shell's clock key excepted); the game's setup must not overwrite it
|
|
947
|
+
this.flush(room);
|
|
948
|
+
if (room.played) {
|
|
949
|
+
room.match++;
|
|
950
|
+
room.played = false;
|
|
951
|
+
}
|
|
952
|
+
room.rev++;
|
|
953
|
+
room.wrev++;
|
|
954
|
+
room.resumed = true;
|
|
955
|
+
room.shared = Object.fromEntries(Object.entries(structuredClone(room.saved.data)).filter(([k]) => k !== "_go"));
|
|
956
|
+
room.sharedBytes = bytes(room.shared);
|
|
957
|
+
room.started = true;
|
|
958
|
+
return this.broadcast(room, { t: "started", match: room.match, shared: room.shared, rev: room.rev, resumed: true });
|
|
959
|
+
}
|
|
960
|
+
room.resumed = false;
|
|
961
|
+
this.flush(room); // effects from the lobby go out before "started"
|
|
962
|
+
if (room.played) {
|
|
963
|
+
// a finished match: the next one starts from empty shared state
|
|
964
|
+
this.flush(room);
|
|
965
|
+
room.match++;
|
|
966
|
+
room.rev++;
|
|
967
|
+
room.played = false;
|
|
968
|
+
room.shared = Object.fromEntries(Object.entries(room.shared).filter(([k]) => k.startsWith("_"))); // the shell's keys (teams) carry over
|
|
969
|
+
room.sharedBytes = bytes(room.shared);
|
|
970
|
+
}
|
|
971
|
+
room.started = true;
|
|
972
|
+
this.broadcast(room, { t: "started", match: room.match, shared: room.shared, rev: room.rev });
|
|
973
|
+
}
|
|
974
|
+
endMatch(s, room, seat) {
|
|
975
|
+
if (room.host !== seat)
|
|
976
|
+
return this.err(s, "not_host");
|
|
977
|
+
if (!room.started)
|
|
978
|
+
return;
|
|
979
|
+
this.flush(room);
|
|
980
|
+
room.started = false;
|
|
981
|
+
room.played = true;
|
|
982
|
+
this.saveWorld(room, true);
|
|
983
|
+
for (const x of room.seats.values())
|
|
984
|
+
this.flushProfile(room, x, true);
|
|
985
|
+
for (const [aid, a] of room.actions) {
|
|
986
|
+
a.from.session?.send({ t: "result", id: a.ref, ok: false, error: "match over" });
|
|
987
|
+
room.actions.delete(aid);
|
|
988
|
+
}
|
|
989
|
+
this.broadcast(room, { t: "ended", match: room.match, shared: room.shared });
|
|
990
|
+
}
|
|
991
|
+
kick(s, room, seat, m) {
|
|
992
|
+
if (room.host !== seat)
|
|
993
|
+
return this.err(s, "not_host");
|
|
994
|
+
const target = typeof m.player === "string" ? room.byId.get(m.player) : undefined;
|
|
995
|
+
if (!target || target === seat)
|
|
996
|
+
return this.err(s, "bad_value");
|
|
997
|
+
room.banned.add(target.key);
|
|
998
|
+
const ts = target.session;
|
|
999
|
+
this.removeSeat(room, target);
|
|
1000
|
+
ts?.end(CLOSE.removed, "the host removed you from this room");
|
|
1001
|
+
}
|
|
1002
|
+
removeSeat(room, seat) {
|
|
1003
|
+
this.flush(room); // the player's last effects and changes go out before their "left"
|
|
1004
|
+
room.seats.delete(seat.key);
|
|
1005
|
+
room.byId.delete(seat.id);
|
|
1006
|
+
room.pendPlayers.delete(seat.id);
|
|
1007
|
+
this.flushProfile(room, seat, true);
|
|
1008
|
+
if (seat.session) {
|
|
1009
|
+
seat.session.room = null;
|
|
1010
|
+
seat.session.seat = null;
|
|
1011
|
+
seat.session = null;
|
|
1012
|
+
}
|
|
1013
|
+
this.broadcast(room, { t: "left", player: seat.id });
|
|
1014
|
+
if (!room.seats.size) {
|
|
1015
|
+
this.saveWorld(room, true);
|
|
1016
|
+
this.closeRoom(room);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
this.elect(room);
|
|
1020
|
+
}
|
|
1021
|
+
/** Forget a room (its last write is already queued) and give up its save lease. Every path that ends a room goes through here. */
|
|
1022
|
+
closeRoom(room) {
|
|
1023
|
+
if (this.rooms.get(room.key) === room)
|
|
1024
|
+
this.rooms.delete(room.key);
|
|
1025
|
+
if (room.lease && this.leases.get(room.lease) === room.key)
|
|
1026
|
+
this.leases.delete(room.lease);
|
|
1027
|
+
room.lease = "";
|
|
1028
|
+
}
|
|
1029
|
+
// ---- leaving --------------------------------------------------------------------------------------------------------------------------
|
|
1030
|
+
/** A socket is gone. Its seat stays (offline) for seatHoldMs, without host duty. */
|
|
1031
|
+
drop(s) {
|
|
1032
|
+
if (s.dead)
|
|
1033
|
+
return;
|
|
1034
|
+
s.dead = true;
|
|
1035
|
+
this.sessions.delete(s);
|
|
1036
|
+
if (!s.who)
|
|
1037
|
+
this.pending--;
|
|
1038
|
+
const n = (this.perAddr.get(s.addr) || 1) - 1;
|
|
1039
|
+
if (n > 0)
|
|
1040
|
+
this.perAddr.set(s.addr, n);
|
|
1041
|
+
else
|
|
1042
|
+
this.perAddr.delete(s.addr);
|
|
1043
|
+
const room = s.room, seat = s.seat;
|
|
1044
|
+
s.room = null;
|
|
1045
|
+
s.seat = null;
|
|
1046
|
+
if (!room || !seat || seat.session !== s)
|
|
1047
|
+
return;
|
|
1048
|
+
seat.session = null;
|
|
1049
|
+
seat.offlineAt = this.now();
|
|
1050
|
+
this.flushProfile(room, seat, true);
|
|
1051
|
+
this.broadcast(room, { t: "online", player: view(seat) });
|
|
1052
|
+
this.elect(room);
|
|
1053
|
+
}
|
|
1054
|
+
// ---- saves: a person's profile, a game's world --------------------------------------------------------------------------------------
|
|
1055
|
+
write(scope, slot, who, data, session, v) {
|
|
1056
|
+
const key = `${scope}|${slot}|${who ?? ""}`;
|
|
1057
|
+
const p = this.saves.store(scope, slot, who, data, session || v ? { ...(session ? { session } : {}), ...(v ? { v } : {}) } : undefined).then(() => { this.count("saves_ok"); return true; }, () => { this.refuse("save_failed"); return false; });
|
|
1058
|
+
this.writing.add(p);
|
|
1059
|
+
this.writingKey.set(key, p);
|
|
1060
|
+
void p.then(() => { this.writing.delete(p); if (this.writingKey.get(key) === p)
|
|
1061
|
+
this.writingKey.delete(key); });
|
|
1062
|
+
return p;
|
|
1063
|
+
}
|
|
1064
|
+
/** The person's profile, loaded once per seat. Null = saving is off here, or the store failed (nothing is ever written over a profile that did not load). */
|
|
1065
|
+
loadProfile(room, seat) {
|
|
1066
|
+
if (!this.saves || !room.slot)
|
|
1067
|
+
return Promise.resolve(null);
|
|
1068
|
+
if (seat.prof)
|
|
1069
|
+
return Promise.resolve(seat.prof);
|
|
1070
|
+
if (seat.profLoad)
|
|
1071
|
+
return seat.profLoad;
|
|
1072
|
+
const p = this.saves.load(room.scope, room.slot, seat.key).then((r) => {
|
|
1073
|
+
const d = r?.data;
|
|
1074
|
+
seat.prof = isObj(d) && plain(d) && bytes(d) <= this.limits.profileBytes ? d : {};
|
|
1075
|
+
seat.profV = typeof r?.v === "number" ? r.v : 1;
|
|
1076
|
+
return seat.prof;
|
|
1077
|
+
}).catch(() => { this.refuse("load_failed"); seat.profLoad = null; return null; });
|
|
1078
|
+
seat.profLoad = p;
|
|
1079
|
+
return p;
|
|
1080
|
+
}
|
|
1081
|
+
async sendProfile(s, room, seat) {
|
|
1082
|
+
const p = await this.loadProfile(room, seat);
|
|
1083
|
+
if (!s.dead)
|
|
1084
|
+
s.send(p ? { t: "profile", p, v: seat.profV } : { t: "profile", off: true });
|
|
1085
|
+
}
|
|
1086
|
+
saveProfile(s, room, seat, m) {
|
|
1087
|
+
const patch = m.p;
|
|
1088
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch))
|
|
1089
|
+
return this.err(s, "bad_value");
|
|
1090
|
+
const bad = badPath(patch);
|
|
1091
|
+
if (bad !== null)
|
|
1092
|
+
return this.err(s, "bad_value", bad);
|
|
1093
|
+
void this.loadProfile(room, seat).then((cur) => {
|
|
1094
|
+
if (!cur)
|
|
1095
|
+
return this.err(s, "save_off");
|
|
1096
|
+
const next = applyPatch(seat.prof ?? cur, patch); // not `cur`: two saves queued behind one load must build on each other
|
|
1097
|
+
if (bytes(next) > this.limits.profileBytes)
|
|
1098
|
+
return this.err(s, "too_big");
|
|
1099
|
+
seat.prof = next;
|
|
1100
|
+
seat.profDirty = true;
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
flushProfile(room, seat, force) {
|
|
1104
|
+
if (!seat.profDirty || !seat.prof || !room.slot)
|
|
1105
|
+
return;
|
|
1106
|
+
const now = this.now();
|
|
1107
|
+
if (!force && now - seat.profAt < this.limits.profileEveryMs)
|
|
1108
|
+
return;
|
|
1109
|
+
seat.profDirty = false;
|
|
1110
|
+
seat.profAt = now;
|
|
1111
|
+
void this.write(room.scope, room.slot, seat.key, seat.prof, undefined, room.spec.saveVersion).then((ok) => { if (!ok)
|
|
1112
|
+
seat.profDirty = true; });
|
|
1113
|
+
}
|
|
1114
|
+
/** Load the room's world save. Until it is loaded nothing is written or cleared (a save that could not be read is never written over); a failed
|
|
1115
|
+
* load is tried again after 2, 5, 15 s and then every 30 s, and the lobby says the game is not being saved meanwhile. */
|
|
1116
|
+
loadWorld(room) {
|
|
1117
|
+
const who = worldOf(room), key = `${room.scope}|${room.slot}|${who ?? ""}`;
|
|
1118
|
+
// after a write of this same save still on its way (a room that just handed it on), so the newest copy is read
|
|
1119
|
+
const job = (this.writingKey.get(key) ?? Promise.resolve(true)).then(() => this.saves.load(room.scope, room.slot, who)).then((r) => {
|
|
1120
|
+
if (this.rooms.get(room.key) !== room || room.worldState === "ready")
|
|
1121
|
+
return;
|
|
1122
|
+
const wasFailed = room.worldState === "failed";
|
|
1123
|
+
room.worldState = "ready";
|
|
1124
|
+
room.loadTries = 0;
|
|
1125
|
+
if (wasFailed)
|
|
1126
|
+
this.broadcast(room, { t: "savestate", error: false });
|
|
1127
|
+
const ok = !!r && isObj(r.data) && this.now() - r.at <= this.limits.saveDays * 86_400_000 && bytes(r.data) <= this.limits.sharedStateBytes;
|
|
1128
|
+
if (ok) {
|
|
1129
|
+
room.saved = { at: r.at, data: r.data, v: typeof r.v === "number" ? r.v : 1 };
|
|
1130
|
+
// a match that started without it (the load failed or was late) is not saved: its first write would replace the real game
|
|
1131
|
+
if (room.started)
|
|
1132
|
+
room.noSave = true;
|
|
1133
|
+
this.broadcast(room, { t: "saved", at: r.at, v: room.saved.v });
|
|
1134
|
+
}
|
|
1135
|
+
const w = room.startWait;
|
|
1136
|
+
if (w && room.host && !room.started) {
|
|
1137
|
+
if (ok && !w.resume)
|
|
1138
|
+
room.startWait = null; // a save turned up: the host is asked Continue or New game first
|
|
1139
|
+
else
|
|
1140
|
+
this.beginMatch(room, w.resume && ok);
|
|
1141
|
+
}
|
|
1142
|
+
}).catch(() => {
|
|
1143
|
+
this.refuse("load_failed");
|
|
1144
|
+
if (this.rooms.get(room.key) !== room || room.worldState === "ready")
|
|
1145
|
+
return;
|
|
1146
|
+
this.loadFailed(room);
|
|
1147
|
+
});
|
|
1148
|
+
this.loading.add(job);
|
|
1149
|
+
void job.then(() => this.loading.delete(job));
|
|
1150
|
+
}
|
|
1151
|
+
loadFailed(room) {
|
|
1152
|
+
const first = room.worldState !== "failed";
|
|
1153
|
+
room.worldState = "failed";
|
|
1154
|
+
room.loadAt = this.now() + ([2000, 5000, 15_000][room.loadTries] ?? 30_000);
|
|
1155
|
+
room.loadTries++;
|
|
1156
|
+
if (first)
|
|
1157
|
+
this.broadcast(room, { t: "savestate", error: true });
|
|
1158
|
+
const w = room.startWait;
|
|
1159
|
+
if (w && room.host && !room.started)
|
|
1160
|
+
this.beginMatch(room, false); // the host asked to start: play on, unsaved
|
|
1161
|
+
}
|
|
1162
|
+
/** Write the shared state as the world save when it changed (every worldEveryMs, or now when forced) and holds something besides the shell's own keys. */
|
|
1163
|
+
saveWorld(room, force) {
|
|
1164
|
+
if (!room.world || room.worldState !== "ready" || room.noSave || room.wrev === room.savedWrev)
|
|
1165
|
+
return;
|
|
1166
|
+
const now = this.now();
|
|
1167
|
+
if (!force && now - room.savedAt < this.limits.worldEveryMs)
|
|
1168
|
+
return;
|
|
1169
|
+
const data = Object.fromEntries(Object.entries(structuredClone(room.shared)).filter(([k]) => k !== "_go"));
|
|
1170
|
+
if (!Object.keys(data).some((k) => !k.startsWith("_")))
|
|
1171
|
+
return;
|
|
1172
|
+
const was = room.savedWrev, first = !room.saved;
|
|
1173
|
+
room.savedWrev = room.wrev;
|
|
1174
|
+
room.savedAt = now;
|
|
1175
|
+
room.saved = { at: now, data, v: room.spec.saveVersion };
|
|
1176
|
+
if (first || force)
|
|
1177
|
+
this.broadcast(room, { t: "saved", at: now, v: room.saved.v });
|
|
1178
|
+
this.worldWrites.push(now);
|
|
1179
|
+
void this.write(room.scope, room.slot, worldOf(room), data, room.sid, room.spec.saveVersion).then((ok) => { if (!ok && room.savedWrev === room.wrev)
|
|
1180
|
+
room.savedWrev = was; });
|
|
1181
|
+
}
|
|
1182
|
+
/** {t:"world", clear:true}: the host ends the saved game (won, lost, abandoned); nothing is saved again until the next start. */
|
|
1183
|
+
worldMsg(s, room, seat, m) {
|
|
1184
|
+
if (room.host !== seat)
|
|
1185
|
+
return this.err(s, "not_host");
|
|
1186
|
+
if (m.clear !== true || !room.world)
|
|
1187
|
+
return this.err(s, "bad_value");
|
|
1188
|
+
if (room.worldState !== "ready")
|
|
1189
|
+
return this.err(s, "save_off"); // a save that was never read is not cleared either
|
|
1190
|
+
room.noSave = true;
|
|
1191
|
+
room.saved = null;
|
|
1192
|
+
room.savedWrev = room.wrev;
|
|
1193
|
+
this.broadcast(room, { t: "saved", at: 0 });
|
|
1194
|
+
void this.write(room.scope, room.slot, worldOf(room), null, room.sid);
|
|
1195
|
+
}
|
|
1196
|
+
// ---- fan-out --------------------------------------------------------------------------------------------------------------------------
|
|
1197
|
+
broadcast(room, m, except) {
|
|
1198
|
+
const text = JSON.stringify({ v: PROTOCOL, ...m });
|
|
1199
|
+
let n = 0;
|
|
1200
|
+
for (const seat of room.seats.values()) {
|
|
1201
|
+
const s = seat.session;
|
|
1202
|
+
if (!s || s === except || s.dead || s.lagging)
|
|
1203
|
+
continue;
|
|
1204
|
+
n++;
|
|
1205
|
+
try {
|
|
1206
|
+
s.conn.send(text);
|
|
1207
|
+
}
|
|
1208
|
+
catch { /* close handler cleans up */ }
|
|
1209
|
+
}
|
|
1210
|
+
this.sent(Buffer.byteLength(text), n, room);
|
|
1211
|
+
}
|
|
1212
|
+
/** Send a room's pending changes and effects now. Variants of one frame, each made once: the host does not get its own shared writes back
|
|
1213
|
+
* (it applied them), pages without "fxb" get effects as single fx messages, an effect never goes back to its sender's old-style page, and hidden
|
|
1214
|
+
* pages get nothing (one fresh copy when they come back). fxOnly: effects alone (big rooms send state every second tick, effects every tick).
|
|
1215
|
+
* tick: called by the fan-out tick. Readers on a slower rate (Session.div) get every div-th tick frame, with everything since their last one
|
|
1216
|
+
* merged in; any other call (a join, a leave, a new host, the start) sends every rate's frame at once, so those events never overtake a change. */
|
|
1217
|
+
flush(room, except, fxOnly = false, tick = false) {
|
|
1218
|
+
const cur = this.takePending(room, fxOnly);
|
|
1219
|
+
if (tick && !fxOnly)
|
|
1220
|
+
room.flushN++;
|
|
1221
|
+
if (cur) {
|
|
1222
|
+
if (room.tiers)
|
|
1223
|
+
for (const acc of room.tiers.values())
|
|
1224
|
+
foldFrame(acc, cur);
|
|
1225
|
+
this.emit(room, cur, except, 1);
|
|
1226
|
+
}
|
|
1227
|
+
if (room.tiers)
|
|
1228
|
+
for (const [d, acc] of room.tiers) {
|
|
1229
|
+
if (tick && (fxOnly || room.flushN % d))
|
|
1230
|
+
continue;
|
|
1231
|
+
if (acc.players.size || acc.shared || acc.fx.length)
|
|
1232
|
+
this.emit(room, acc, except, d);
|
|
1233
|
+
room.tiers.set(d, emptyFrame());
|
|
1234
|
+
}
|
|
1235
|
+
// every rate's frame just went out: readers can change rate here without missing or repeating anything
|
|
1236
|
+
if (!tick || (!fxOnly && room.flushN % 4 === 0))
|
|
1237
|
+
this.retier(room);
|
|
1238
|
+
}
|
|
1239
|
+
/** The room's pending changes as one frame (null when there is nothing), and the pending lists cleared. */
|
|
1240
|
+
takePending(room, fxOnly) {
|
|
1241
|
+
if (fxOnly ? !room.pendFx.length : !room.pendPlayers.size && !room.pendShared && !room.pendFx.length)
|
|
1242
|
+
return null;
|
|
1243
|
+
const f = { players: new Map(), pt: new Map(), shared: null, sc: 0, fx: room.pendFx };
|
|
1244
|
+
room.pendFx = [];
|
|
1245
|
+
room.pendFxBytes = 0;
|
|
1246
|
+
if (!fxOnly) {
|
|
1247
|
+
f.players = room.pendPlayers;
|
|
1248
|
+
f.pt = room.pendPT;
|
|
1249
|
+
f.shared = room.pendShared;
|
|
1250
|
+
f.sc = room.pendSC;
|
|
1251
|
+
room.pendPlayers = new Map();
|
|
1252
|
+
room.pendShared = null;
|
|
1253
|
+
room.pendPT = new Map();
|
|
1254
|
+
}
|
|
1255
|
+
return f;
|
|
1256
|
+
}
|
|
1257
|
+
/** Readers take their wanted rate (the slower of the hub's reading and the page's ask); the merged frames exist only for rates in use. */
|
|
1258
|
+
retier(room) {
|
|
1259
|
+
let used = null;
|
|
1260
|
+
for (const seat of room.seats.values()) {
|
|
1261
|
+
const x = seat.session;
|
|
1262
|
+
if (!x)
|
|
1263
|
+
continue;
|
|
1264
|
+
const want = Math.max(x.netDiv, x.askDiv);
|
|
1265
|
+
if (want !== x.div) {
|
|
1266
|
+
x.div = want;
|
|
1267
|
+
this.count(want > 1 ? "rate_slower" : "rate_full");
|
|
1268
|
+
x.send({ t: "rate", div: want, every: this.every(room) * this.limits.tickMs * want }); // the page sizes its smoothing delay to it
|
|
1269
|
+
}
|
|
1270
|
+
if (x.div > 1)
|
|
1271
|
+
(used ??= new Set()).add(x.div);
|
|
1272
|
+
}
|
|
1273
|
+
if (!used) {
|
|
1274
|
+
room.tiers = null;
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
const tiers = room.tiers ?? new Map();
|
|
1278
|
+
for (const d of [...tiers.keys()])
|
|
1279
|
+
if (!used.has(d))
|
|
1280
|
+
tiers.delete(d);
|
|
1281
|
+
for (const d of used)
|
|
1282
|
+
if (!tiers.has(d))
|
|
1283
|
+
tiers.set(d, emptyFrame());
|
|
1284
|
+
room.tiers = tiers;
|
|
1285
|
+
}
|
|
1286
|
+
/** One frame to the room's readers on rate 1/div. A room of one gets none: everything in it came from that page (its own moves, its
|
|
1287
|
+
* own shared writes as host, its own effects), so playing alone costs no data coming down. */
|
|
1288
|
+
emit(room, f, except, div) {
|
|
1289
|
+
if (room.seats.size <= 1)
|
|
1290
|
+
return;
|
|
1291
|
+
const base = { t: "delta", rev: room.rev, at: this.now() };
|
|
1292
|
+
const fx = f.fx, sh = f.shared;
|
|
1293
|
+
if (f.players.size) {
|
|
1294
|
+
base.players = Object.fromEntries(f.players);
|
|
1295
|
+
base.pt = Object.fromEntries(f.pt);
|
|
1296
|
+
}
|
|
1297
|
+
if (sh)
|
|
1298
|
+
base.sc = f.sc;
|
|
1299
|
+
// the host wrote the shared part in this term: it gets the frame without it
|
|
1300
|
+
const hostS = sh && sh.term === room.term ? room.host?.session ?? null : null;
|
|
1301
|
+
// each variant is made and measured once (bytes as on the wire: UTF-8, so names and text in any language count in full)
|
|
1302
|
+
const texts = new Map();
|
|
1303
|
+
const textOf = (withShared, withFx) => {
|
|
1304
|
+
const k = `${withShared ? 1 : 0}${withFx ? 1 : 0}`;
|
|
1305
|
+
if (!texts.has(k)) {
|
|
1306
|
+
const m = { ...base };
|
|
1307
|
+
if (withFx && fx.length)
|
|
1308
|
+
m.fx = fx;
|
|
1309
|
+
if (withShared && sh) {
|
|
1310
|
+
m.shared = sh.patch;
|
|
1311
|
+
m.st = sh.term;
|
|
1312
|
+
}
|
|
1313
|
+
const text = m.players || m.fx || m.shared ? JSON.stringify({ v: PROTOCOL, ...m }) : null;
|
|
1314
|
+
texts.set(k, text === null ? null : { text, bytes: Buffer.byteLength(text) });
|
|
1315
|
+
}
|
|
1316
|
+
return texts.get(k);
|
|
1317
|
+
};
|
|
1318
|
+
let frames = 0, out = 0;
|
|
1319
|
+
const put = (x, t) => { frames++; out += t.bytes; try {
|
|
1320
|
+
x.conn.send(t.text);
|
|
1321
|
+
}
|
|
1322
|
+
catch { /* close handler cleans up */ } };
|
|
1323
|
+
for (const seat of room.seats.values()) {
|
|
1324
|
+
const x = seat.session;
|
|
1325
|
+
if (!x || x.div !== div || x === except || x.dead || x.lagging || seat.hidden)
|
|
1326
|
+
continue;
|
|
1327
|
+
const fxb = x.feats.has("fxb");
|
|
1328
|
+
const text = textOf(x !== hostS, fxb);
|
|
1329
|
+
if (text)
|
|
1330
|
+
put(x, text);
|
|
1331
|
+
if (!fxb)
|
|
1332
|
+
for (const e of fx)
|
|
1333
|
+
if (e.from !== seat.id) {
|
|
1334
|
+
const one = JSON.stringify({ v: PROTOCOL, t: "fx", ...e });
|
|
1335
|
+
put(x, { text: one, bytes: Buffer.byteLength(one) });
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
this.meter.framesOut += frames;
|
|
1339
|
+
this.meter.bytesOut += out;
|
|
1340
|
+
room.outBytes += out;
|
|
1341
|
+
}
|
|
1342
|
+
/** The open rooms a page could join instead (rooms {spec?}): same namespace, game version and save slot; not private, not someone's game
|
|
1343
|
+
* alone, not empty. Per room: code, host name, players connected and away (dropped, seat held), max, started, match, the host's round trip to the hub (ms, 0 = not
|
|
1344
|
+
* known: the host's browser runs the rules, so its link is what a newcomer feels), slowed (the room sends less often than normal), full,
|
|
1345
|
+
* yours. Up to 50, the ones you can join first, then those waiting to start, then the fullest, then the newest. Made at most every 250 ms
|
|
1346
|
+
* per game; asking costs one token of a 1-a-second bucket. */
|
|
1347
|
+
roomList(s, m) {
|
|
1348
|
+
if (!this.listRooms)
|
|
1349
|
+
return { t: "rooms", off: true };
|
|
1350
|
+
const now = this.now(), slot = this.slotOf(s), key = `${s.scope}|${s.game}|${slot}`;
|
|
1351
|
+
let hit = this.lists.get(key);
|
|
1352
|
+
if (!hit || now - hit.at >= 250) {
|
|
1353
|
+
const rooms = [];
|
|
1354
|
+
for (const r of this.rooms.values()) {
|
|
1355
|
+
if (r.scope !== s.scope || r.game !== s.game || r.slot !== slot || r.priv || r.owner)
|
|
1356
|
+
continue;
|
|
1357
|
+
if (this.limits.listIdleMs > 0 && now - r.activeAt > this.limits.listIdleMs)
|
|
1358
|
+
continue; // probably left open and forgotten
|
|
1359
|
+
let on = 0, away = 0, taken = 0;
|
|
1360
|
+
for (const x of r.seats.values()) {
|
|
1361
|
+
if (x.session)
|
|
1362
|
+
on++;
|
|
1363
|
+
else
|
|
1364
|
+
away++; // a page put away (another tab) still holds its seat: counted as in
|
|
1365
|
+
// a seat held for someone gone at least evictAfterMs gives way to a newcomer (join): it does not make the room full
|
|
1366
|
+
if (x.session || !x.offlineAt || now - x.offlineAt < this.limits.evictAfterMs)
|
|
1367
|
+
taken++;
|
|
1368
|
+
}
|
|
1369
|
+
if (!on)
|
|
1370
|
+
continue;
|
|
1371
|
+
const h = r.host?.session;
|
|
1372
|
+
rooms.push({ _r: r, code: r.code, host: r.host?.name ?? null, players: on, away, max: r.spec.max, started: r.started, match: r.match,
|
|
1373
|
+
ping: h?.rtt ?? 0, slowed: r.slowF > 1 || (this.pressure && r.seats.size > 4), full: taken >= r.spec.max, spec: r.specKey, created: r.created });
|
|
1374
|
+
}
|
|
1375
|
+
rooms.sort((a, b) => Number(a.full) - Number(b.full) || Number(a.started) - Number(b.started) || b.players - a.players || b.created - a.created);
|
|
1376
|
+
if (this.lists.size > 1000)
|
|
1377
|
+
this.lists.clear();
|
|
1378
|
+
hit = { at: now, rooms: rooms.slice(0, 50).map(({ _r, created, ...x }) => { void _r; void created; return x; }) };
|
|
1379
|
+
this.lists.set(key, hit);
|
|
1380
|
+
}
|
|
1381
|
+
// a page that says its spec sees only rooms it can join (another spec = another version of the game's options)
|
|
1382
|
+
const want = m.spec !== undefined ? parseSpec(m.spec, this.limits.maxPlayers) : null;
|
|
1383
|
+
const wantKey = want ? stable(want) : null;
|
|
1384
|
+
const mine = s.room?.code;
|
|
1385
|
+
const rooms = hit.rooms.filter((x) => !wantKey || x.spec === wantKey).map(({ spec, ...x }) => { void spec; return x.code === mine ? { ...x, you: true } : x; });
|
|
1386
|
+
return { t: "rooms", rooms, at: now };
|
|
1387
|
+
}
|
|
1388
|
+
/** Every tickMs: send changes (every second tick in rooms over 8), handle slow readers, timeouts, silent hosts, expired seats. */
|
|
1389
|
+
tick() {
|
|
1390
|
+
const t0 = performance.now();
|
|
1391
|
+
try {
|
|
1392
|
+
this.tickInner();
|
|
1393
|
+
}
|
|
1394
|
+
finally {
|
|
1395
|
+
this.tickTimes[this.tickCount++ % this.tickTimes.length] = performance.now() - t0;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/** A room over its data budget sends half as often (up to 8 times less), its host is told once (error too_much_data); it speeds up again when
|
|
1399
|
+
* the faster rate would be under 80% of the budget. */
|
|
1400
|
+
budget(r) {
|
|
1401
|
+
const L = this.limits;
|
|
1402
|
+
r.outAvg = r.outAvg ? r.outAvg * 0.7 + r.outRate * 0.3 : r.outRate;
|
|
1403
|
+
if (r.outAvg > L.roomOutBytes && r.slowF < 8) {
|
|
1404
|
+
r.slowF *= 2;
|
|
1405
|
+
r.outAvg /= 2;
|
|
1406
|
+
this.refuse("room_budget");
|
|
1407
|
+
if (!r.slowTold && r.host?.session) {
|
|
1408
|
+
r.slowTold = true;
|
|
1409
|
+
this.err(r.host.session, "too_much_data");
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
else if (r.slowF > 1 && r.outAvg * 2 < L.roomOutBytes * 0.8) {
|
|
1413
|
+
r.slowF /= 2;
|
|
1414
|
+
r.outAvg *= 2;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
/** How many ticks apart a room sends its state: 1 (50 ms), 2 in rooms over 8 players, times its own budget factor, times 2 under server pressure
|
|
1418
|
+
* for rooms over 4 players. */
|
|
1419
|
+
every(r) { return (r.seats.size > 8 ? 2 : 1) * r.slowF * (this.pressure && r.seats.size > 4 ? 2 : 1); }
|
|
1420
|
+
/** Once a second: the rates over the last second (server and per room). */
|
|
1421
|
+
meters(now) {
|
|
1422
|
+
if (now - this.mark.at < 1000)
|
|
1423
|
+
return;
|
|
1424
|
+
const sec = this.mark.at ? (now - this.mark.at) / 1000 : 1;
|
|
1425
|
+
for (const k of ["framesOut", "bytesOut", "msgsIn"]) {
|
|
1426
|
+
this.rates[k] = Math.round((this.meter[k] - this.mark[k]) / sec);
|
|
1427
|
+
this.mark[k] = this.meter[k];
|
|
1428
|
+
}
|
|
1429
|
+
this.mark.at = now;
|
|
1430
|
+
for (const r of this.rooms.values()) {
|
|
1431
|
+
r.outRate = Math.round((r.outBytes - r.outMark) / sec);
|
|
1432
|
+
r.outMark = r.outBytes;
|
|
1433
|
+
this.budget(r);
|
|
1434
|
+
}
|
|
1435
|
+
// the whole server: over 80% of its data cap, or the event loop running late (p99 over 30 ms for 10 s, from the host process), bigger rooms
|
|
1436
|
+
// send at half rate and new rooms wait; back to normal under 60% and a loop on time
|
|
1437
|
+
const mbit = (this.rates.bytesOut * 8) / 1e6, cap = this.limits.maxOutMbit;
|
|
1438
|
+
const late = (this.loopRecent?.p99 ?? 0) > 30;
|
|
1439
|
+
if (!this.pressure && ((cap > 0 && mbit > cap * 0.8) || late)) {
|
|
1440
|
+
this.pressure = true;
|
|
1441
|
+
this.refuse("pressure");
|
|
1442
|
+
}
|
|
1443
|
+
else if (this.pressure && (cap <= 0 || mbit < cap * 0.6) && !late)
|
|
1444
|
+
this.pressure = false;
|
|
1445
|
+
while (this.worldWrites.length && now - this.worldWrites[0] > 60_000)
|
|
1446
|
+
this.worldWrites.shift();
|
|
1447
|
+
}
|
|
1448
|
+
/** Everything but the room sends; `sendAll` false when subTick sends rooms on their own phase. */
|
|
1449
|
+
tickInner(sendAll = true) {
|
|
1450
|
+
const L = this.limits, now = this.now();
|
|
1451
|
+
this.meters(now);
|
|
1452
|
+
for (const s of this.sessions) {
|
|
1453
|
+
if ((!s.who || !s.room) && now - s.opened > L.pendingMs * (s.who ? 2 : 1) && !s.dead) {
|
|
1454
|
+
this.refuse("timeout");
|
|
1455
|
+
s.end(s.who ? CLOSE.badMessage : CLOSE.notAllowed, s.who ? "join took too long" : "sign-in took too long");
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
const b = s.conn.buffered();
|
|
1459
|
+
if (b > L.dropBytes) {
|
|
1460
|
+
this.refuse("too_slow");
|
|
1461
|
+
s.end(CLOSE.tooSlow, "connection too slow");
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
if (b > L.slowBytes) {
|
|
1465
|
+
s.lagging = true;
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
if (s.lagging && b < L.slowBytes / 4 && s.room && s.seat) {
|
|
1469
|
+
s.lagging = false;
|
|
1470
|
+
s.send(this.snapshot(s.room, s.seat, "sync"));
|
|
1471
|
+
}
|
|
1472
|
+
// a reader whose queue builds up goes to half the update rate (a second time after another second: a quarter); it goes back one step after
|
|
1473
|
+
// its queue stayed near empty for rateUpMs. Slower fast, faster slowly, so a phone on a weak signal does not swing between the two.
|
|
1474
|
+
if (b > L.rateBytes) {
|
|
1475
|
+
s.clearAt = now;
|
|
1476
|
+
if (s.netDiv < 4 && now - s.downAt >= 1000) {
|
|
1477
|
+
s.netDiv *= 2;
|
|
1478
|
+
s.downAt = now;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
else if (b > 1024)
|
|
1482
|
+
s.clearAt = now;
|
|
1483
|
+
else if (s.netDiv > 1 && now - s.clearAt >= L.rateUpMs) {
|
|
1484
|
+
s.netDiv /= 2;
|
|
1485
|
+
s.clearAt = now;
|
|
1486
|
+
}
|
|
1487
|
+
// a visible page that went quiet (a phone that lost its signal): gone after silentMs, its seat held as for any drop. Hidden pages are left to
|
|
1488
|
+
// the socket's own 30 s ping (browsers slow a hidden page's timers to once a minute).
|
|
1489
|
+
if (s.seat && !s.seat.hidden && now - s.lastRx > L.silentMs) {
|
|
1490
|
+
this.refuse("silent");
|
|
1491
|
+
s.end(CLOSE.silent, "no answer from this page");
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// seats, silent hosts, action time-outs, load retries and save timers: once a second is exact enough (all are seconds long)
|
|
1495
|
+
const house = now - this.houseAt >= 1000;
|
|
1496
|
+
if (house)
|
|
1497
|
+
this.houseAt = now;
|
|
1498
|
+
for (const room of this.rooms.values()) {
|
|
1499
|
+
this.guard(room, () => {
|
|
1500
|
+
room.tickN++;
|
|
1501
|
+
if (house)
|
|
1502
|
+
this.housekeep(room, now);
|
|
1503
|
+
if (!sendAll || this.rooms.get(room.key) !== room)
|
|
1504
|
+
return;
|
|
1505
|
+
if (room.tickN % this.every(room))
|
|
1506
|
+
this.flush(room, undefined, true, true);
|
|
1507
|
+
else
|
|
1508
|
+
this.flush(room, undefined, false, true);
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
housekeep(room, now) {
|
|
1513
|
+
const L = this.limits;
|
|
1514
|
+
let gone = null;
|
|
1515
|
+
for (const seat of room.seats.values())
|
|
1516
|
+
if (!seat.session && seat.offlineAt && now - seat.offlineAt > (seat.hidden ? Math.max(L.seatHoldMs, L.awayHoldMs) : L.seatHoldMs))
|
|
1517
|
+
(gone ??= []).push(seat);
|
|
1518
|
+
if (gone)
|
|
1519
|
+
for (const seat of gone)
|
|
1520
|
+
this.removeSeat(room, seat);
|
|
1521
|
+
if (this.rooms.get(room.key) !== room)
|
|
1522
|
+
return;
|
|
1523
|
+
// idle rooms (PlayFab's lobby TTL works the same way: any member activity resets it): warned idleWarnMs before, then closed
|
|
1524
|
+
if (L.idleMs > 0) {
|
|
1525
|
+
const idle = now - room.activeAt;
|
|
1526
|
+
if (idle >= L.idleMs)
|
|
1527
|
+
return this.closeIdle(room);
|
|
1528
|
+
if (!room.idleWarned && idle >= L.idleMs - L.idleWarnMs) {
|
|
1529
|
+
room.idleWarned = true;
|
|
1530
|
+
this.flush(room);
|
|
1531
|
+
this.broadcast(room, { t: "idle", closesAt: room.activeAt + L.idleMs });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (room.host && now - room.hostAliveAt > L.hostSilenceMs) {
|
|
1535
|
+
// The host's connection is open but its game stopped (a frozen phone tab): hand host duty on.
|
|
1536
|
+
const h = room.host;
|
|
1537
|
+
h.stalled = true;
|
|
1538
|
+
this.broadcast(room, { t: "online", player: view(h) });
|
|
1539
|
+
this.elect(room);
|
|
1540
|
+
}
|
|
1541
|
+
for (const [aid, a] of room.actions)
|
|
1542
|
+
if (now - a.at > L.actionTimeoutMs) {
|
|
1543
|
+
room.actions.delete(aid);
|
|
1544
|
+
a.from.session?.send({ t: "result", id: a.ref, ok: false, error: "no answer" });
|
|
1545
|
+
}
|
|
1546
|
+
if (room.worldState === "loading" && room.startWait && now - room.startWait.at > 3000) {
|
|
1547
|
+
this.refuse("load_slow");
|
|
1548
|
+
this.loadFailed(room);
|
|
1549
|
+
}
|
|
1550
|
+
else if (room.worldState === "failed" && now >= room.loadAt) {
|
|
1551
|
+
room.loadAt = Infinity;
|
|
1552
|
+
this.loadWorld(room);
|
|
1553
|
+
}
|
|
1554
|
+
this.saveWorld(room, false);
|
|
1555
|
+
for (const x of room.seats.values())
|
|
1556
|
+
this.flushProfile(room, x, false);
|
|
1557
|
+
}
|
|
1558
|
+
/** One room's part of a tick. An error there closes that room (1011) and the server keeps going: one bad room never stops the process. */
|
|
1559
|
+
guard(room, fn) {
|
|
1560
|
+
try {
|
|
1561
|
+
fn();
|
|
1562
|
+
}
|
|
1563
|
+
catch (e) {
|
|
1564
|
+
this.refuse("room_error");
|
|
1565
|
+
if (!this.erred.has(room.game)) {
|
|
1566
|
+
this.erred.add(room.game);
|
|
1567
|
+
console.error(`tickcli: a room of game ${room.game} failed and was closed:`, e?.message || e);
|
|
1568
|
+
}
|
|
1569
|
+
try {
|
|
1570
|
+
for (const seat of room.seats.values()) {
|
|
1571
|
+
const x = seat.session;
|
|
1572
|
+
if (x) {
|
|
1573
|
+
x.room = null;
|
|
1574
|
+
x.seat = null;
|
|
1575
|
+
x.end(CLOSE.roomError, "something went wrong in this room, join again");
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
catch { /* closing anyway */ }
|
|
1580
|
+
this.closeRoom(room);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
/** Called every tickMs / 5 by start(). Housekeeping once per tickMs; each room sends on its own phase (every 5 sub-ticks, every 10 in rooms
|
|
1584
|
+
* over 8 players, effects every 5), so 2,000 sockets are not written in one burst. tick() stays the one-call form for tests and simple hosts. */
|
|
1585
|
+
subTick() {
|
|
1586
|
+
const t0 = performance.now(), n = this.sub++;
|
|
1587
|
+
try {
|
|
1588
|
+
if (n % 5 === 0)
|
|
1589
|
+
this.tickInner(false);
|
|
1590
|
+
for (const room of this.rooms.values()) {
|
|
1591
|
+
const k = n + room.phase;
|
|
1592
|
+
if (k % 5)
|
|
1593
|
+
continue;
|
|
1594
|
+
this.guard(room, () => { if ((k / 5) % this.every(room))
|
|
1595
|
+
this.flush(room, undefined, true, true);
|
|
1596
|
+
else
|
|
1597
|
+
this.flush(room, undefined, false, true); });
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
finally {
|
|
1601
|
+
this.tickTimes[this.tickCount++ % this.tickTimes.length] = performance.now() - t0;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
start() {
|
|
1605
|
+
if (this.timer)
|
|
1606
|
+
return;
|
|
1607
|
+
this.timer = setInterval(() => this.subTick(), this.limits.tickMs / 5);
|
|
1608
|
+
this.timer.unref?.();
|
|
1609
|
+
}
|
|
1610
|
+
stop() { if (this.timer)
|
|
1611
|
+
clearInterval(this.timer); this.timer = null; }
|
|
1612
|
+
/** Server shutdown: pending changes and saves are written, the live rooms go to onSnapshot (the next process takes them back), then every player
|
|
1613
|
+
* hears 1012 and reconnects. Without onSnapshot a restart ends every match. */
|
|
1614
|
+
async shutdown() {
|
|
1615
|
+
this.closing = true;
|
|
1616
|
+
this.stop();
|
|
1617
|
+
for (const room of this.rooms.values()) {
|
|
1618
|
+
this.flush(room);
|
|
1619
|
+
// an action nobody will answer fails now, as on a host change
|
|
1620
|
+
for (const [aid, a] of room.actions) {
|
|
1621
|
+
a.from.session?.send({ t: "result", id: a.ref, ok: false, error: "server restarting" });
|
|
1622
|
+
room.actions.delete(aid);
|
|
1623
|
+
}
|
|
1624
|
+
this.saveWorld(room, true);
|
|
1625
|
+
for (const x of room.seats.values())
|
|
1626
|
+
this.flushProfile(room, x, true);
|
|
1627
|
+
}
|
|
1628
|
+
const snap = this.onSnapshot ? this.exportRooms() : null;
|
|
1629
|
+
for (const s of [...this.sessions])
|
|
1630
|
+
s.end(CLOSE.restart, "server restarting");
|
|
1631
|
+
for (const room of [...this.rooms.values()])
|
|
1632
|
+
this.closeRoom(room);
|
|
1633
|
+
await this.settle();
|
|
1634
|
+
if (snap)
|
|
1635
|
+
await this.onSnapshot(snap);
|
|
1636
|
+
}
|
|
1637
|
+
/** The live rooms as plain JSON: everything a new process needs to carry on (sockets, timers and open actions are not kept). */
|
|
1638
|
+
exportRooms() {
|
|
1639
|
+
const rooms = [];
|
|
1640
|
+
for (const r of this.rooms.values()) {
|
|
1641
|
+
if (r.seats.size === 0)
|
|
1642
|
+
continue;
|
|
1643
|
+
rooms.push({
|
|
1644
|
+
key: r.key, scope: r.scope, game: r.game, spec: r.spec, code: r.code, priv: r.priv, slot: r.slot, owner: r.owner, world: r.world, lease: r.lease, saveBusy: r.saveBusy,
|
|
1645
|
+
worldState: r.worldState === "ready" ? "ready" : "loading", saved: r.saved, wrev: r.wrev, savedWrev: r.savedWrev, noSave: r.noSave, resumed: r.resumed, sid: r.sid,
|
|
1646
|
+
match: r.match, rev: r.rev, term: r.term, started: r.started, played: r.played, shared: r.shared, banned: [...r.banned],
|
|
1647
|
+
seats: [...r.seats.values()].map((x) => ({ key: x.key, id: x.id, name: x.name, guest: x.guest, state: x.state, hidden: x.hidden, since: x.since })),
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
return { format: SNAPSHOT_FORMAT, at: this.now(), rooms };
|
|
1651
|
+
}
|
|
1652
|
+
/** Take back rooms from exportRooms() after a restart: every seat offline (held 60 s for its player to come back), no host until one is back, a new
|
|
1653
|
+
* term, save leases taken again. A snapshot older than maxAgeMs or of another format is ignored. Returns how many rooms came back. */
|
|
1654
|
+
importRooms(snap, o = {}) {
|
|
1655
|
+
const sn = snap;
|
|
1656
|
+
const now = this.now(), L = this.limits;
|
|
1657
|
+
if (!sn || typeof sn !== "object" || sn.format !== SNAPSHOT_FORMAT || !Array.isArray(sn.rooms) || !(now - sn.at <= (o.maxAgeMs ?? 120_000)))
|
|
1658
|
+
return 0;
|
|
1659
|
+
let n = 0;
|
|
1660
|
+
for (const x of sn.rooms) {
|
|
1661
|
+
if (this.rooms.size >= L.maxRooms || this.rooms.has(x.key))
|
|
1662
|
+
continue;
|
|
1663
|
+
const spec = parseSpec(x.spec, L.maxPlayers);
|
|
1664
|
+
if (!spec || !isObj(x.shared) || !Array.isArray(x.seats))
|
|
1665
|
+
continue;
|
|
1666
|
+
const room = {
|
|
1667
|
+
key: x.key, scope: x.scope, game: x.game, spec, specKey: stable(spec), code: x.code, priv: !!x.priv, slot: x.slot, owner: x.owner, world: !!x.world, lease: "", saveBusy: !!x.saveBusy,
|
|
1668
|
+
worldState: x.world ? x.worldState : "off", loadTries: 0, loadAt: 0, startWait: null, sid: x.sid, saved: x.saved && isObj(x.saved.data) ? x.saved : null, wrev: x.wrev, savedWrev: x.savedWrev, savedAt: now,
|
|
1669
|
+
noSave: !!x.noSave, resumed: !!x.resumed, seats: new Map(), byId: new Map(), banned: new Set(x.banned), match: x.match, rev: x.rev, term: x.term + 1, host: null, hostAliveAt: now,
|
|
1670
|
+
started: !!x.started, played: !!x.played, shared: x.shared, pendPlayers: new Map(), pendShared: null, pendPT: new Map(), pendSC: 0, pendAt: 0, actions: new Map(), nextAction: 1, tickN: 0,
|
|
1671
|
+
outBytes: 0, outMark: 0, outRate: 0, phase: crypto.randomInt(10), outAvg: 0, slowF: 1, slowTold: false, pendFx: [], pendFxBytes: 0, sharedBytes: bytes(x.shared), sizeChecks: 0, created: this.now(), flushN: 0, tiers: null, activeAt: this.now(), idleWarned: false,
|
|
1672
|
+
};
|
|
1673
|
+
// held 60 s from now (seatHoldMs counts from offlineAt)
|
|
1674
|
+
const off = now + Math.max(0, 60_000 - L.seatHoldMs);
|
|
1675
|
+
for (const z of x.seats) {
|
|
1676
|
+
const seat = { key: z.key, id: z.id, name: z.name, guest: !!z.guest, state: isObj(z.state) ? z.state : {}, session: null, hidden: !!z.hidden, stalled: false, since: z.since, offlineAt: off,
|
|
1677
|
+
lastAct: 0, kept: true, profV: 1, prof: null, profLoad: null, profDirty: false, profAt: 0, stateBytes: bytes(isObj(z.state) ? z.state : {}) };
|
|
1678
|
+
room.seats.set(seat.key, seat);
|
|
1679
|
+
room.byId.set(seat.id, seat);
|
|
1680
|
+
}
|
|
1681
|
+
if (x.world && x.lease) {
|
|
1682
|
+
if (this.leases.has(x.lease)) {
|
|
1683
|
+
room.world = false;
|
|
1684
|
+
room.saveBusy = true;
|
|
1685
|
+
room.worldState = "off";
|
|
1686
|
+
}
|
|
1687
|
+
else {
|
|
1688
|
+
this.leases.set(x.lease, room.key);
|
|
1689
|
+
room.lease = x.lease;
|
|
1690
|
+
if (room.worldState === "loading")
|
|
1691
|
+
this.loadWorld(room);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
this.rooms.set(room.key, room);
|
|
1695
|
+
n++;
|
|
1696
|
+
}
|
|
1697
|
+
return n;
|
|
1698
|
+
}
|
|
1699
|
+
/** Write every changed profile and world now and wait until the store has taken them (a restart, a test). */
|
|
1700
|
+
flushSaves() {
|
|
1701
|
+
for (const room of this.rooms.values()) {
|
|
1702
|
+
this.saveWorld(room, true);
|
|
1703
|
+
for (const x of room.seats.values())
|
|
1704
|
+
this.flushProfile(room, x, true);
|
|
1705
|
+
}
|
|
1706
|
+
return this.settle();
|
|
1707
|
+
}
|
|
1708
|
+
/** Resolves once every world save being read now has been read (tests, a bench). */
|
|
1709
|
+
async whenLoaded() { while (this.loading.size)
|
|
1710
|
+
await Promise.all([...this.loading]); }
|
|
1711
|
+
async settle() { while (this.writing.size)
|
|
1712
|
+
await Promise.all([...this.writing]); }
|
|
1713
|
+
/** Close every room of one namespace (a public link stopped). */
|
|
1714
|
+
closeScope(scope, reason = "this game is no longer shared") {
|
|
1715
|
+
for (const room of [...this.rooms.values()]) {
|
|
1716
|
+
if (room.scope !== scope)
|
|
1717
|
+
continue;
|
|
1718
|
+
this.saveWorld(room, true);
|
|
1719
|
+
for (const seat of room.seats.values()) {
|
|
1720
|
+
this.flushProfile(room, seat, true);
|
|
1721
|
+
seat.session?.end(CLOSE.notAllowed, reason);
|
|
1722
|
+
}
|
|
1723
|
+
this.closeRoom(room);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
/** One namespace's use right now: rooms, seats, connected players and bytes out a second (over the last second). For per-customer caps. */
|
|
1727
|
+
scopeStats(scope) {
|
|
1728
|
+
let rooms = 0, players = 0, online = 0, bytesOut = 0;
|
|
1729
|
+
for (const r of this.rooms.values()) {
|
|
1730
|
+
if (r.scope !== scope)
|
|
1731
|
+
continue;
|
|
1732
|
+
rooms++;
|
|
1733
|
+
bytesOut += r.outRate;
|
|
1734
|
+
for (const x of r.seats.values()) {
|
|
1735
|
+
players++;
|
|
1736
|
+
if (x.session)
|
|
1737
|
+
online++;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return { rooms, players, online, bytesOut };
|
|
1741
|
+
}
|
|
1742
|
+
stats() {
|
|
1743
|
+
let players = 0, online = 0, started = 0, slow = 0;
|
|
1744
|
+
for (const r of this.rooms.values()) {
|
|
1745
|
+
if (r.started)
|
|
1746
|
+
started++;
|
|
1747
|
+
for (const s of r.seats.values()) {
|
|
1748
|
+
players++;
|
|
1749
|
+
if (s.session)
|
|
1750
|
+
online++;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
for (const s of this.sessions)
|
|
1754
|
+
if (s.lagging)
|
|
1755
|
+
slow++;
|
|
1756
|
+
const n = Math.min(this.tickCount, this.tickTimes.length), sorted = Array.from(this.tickTimes.subarray(0, n)).sort((a, b) => a - b);
|
|
1757
|
+
const pct = (p) => (n ? Math.round(sorted[Math.min(n - 1, Math.floor(n * p))] * 100) / 100 : 0);
|
|
1758
|
+
const top = [...this.rooms.values()].sort((a, b) => b.outRate - a.outRate).slice(0, 5).filter((r) => r.outRate > 0)
|
|
1759
|
+
.map((r) => ({ players: r.seats.size, bytesOut: r.outRate, sharedBytes: r.sharedBytes }));
|
|
1760
|
+
const live = {
|
|
1761
|
+
...this.rates, tickMs: { p50: pct(0.5), p99: pct(0.99), max: n ? Math.round(sorted[n - 1] * 100) / 100 : 0 }, slow, started,
|
|
1762
|
+
savesOk: this.counts.saves_ok || 0, savesFailed: this.refused.save_failed || 0, loadsFailed: this.refused.load_failed || 0, worldWritesPerMin: this.worldWrites.length,
|
|
1763
|
+
loop: this.loop, top, pressure: this.pressure, slowedRooms: [...this.rooms.values()].filter((r) => r.slowF > 1).length,
|
|
1764
|
+
};
|
|
1765
|
+
return { rooms: this.rooms.size, players, online, connections: this.sessions.size, pending: this.pending, refused: { ...this.refused }, live };
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const view = (s) => ({ id: s.id, name: s.name, online: !!s.session, hidden: s.hidden || s.stalled, guest: s.guest, state: s.state });
|
|
1769
|
+
/** A guest's display name: letters, digits, spaces, "_" and "-", at most 20 characters. */
|
|
1770
|
+
export function cleanName(raw) {
|
|
1771
|
+
if (typeof raw !== "string" || raw.length > 200)
|
|
1772
|
+
return null;
|
|
1773
|
+
let s = raw.normalize("NFKC").replace(/[^\p{L}\p{N}\p{M} _-]/gu, "").replace(/(\p{M}{2})\p{M}+/gu, "$1").replace(/\s+/g, " ").trim();
|
|
1774
|
+
s = Array.from(s).slice(0, 20).join("").trim();
|
|
1775
|
+
return /[\p{L}\p{N}]/u.test(s) ? s : null;
|
|
1776
|
+
}
|
|
1777
|
+
//# sourceMappingURL=hub.js.map
|