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