@zaisma/game-server 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -1
- package/bin/node-parts.js +311 -0
- package/bin/node.d.ts +43 -0
- package/bin/node.js +615 -0
- package/package.json +42 -1
- package/src/chat.d.ts +65 -0
- package/src/chat.js +108 -0
- package/src/database.d.ts +150 -0
- package/src/database.js +244 -0
- package/src/dump.d.ts +61 -0
- package/src/dump.js +282 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +12 -0
- package/src/internal.d.ts +13 -0
- package/src/internal.js +15 -0
- package/src/names.d.ts +4 -0
- package/src/names.js +18 -0
- package/src/outside-calls.d.ts +46 -0
- package/src/outside-calls.js +30 -0
- package/src/pass.d.ts +194 -0
- package/src/pass.js +231 -0
- package/src/relay.d.ts +7 -0
- package/src/relay.js +20 -0
- package/src/requests.d.ts +89 -0
- package/src/requests.js +127 -0
- package/src/room-calls.d.ts +92 -0
- package/src/room-calls.js +194 -0
- package/src/room.d.ts +1462 -0
- package/src/room.js +2192 -0
- package/src/schedules.d.ts +82 -0
- package/src/schedules.js +200 -0
- package/src/state-sync.d.ts +57 -0
- package/src/state-sync.js +175 -0
- package/src/webhooks.d.ts +112 -0
- package/src/webhooks.js +102 -0
package/src/room.js
ADDED
|
@@ -0,0 +1,2192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The game server API and the room logic shared by Zaisma rooms (Durable Objects)
|
|
3
|
+
* and the Node runner, so a game behaves the same in both places.
|
|
4
|
+
*
|
|
5
|
+
* Words: the *client* is the game in the player's browser. The *game server* is the
|
|
6
|
+
* developer's code that holds the official game state. A *room* is one live match,
|
|
7
|
+
* running one copy of the game server. Names follow docs/NAMING.md.
|
|
8
|
+
*/
|
|
9
|
+
import { DatabaseError } from "./database.js";
|
|
10
|
+
import { isRoomCode, roomCode } from "./names.js";
|
|
11
|
+
import { readSchedules } from "./schedules.js";
|
|
12
|
+
import { roomCallChecks } from "./room-calls.js";
|
|
13
|
+
import { newStateTracker } from "./state-sync.js";
|
|
14
|
+
function shownPlayer(player) {
|
|
15
|
+
return { id: player.id, name: player.name, kind: player.kind };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A restored room's restoreInfo, from when its state was saved and the version that saved it. A state
|
|
19
|
+
* saved as the room emptied (isSavedEmpty) lost nothing, however long ago: a kept world waking after a week
|
|
20
|
+
* reads savedAt for the time that passed.
|
|
21
|
+
*/
|
|
22
|
+
export function restoreInfoOf(savedAtMs, nowMs = Date.now(), savedVersionId = null, isSavedEmpty = false) {
|
|
23
|
+
return { savedAt: new Date(savedAtMs).toISOString(), lostDurationSec: isSavedEmpty ? 0 : Math.max(0, Math.round((nowMs - savedAtMs) / 1000)), savedVersionId };
|
|
24
|
+
}
|
|
25
|
+
function describeObject(given) {
|
|
26
|
+
try {
|
|
27
|
+
return (JSON.stringify(given) ?? "an object").slice(0, 80);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "an object";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* canJoin's answer as the room acts on it: let in, or turn away with a refusalMessage (null for the
|
|
35
|
+
* usual one). Answering nothing or anything else is a mistake (bad_handler_answer), described for the game server's errors.
|
|
36
|
+
*/
|
|
37
|
+
export function readJoinAnswer(answer) {
|
|
38
|
+
if (answer === true)
|
|
39
|
+
return { isAllowed: true };
|
|
40
|
+
if (answer === false)
|
|
41
|
+
return { isAllowed: false, refusalMessage: null };
|
|
42
|
+
const refusalMessage = typeof answer === "object" && answer !== null && !Array.isArray(answer) ? answer.refusalMessage : undefined;
|
|
43
|
+
if (typeof refusalMessage === "string" && Object.keys(answer).length === 1)
|
|
44
|
+
return { isAllowed: false, refusalMessage };
|
|
45
|
+
if (answer === undefined || answer === null) {
|
|
46
|
+
return { mistake: new GameServerError("bad_handler_answer", "canJoin", "canJoin answered nothing, so the player was turned away.", "Answer true to let them in, or { refusalMessage } to say why not.") };
|
|
47
|
+
}
|
|
48
|
+
const given = typeof answer === "string" ? `the text ${JSON.stringify(answer.slice(0, 60))}` : typeof answer === "object" ? describeObject(answer) : String(answer);
|
|
49
|
+
return { mistake: new GameServerError("bad_handler_answer", "canJoin", `canJoin answered ${given}, so the player was turned away.`, 'Answer true to let them in, false to turn them away, or { refusalMessage: "Wrong room password." } to turn them away with a message.') };
|
|
50
|
+
}
|
|
51
|
+
/** Everything a game server object may have, in the order the docs give them. Anything else is refused. */
|
|
52
|
+
export const GAME_SERVER_OPTIONS = [
|
|
53
|
+
"maximumPlayerCount",
|
|
54
|
+
"maximumSpectatorCount",
|
|
55
|
+
"ticksPerSec",
|
|
56
|
+
"hostControlsEnabled",
|
|
57
|
+
"idleCheck",
|
|
58
|
+
"idleTimeoutSec",
|
|
59
|
+
"awayMode",
|
|
60
|
+
"seatHoldDurationSec",
|
|
61
|
+
"stateSyncEnabled",
|
|
62
|
+
"reconnectTimeoutSec",
|
|
63
|
+
"stateSaveIntervalSec",
|
|
64
|
+
"setup",
|
|
65
|
+
"onJoin",
|
|
66
|
+
"canJoin",
|
|
67
|
+
"onMoved",
|
|
68
|
+
"onLeave",
|
|
69
|
+
"onSpectatorJoin",
|
|
70
|
+
"onSpectatorLeave",
|
|
71
|
+
"onAway",
|
|
72
|
+
"onBack",
|
|
73
|
+
"onMessage",
|
|
74
|
+
"onBytes",
|
|
75
|
+
"onTick",
|
|
76
|
+
"onEnding",
|
|
77
|
+
"onRestored",
|
|
78
|
+
"stateView",
|
|
79
|
+
"onVersionUpdate",
|
|
80
|
+
"onRequest",
|
|
81
|
+
"schedules",
|
|
82
|
+
"onSchedule",
|
|
83
|
+
"onWebhook",
|
|
84
|
+
];
|
|
85
|
+
export const ROOM_LIMITS = {
|
|
86
|
+
/** Players in one room: what one room carries at 60 ticks a second (test/load/room-load.test.ts). */
|
|
87
|
+
maximumPlayerCount: 100,
|
|
88
|
+
defaultMaximumPlayerCount: 8,
|
|
89
|
+
/** Spectators in one room: each send costs the room time, so its audience is bounded like its players. Bigger audiences are a stream. */
|
|
90
|
+
maximumSpectatorCount: 100,
|
|
91
|
+
maximumTicksPerSec: 60,
|
|
92
|
+
/** A room's name, its labels, and each label's name and value. */
|
|
93
|
+
maximumNameCharacterCount: 40,
|
|
94
|
+
maximumLabelCount: 4,
|
|
95
|
+
maximumLabelNameCharacterCount: 16,
|
|
96
|
+
maximumLabelValueCharacterCount: 24,
|
|
97
|
+
/** Join data a player brings to a room, and setup data a reserved room starts with, as JSON. */
|
|
98
|
+
maximumJoinDataSizeBytes: 4 * 1024,
|
|
99
|
+
maximumSetupDataSizeBytes: 4 * 1024,
|
|
100
|
+
/** Rooms one room may reserve or move players to a minute: enough for a full lobby, not for a bug that does it every tick. */
|
|
101
|
+
maximumReservesPerMin: 20,
|
|
102
|
+
/** A kick's message, and a refusal's (canJoin's refusalMessage). */
|
|
103
|
+
maximumKickMessageCharacterCount: 200,
|
|
104
|
+
maximumRefusalMessageCharacterCount: 200,
|
|
105
|
+
/** Largest game message (or bytes) a client may send. */
|
|
106
|
+
maximumGameMessageSizeBytes: 16 * 1024,
|
|
107
|
+
/**
|
|
108
|
+
* Sustained game messages (and bytes) per second per player, with a short burst allowance. A game at
|
|
109
|
+
* 60 ticks sends two inputs in one message: incoming messages are what a room is billed for.
|
|
110
|
+
*/
|
|
111
|
+
maximumGameMessagesPerSec: 30,
|
|
112
|
+
maximumGameMessageBurstCount: 60,
|
|
113
|
+
/** The idle check's default and range (idleTimeoutSec, joinRoom({ idleTimeoutSec })). */
|
|
114
|
+
defaultIdleTimeoutSec: 600,
|
|
115
|
+
minimumIdleTimeoutSec: 30,
|
|
116
|
+
maximumIdleTimeoutSec: 24 * 3600,
|
|
117
|
+
/** How long a game hidden in a real-time room counts before its player is away. */
|
|
118
|
+
hiddenTimeoutSec: 120,
|
|
119
|
+
/** How long a seat is held by default, and at most (seatHoldDurationSec). */
|
|
120
|
+
defaultSeatHoldDurationSec: 300,
|
|
121
|
+
maximumSeatHoldDurationSec: 1800,
|
|
122
|
+
/** How long a player whose connection dropped stays in the match by default, and at most (reconnectTimeoutSec). */
|
|
123
|
+
defaultReconnectTimeoutSec: 30,
|
|
124
|
+
maximumReconnectTimeoutSec: 300,
|
|
125
|
+
/** How often a real-time room saves its state by default, and the range (stateSaveIntervalSec). */
|
|
126
|
+
defaultStateSaveIntervalSec: 5,
|
|
127
|
+
minimumStateSaveIntervalSec: 1,
|
|
128
|
+
maximumStateSaveIntervalSec: 60,
|
|
129
|
+
/** A match moving to a version published "now": its notice without onVersionUpdate, and the longest onVersionUpdate can hold it. */
|
|
130
|
+
defaultVersionUpdateDelaySec: 10,
|
|
131
|
+
maximumVersionUpdateDelaySec: 300,
|
|
132
|
+
/** A real-time room's state bigger than this as JSON isn't saved, and a restart starts it fresh. */
|
|
133
|
+
maximumSavedStateSizeBytes: 32 * 1024 * 1024,
|
|
134
|
+
/** A player's view of the state as JSON (stateSyncEnabled): bigger views aren't sent, and the game's errors say so once. */
|
|
135
|
+
maximumStateViewSizeBytes: 1024 * 1024,
|
|
136
|
+
/** A latest-only message's latestKey. */
|
|
137
|
+
maximumLatestKeyCharacterCount: 40,
|
|
138
|
+
/** Latest-only messages waiting for a backed-up player, one per latestKey; past it, the oldest is dropped. */
|
|
139
|
+
maximumLatestOnlySlotCount: 64,
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* How rooms watch connections: when they probe, and when a player counts as backed up or dropped. Tuning,
|
|
143
|
+
* not contract: unlike ROOM_LIMITS, these may change in any version.
|
|
144
|
+
*/
|
|
145
|
+
export const ROOM_TUNING = {
|
|
146
|
+
/** How often a ticking room probes each player, for their ping and the room's clock. */
|
|
147
|
+
probeIntervalSec: 2,
|
|
148
|
+
/** A probe left unanswered this long drops the player: their connection stopped or fell too far behind. */
|
|
149
|
+
probeAnswerTimeoutSec: 8,
|
|
150
|
+
/** A player is also probed each time this much has gone out to them since their last probe. */
|
|
151
|
+
probeSentSizeBytes: 256 * 1024,
|
|
152
|
+
/** Bytes sent to a player since the last probe they answered; past it, they're dropped. */
|
|
153
|
+
maximumUnansweredSentSizeBytes: 2 * 1024 * 1024,
|
|
154
|
+
/** Unanswered probes the room remembers per player; the oldest is forgotten past it. */
|
|
155
|
+
maximumUnansweredProbeCount: 16,
|
|
156
|
+
/**
|
|
157
|
+
* A player is backed up with this much sent and not yet through: on your own server, the socket's
|
|
158
|
+
* bufferedAmount; in Zaisma rooms, sent since the last probe they answered, while a probe of theirs is
|
|
159
|
+
* backedUpDelayMs later than their ping. State sync skips them, and latest-only messages wait for them in slots.
|
|
160
|
+
*/
|
|
161
|
+
backedUpSizeBytes: 64 * 1024,
|
|
162
|
+
backedUpDelayMs: 250,
|
|
163
|
+
};
|
|
164
|
+
/** The room protocol's version: hello and welcome say it, so either side can tell what the other speaks. */
|
|
165
|
+
export const PROTOCOL_VERSION_NUMBER = 1;
|
|
166
|
+
/** "Still there?" waits this long for an answer, and the room this much more before it decides for itself. */
|
|
167
|
+
const IDLE_ASK_MS = 30_000;
|
|
168
|
+
const IDLE_GRACE_MS = 60_000;
|
|
169
|
+
const GAME_SERVER_ERROR_CODES = ["unknown_option", "bad_option", "bad_room_call", "room_call_limit", "room_call_unavailable", "bad_handler_answer"];
|
|
170
|
+
/**
|
|
171
|
+
* A game server object that breaks the rules, with a code, the option at fault and what to do
|
|
172
|
+
* (like the developer API's errors, docs/NAMING.md).
|
|
173
|
+
*/
|
|
174
|
+
export class GameServerError extends Error {
|
|
175
|
+
code;
|
|
176
|
+
field;
|
|
177
|
+
fix;
|
|
178
|
+
constructor(code, field, message, fix) {
|
|
179
|
+
super(`${message} ${fix} (${code})`);
|
|
180
|
+
this.name = "GameServerError";
|
|
181
|
+
this.code = code;
|
|
182
|
+
this.field = field;
|
|
183
|
+
this.fix = fix;
|
|
184
|
+
}
|
|
185
|
+
/** True for any error named GameServerError with a known code, so instanceof works for the copy a game's sandbox has too. */
|
|
186
|
+
static [Symbol.hasInstance](candidate) {
|
|
187
|
+
return candidate instanceof Error && candidate.name === "GameServerError" && GAME_SERVER_ERROR_CODES.includes(candidate.code);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** The checks every room call goes through, here and in a game's sandbox (room-calls.ts). */
|
|
191
|
+
const checks = roomCallChecks(GameServerError, ROOM_LIMITS);
|
|
192
|
+
/** The error for an option a game server doesn't take, naming the ones it does. */
|
|
193
|
+
export function unknownOptionError(field) {
|
|
194
|
+
return new GameServerError("unknown_option", field, `${field} isn't a game server option.`, `A game server takes: ${GAME_SERVER_OPTIONS.join(", ")}.`);
|
|
195
|
+
}
|
|
196
|
+
/** The error for a room whose game server has no setup(): it only answers requests, runs on schedules or takes webhooks. */
|
|
197
|
+
export function noSetupError() {
|
|
198
|
+
return new GameServerError("bad_option", "setup", "This game server has no setup(), so it can't run rooms; it only answers requests, runs on schedules or takes webhooks.", "Add setup: (setupData) => ({ ...your starting state }) to run rooms too.");
|
|
199
|
+
}
|
|
200
|
+
/** A game server's schedules, checked (readSchedules), or GameServerError for the field schedules. */
|
|
201
|
+
export function checkSchedules(schedules, hasOnSchedule) {
|
|
202
|
+
const read = readSchedules(schedules, hasOnSchedule);
|
|
203
|
+
if ("message" in read)
|
|
204
|
+
throw new GameServerError("bad_option", "schedules", read.message, read.fix);
|
|
205
|
+
return read.schedules;
|
|
206
|
+
}
|
|
207
|
+
/** Check a game server's options once, when it's defined: unknown options and values out of range throw. */
|
|
208
|
+
export function defineGameServer(server) {
|
|
209
|
+
for (const field of Object.keys(server ?? {}))
|
|
210
|
+
if (!GAME_SERVER_OPTIONS.includes(field))
|
|
211
|
+
throw unknownOptionError(field);
|
|
212
|
+
const bad = (field, message, fix) => new GameServerError("bad_option", field, message, fix);
|
|
213
|
+
if (typeof server?.setup !== "function" && typeof server?.onRequest !== "function" && typeof server?.onSchedule !== "function" && typeof server?.onWebhook !== "function") {
|
|
214
|
+
throw bad("setup", "A game server needs setup() to run rooms, onRequest() to answer requests, onSchedule() to run on schedules or onWebhook() to take webhooks, or more than one.", "Add setup: (setupData) => ({ ...your starting state }) for rooms, onRequest(player, request, server) for requests, schedules with onSchedule(run, server), or onWebhook(event, server) for webhooks.");
|
|
215
|
+
}
|
|
216
|
+
for (const field of GAME_SERVER_OPTIONS) {
|
|
217
|
+
if (field.startsWith("on") || field === "canJoin" || field === "setup" || field === "stateView") {
|
|
218
|
+
if (server[field] !== undefined && typeof server[field] !== "function")
|
|
219
|
+
throw bad(field, `${field} isn't a function.`, `Give ${field} as a function, or leave it out.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
checkSchedules(server.schedules, typeof server.onSchedule === "function");
|
|
223
|
+
const { maximumPlayerCount, maximumSpectatorCount, ticksPerSec, hostControlsEnabled, idleCheck, idleTimeoutSec, awayMode, seatHoldDurationSec, reconnectTimeoutSec, stateSaveIntervalSec } = server;
|
|
224
|
+
if (maximumPlayerCount !== undefined && !(Number.isInteger(maximumPlayerCount) && maximumPlayerCount >= 1 && maximumPlayerCount <= ROOM_LIMITS.maximumPlayerCount)) {
|
|
225
|
+
throw bad("maximumPlayerCount", `maximumPlayerCount is ${JSON.stringify(maximumPlayerCount)}.`, `Give a whole number from 1 to ${ROOM_LIMITS.maximumPlayerCount}, like 8.`);
|
|
226
|
+
}
|
|
227
|
+
if (maximumSpectatorCount !== undefined && !(Number.isInteger(maximumSpectatorCount) && maximumSpectatorCount >= 0 && maximumSpectatorCount <= ROOM_LIMITS.maximumSpectatorCount)) {
|
|
228
|
+
throw bad("maximumSpectatorCount", `maximumSpectatorCount is ${JSON.stringify(maximumSpectatorCount)}.`, `Give a whole number from 0 to ${ROOM_LIMITS.maximumSpectatorCount}, like 20, or leave it out so nobody watches.`);
|
|
229
|
+
}
|
|
230
|
+
if (ticksPerSec !== undefined && !(typeof ticksPerSec === "number" && ticksPerSec > 0 && ticksPerSec <= ROOM_LIMITS.maximumTicksPerSec)) {
|
|
231
|
+
throw bad("ticksPerSec", `ticksPerSec is ${JSON.stringify(ticksPerSec)}.`, `Give a number above 0 and up to ${ROOM_LIMITS.maximumTicksPerSec}, like 30, or leave it out for a turn-based game.`);
|
|
232
|
+
}
|
|
233
|
+
if (hostControlsEnabled !== undefined && typeof hostControlsEnabled !== "boolean")
|
|
234
|
+
throw bad("hostControlsEnabled", "hostControlsEnabled isn't true or false.", "Give true or false.");
|
|
235
|
+
if (server.stateSyncEnabled !== undefined && typeof server.stateSyncEnabled !== "boolean")
|
|
236
|
+
throw bad("stateSyncEnabled", "stateSyncEnabled isn't true or false.", "Give true to send each player the state and then only what changes, or leave it out.");
|
|
237
|
+
if (server.stateView !== undefined && !server.stateSyncEnabled)
|
|
238
|
+
throw bad("stateView", "stateView only works with stateSyncEnabled.", "Add stateSyncEnabled: true, or leave stateView out.");
|
|
239
|
+
if (idleCheck !== undefined && idleCheck !== "server" && idleCheck !== "client" && idleCheck !== "off") {
|
|
240
|
+
throw bad("idleCheck", `idleCheck is ${JSON.stringify(idleCheck)}.`, 'Give "server" (the room checks, after idleTimeoutSec), "client" (each player\'s game decides) or "off".');
|
|
241
|
+
}
|
|
242
|
+
if (idleTimeoutSec !== undefined && !(typeof idleTimeoutSec === "number" && idleTimeoutSec >= ROOM_LIMITS.minimumIdleTimeoutSec && idleTimeoutSec <= ROOM_LIMITS.maximumIdleTimeoutSec)) {
|
|
243
|
+
throw bad("idleTimeoutSec", `idleTimeoutSec is ${JSON.stringify(idleTimeoutSec)}.`, `Give seconds from ${ROOM_LIMITS.minimumIdleTimeoutSec} to ${ROOM_LIMITS.maximumIdleTimeoutSec}, like 600; to turn the idle check off, give idleCheck: "off".`);
|
|
244
|
+
}
|
|
245
|
+
if (awayMode !== undefined && awayMode !== "hold" && awayMode !== "lobby")
|
|
246
|
+
throw bad("awayMode", `awayMode is ${JSON.stringify(awayMode)}.`, 'Give "hold" (hold their seat) or "lobby" (send them to the room that made this one).');
|
|
247
|
+
if (seatHoldDurationSec !== undefined && !(typeof seatHoldDurationSec === "number" && seatHoldDurationSec >= 0 && seatHoldDurationSec <= ROOM_LIMITS.maximumSeatHoldDurationSec)) {
|
|
248
|
+
throw bad("seatHoldDurationSec", `seatHoldDurationSec is ${JSON.stringify(seatHoldDurationSec)}.`, `Give seconds from 0 (no held seats) to ${ROOM_LIMITS.maximumSeatHoldDurationSec}, like 300.`);
|
|
249
|
+
}
|
|
250
|
+
if (reconnectTimeoutSec !== undefined && !(typeof reconnectTimeoutSec === "number" && reconnectTimeoutSec >= 0 && reconnectTimeoutSec <= ROOM_LIMITS.maximumReconnectTimeoutSec)) {
|
|
251
|
+
throw bad("reconnectTimeoutSec", `reconnectTimeoutSec is ${JSON.stringify(reconnectTimeoutSec)}.`, `Give seconds from 0 (leave at once) to ${ROOM_LIMITS.maximumReconnectTimeoutSec}, like 30.`);
|
|
252
|
+
}
|
|
253
|
+
if (stateSaveIntervalSec !== undefined && !(typeof stateSaveIntervalSec === "number" && stateSaveIntervalSec >= ROOM_LIMITS.minimumStateSaveIntervalSec && stateSaveIntervalSec <= ROOM_LIMITS.maximumStateSaveIntervalSec)) {
|
|
254
|
+
throw bad("stateSaveIntervalSec", `stateSaveIntervalSec is ${JSON.stringify(stateSaveIntervalSec)}.`, `Give a number of seconds from ${ROOM_LIMITS.minimumStateSaveIntervalSec} to ${ROOM_LIMITS.maximumStateSaveIntervalSec}, like ${ROOM_LIMITS.defaultStateSaveIntervalSec}, or leave it out for ${ROOM_LIMITS.defaultStateSaveIntervalSec}.`);
|
|
255
|
+
}
|
|
256
|
+
return server;
|
|
257
|
+
}
|
|
258
|
+
export const ERROR_TEXT = {
|
|
259
|
+
room_full: "This room is full.",
|
|
260
|
+
room_closed: "This room isn't taking new players.",
|
|
261
|
+
ended: "This match has ended.",
|
|
262
|
+
too_big: "Message too big.",
|
|
263
|
+
too_fast: "Too many messages. Slow down.",
|
|
264
|
+
bad_message: "The room couldn't read or use that message.",
|
|
265
|
+
bad_pass: "Your player pass isn't valid for this game.",
|
|
266
|
+
no_hello: "Send your player pass first.",
|
|
267
|
+
not_allowed: "Only the game server or the room's host can do that.",
|
|
268
|
+
game_unavailable: "This game isn't available right now.",
|
|
269
|
+
busy: "Rooms are busy. Try again soon.",
|
|
270
|
+
kept_out: "You were removed from this room.",
|
|
271
|
+
refused: "The game didn't let you into this room.",
|
|
272
|
+
room_not_running: "That room isn't running.",
|
|
273
|
+
hosting_paused: "This game's hosting is paused. It starts again when the month resets or its developer adds credit.",
|
|
274
|
+
account_needed: "Playing online needs a Zaisma account. Sign in on the game's page to play.",
|
|
275
|
+
game_not_open_to_player: "This game isn't open to this account.",
|
|
276
|
+
blocked_player_here: "Someone you blocked is in this room.",
|
|
277
|
+
other_version: "This room runs another version of the game, so the game reloads at it first.",
|
|
278
|
+
spectators_full: "Nobody else can watch this room right now.",
|
|
279
|
+
spectator_cannot_send: "Spectators watch; they can't send to the room.",
|
|
280
|
+
already_playing: "You're playing in this room, so you can't watch it too.",
|
|
281
|
+
bad_origin: "Rooms take players from the game's own page.",
|
|
282
|
+
// The SDK's own: sends waiting while it reconnects ran past its queue.
|
|
283
|
+
send_queue_full: "Up to 60 sends wait while reconnecting; older ones were dropped.",
|
|
284
|
+
};
|
|
285
|
+
/** What players are told when Zaisma ends a match at room.endsAt. */
|
|
286
|
+
export const HOSTING_PAUSED_END_MESSAGE = "This game's hosting is paused, so this match has ended. New rooms open again when the month resets or its developer adds credit.";
|
|
287
|
+
/** How long a room holds a seat for a player another room is sending. */
|
|
288
|
+
const SEAT_MS = 60_000;
|
|
289
|
+
/** room.secrets and server.secrets where a game has none. */
|
|
290
|
+
export const NO_SECRETS = Object.freeze({});
|
|
291
|
+
/** room.verifyReceipt and server.verifyReceipt where the runtime has no receipt keys. */
|
|
292
|
+
export const NO_RECEIPT_CHECK = () => Promise.reject(new Error("Receipts can't be checked here: run the game server in Zaisma rooms or with the Node runner (npx @zaisma/game-server), or call verifyReceipt with the keys from https://rooms.zaisma.com/.well-known/receipt-jwks.json and issuer https://rooms.zaisma.com/receipts."));
|
|
293
|
+
/** room.database where the room has no database: every call fails, saying so. */
|
|
294
|
+
const NO_DATABASE = {
|
|
295
|
+
query: () => Promise.reject(new DatabaseError("database_unavailable", "This room has no game database.", "On your own server, start the Node runner with --database-file <file>, or pass database to startServer.")),
|
|
296
|
+
batch: () => Promise.reject(new DatabaseError("database_unavailable", "This room has no game database.", "On your own server, start the Node runner with --database-file <file>, or pass database to startServer.")),
|
|
297
|
+
erasePlayer: () => Promise.reject(new DatabaseError("database_unavailable", "This room has no game database.", "On your own server, start the Node runner with --database-file <file>, or pass database to startServer.")),
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Plain JSON within maximumSizeBytes (ROOM_LIMITS.maximumJoinDataSizeBytes or maximumSetupDataSizeBytes), copied, or isValid false if it isn't.
|
|
301
|
+
* Nothing (undefined or null) is null. Players' data that fails this is refused.
|
|
302
|
+
*/
|
|
303
|
+
export function checkRoomData(given, maximumSizeBytes) {
|
|
304
|
+
return checks.checkRoomData(given, maximumSizeBytes);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* An error for a client. message replaces the usual text, like the game's reason for turning a player away;
|
|
308
|
+
* details add what the code needs, like the room's versionId with other_version.
|
|
309
|
+
*/
|
|
310
|
+
export function errorMessage(code, message, details) {
|
|
311
|
+
return JSON.stringify({ type: "error", code, message: message || ERROR_TEXT[code], ...details });
|
|
312
|
+
}
|
|
313
|
+
/** WebSocket close code for a match that ended normally. */
|
|
314
|
+
export const ENDED_CLOSE_CODE = 4000;
|
|
315
|
+
/** Close code for a player refused or removed by the room: the SDK doesn't reconnect. */
|
|
316
|
+
export const REMOVED_CLOSE_CODE = 4001;
|
|
317
|
+
/** Close code for a player who walked away, once the room stops holding their seat. They may try to come back. */
|
|
318
|
+
export const SEAT_GONE_CLOSE_CODE = 4002;
|
|
319
|
+
/** Close code for a connection that stopped answering or fell too far behind; the SDK reconnects. */
|
|
320
|
+
export const NO_ANSWER_CLOSE_CODE = 4003;
|
|
321
|
+
/** Close code for a match switching to a version published "now"; the SDK reconnects, or the page reloads the game first. */
|
|
322
|
+
export const VERSION_UPDATE_CLOSE_CODE = 4004;
|
|
323
|
+
/** Close code (the WebSocket standard's "try again later") for rooms too busy to take the connection; the SDK tries again. */
|
|
324
|
+
export const BUSY_CLOSE_CODE = 1013;
|
|
325
|
+
/**
|
|
326
|
+
* A binary frame as the room sends it: one byte, the length of fromPlayerId in UTF-8 bytes (0 for the
|
|
327
|
+
* game server), then fromPlayerId, then the bytes.
|
|
328
|
+
*/
|
|
329
|
+
export function bytesFrame(bytes, fromPlayerId) {
|
|
330
|
+
const from = new TextEncoder().encode(fromPlayerId ?? "");
|
|
331
|
+
if (from.length > 255)
|
|
332
|
+
throw new Error("A player ID is too long to send bytes from");
|
|
333
|
+
const frame = new Uint8Array(1 + from.length + bytes.length);
|
|
334
|
+
frame[0] = from.length;
|
|
335
|
+
frame.set(from, 1);
|
|
336
|
+
frame.set(bytes, 1 + from.length);
|
|
337
|
+
return frame;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Marks a game server whose onMessage and onBytes only queue the input for its next call, like Zaisma's
|
|
341
|
+
* sandboxed real-time ones: the room hands each input over as it comes, outside the handlers waiting
|
|
342
|
+
* their turn, so ticks never wait behind input.
|
|
343
|
+
*/
|
|
344
|
+
export const QUEUES_INPUT = Symbol("queuesInput");
|
|
345
|
+
/**
|
|
346
|
+
* On a room's API object, for Zaisma's sandbox: a count that goes up each time a block or friendship
|
|
347
|
+
* between the room's players may have changed, so the sandbox is sent the pairs again only then.
|
|
348
|
+
*/
|
|
349
|
+
export const PAIR_CHANGE_COUNT = Symbol("pairChangeCount");
|
|
350
|
+
/** A room's method for a game server in a sandbox to reserve a room with the room code it already told the game. Zaisma's own: not on Room's type. */
|
|
351
|
+
export const RESERVE_WITH_ROOM_CODE = Symbol("reserveWithRoomCode");
|
|
352
|
+
/**
|
|
353
|
+
* Marks a game server that runs state sync itself, like Zaisma's sandboxed ones, whose state lives in the
|
|
354
|
+
* sandbox: its calls send their frames through the room's STATE_SYNC, and the room calls this, with the
|
|
355
|
+
* state and its API, when a round is due outside them (a player reconnecting, or catching up, in a turn-based room).
|
|
356
|
+
*/
|
|
357
|
+
export const SYNCS_STATE = Symbol("syncsState");
|
|
358
|
+
/**
|
|
359
|
+
* On a room's API object, for a game server that runs state sync itself (SYNCS_STATE): viewers() says
|
|
360
|
+
* which players are backed up and which need a full copy (taken: each is said once), and send() sends a
|
|
361
|
+
* frame to some of them.
|
|
362
|
+
*/
|
|
363
|
+
export const STATE_SYNC = Symbol("stateSync");
|
|
364
|
+
/** Messages a room's traffic counts by type: their data.type, like "move", or one of these. */
|
|
365
|
+
export const NO_MESSAGE_TYPE = "(none)";
|
|
366
|
+
export const BYTES_MESSAGE_TYPE = "(bytes)";
|
|
367
|
+
export const ZAISMA_MESSAGE_TYPE = "(zaisma)";
|
|
368
|
+
export const OTHER_MESSAGE_TYPE = "(other)";
|
|
369
|
+
/** State sync's updates (stateSyncEnabled). */
|
|
370
|
+
export const STATE_MESSAGE_TYPE = "(state)";
|
|
371
|
+
/** Message types a room counts apart in each direction; any more count as OTHER_MESSAGE_TYPE. */
|
|
372
|
+
const MAXIMUM_MESSAGE_TYPE_COUNT = 20;
|
|
373
|
+
/** A game's message type: up to 40 letters, digits and _ - . : */
|
|
374
|
+
const MESSAGE_TYPE_PATTERN = /^[A-Za-z0-9_\-.:]{1,40}$/;
|
|
375
|
+
/** No traffic yet. */
|
|
376
|
+
export function noTraffic() {
|
|
377
|
+
return { inboundSizeBytes: 0, outboundMessageCount: 0, outboundSizeBytes: 0, messageTypes: [] };
|
|
378
|
+
}
|
|
379
|
+
/** Two rooms' (or two hours') traffic added up, message types matched by type and direction. */
|
|
380
|
+
export function addTraffic(first, second) {
|
|
381
|
+
const types = new Map(first.messageTypes.map((type) => [`${type.direction} ${type.messageType}`, { ...type }]));
|
|
382
|
+
for (const type of second.messageTypes) {
|
|
383
|
+
const key = `${type.direction} ${type.messageType}`;
|
|
384
|
+
const kept = types.get(key);
|
|
385
|
+
if (!kept)
|
|
386
|
+
types.set(key, { ...type, backedUpMessageCount: type.backedUpMessageCount ?? 0 });
|
|
387
|
+
else {
|
|
388
|
+
kept.messageCount += type.messageCount;
|
|
389
|
+
kept.sizeBytes += type.sizeBytes;
|
|
390
|
+
kept.largestSizeBytes = Math.max(kept.largestSizeBytes, type.largestSizeBytes);
|
|
391
|
+
// Traffic a room kept from before backed-up sends were counted has none.
|
|
392
|
+
kept.backedUpMessageCount = (kept.backedUpMessageCount ?? 0) + (type.backedUpMessageCount ?? 0);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
inboundSizeBytes: first.inboundSizeBytes + second.inboundSizeBytes,
|
|
397
|
+
outboundMessageCount: first.outboundMessageCount + second.outboundMessageCount,
|
|
398
|
+
outboundSizeBytes: first.outboundSizeBytes + second.outboundSizeBytes,
|
|
399
|
+
messageTypes: [...types.values()],
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** A game message's type for the traffic counts: its data.type when that's a short name, else NO_MESSAGE_TYPE. */
|
|
403
|
+
function messageTypeOf(messageData) {
|
|
404
|
+
const type = messageData && typeof messageData === "object" ? messageData.type : undefined;
|
|
405
|
+
return typeof type === "string" && MESSAGE_TYPE_PATTERN.test(type) ? type : NO_MESSAGE_TYPE;
|
|
406
|
+
}
|
|
407
|
+
/** How many past players a room remembers, so they can come back to a closed room, and how many it keeps out: at least everyone a full room can hold, so blocks and friends are found among all of them. */
|
|
408
|
+
const REMEMBERED_PLAYER_COUNT = ROOM_LIMITS.maximumPlayerCount;
|
|
409
|
+
const NOBODY_JOINED = { fromRoomCode: null, isReturning: false, isSpectator: false };
|
|
410
|
+
const WATCHING = { fromRoomCode: null, isReturning: false, isSpectator: true };
|
|
411
|
+
/** Two players with a block between them, in either order (player IDs have no spaces). */
|
|
412
|
+
const pairKey = (firstPlayerId, secondPlayerId) => (firstPlayerId < secondPlayerId ? `${firstPlayerId} ${secondPlayerId}` : `${secondPlayerId} ${firstPlayerId}`);
|
|
413
|
+
/** One player's block of another: the blocker first. */
|
|
414
|
+
const blockKey = (blockerPlayerId, blockedPlayerId) => `${blockerPlayerId} ${blockedPlayerId}`;
|
|
415
|
+
export class RoomCore {
|
|
416
|
+
state;
|
|
417
|
+
players = new Map();
|
|
418
|
+
/** Spectators watching (maximumSpectatorCount): never in players, never counted as playing. */
|
|
419
|
+
spectators = new Map();
|
|
420
|
+
ended = false;
|
|
421
|
+
/** When Zaisma will end this match (room.endsAt), or null. */
|
|
422
|
+
endsAt = null;
|
|
423
|
+
/** Messages received from clients, for usage counts. */
|
|
424
|
+
messageCount = 0;
|
|
425
|
+
/** The rest of the room's traffic since takeTraffic, by "<direction> <type>". */
|
|
426
|
+
traffic = { inboundSizeBytes: 0, outboundMessageCount: 0, outboundSizeBytes: 0, messageTypes: new Map(), inboundTypeCount: 0, outboundTypeCount: 0 };
|
|
427
|
+
/** Goes up whenever a block or friendship between players may have changed (PAIR_CHANGE_COUNT). */
|
|
428
|
+
pairChangeCount = 0;
|
|
429
|
+
/** Set when the state changed and a sleeping room should save it. */
|
|
430
|
+
dirty = false;
|
|
431
|
+
/** A version published "now" this match is moving to, once the transport started it (startVersionUpdate). */
|
|
432
|
+
versionUpdate = null;
|
|
433
|
+
/** When a real-time room's state was last saved, set by the transport after each save; null until it has been. */
|
|
434
|
+
savedAtMs = null;
|
|
435
|
+
/** Hidden from room lists until its first player's hello asks for it (joinRoom's isListed, quickJoin), or a reserve does. */
|
|
436
|
+
isListed = false;
|
|
437
|
+
isOpen = true;
|
|
438
|
+
friendJoinsEnabled = true;
|
|
439
|
+
name = null;
|
|
440
|
+
labels = {};
|
|
441
|
+
/** Kept (room.setKept): the transport keeps its state when it empties. */
|
|
442
|
+
isKept = false;
|
|
443
|
+
/**
|
|
444
|
+
* Set when the player count, isListed, isOpen or isKept changed, so the room list can be brought up to date,
|
|
445
|
+
* and when the room's blocks changed, since a sleeping room's saved room is written with its listing.
|
|
446
|
+
*/
|
|
447
|
+
listingChanged = false;
|
|
448
|
+
/** Players who have been in this room, newest last: they may come back after it closes. */
|
|
449
|
+
known = new Set();
|
|
450
|
+
/** Players kicked and kept out for the rest of the match, with the message they were given. */
|
|
451
|
+
keptOut = new Map();
|
|
452
|
+
/** Players other rooms are sending here (room.moveTo), with a seat each until they arrive. */
|
|
453
|
+
expected = new Map();
|
|
454
|
+
/** Seats held for players out of the match (walked away, or lost their connection) until they come back or it runs out. */
|
|
455
|
+
held = new Map();
|
|
456
|
+
/** Players who walked away that onAway kept in the room, and players whose connection dropped (lost), and why. */
|
|
457
|
+
kept = new Map();
|
|
458
|
+
/** Players whose connection dropped (kept as "lost"): when their time to reconnect runs out. */
|
|
459
|
+
reconnectUntilMs = new Map();
|
|
460
|
+
/** The room that made this one, where awayMode "lobby" sends players who walk away. */
|
|
461
|
+
lobbyRoomCode = null;
|
|
462
|
+
/** Blocks between players from their accounts (Zaisma's own record, or passes on your own server), either way: pairKey(first, second). */
|
|
463
|
+
accountBlocks = new Set();
|
|
464
|
+
/** Blocks players' games said they made, a guest's or one just made: blockKey(blocker, blocked). */
|
|
465
|
+
sessionBlocks = new Set();
|
|
466
|
+
/** Friendships between players from their accounts (Zaisma's own record, or passes on your own server): pairKey(first, second). */
|
|
467
|
+
friendships = new Set();
|
|
468
|
+
/** The blocks each player was last told of, so they're told again only when theirs change. */
|
|
469
|
+
toldBlocks = new Map();
|
|
470
|
+
/** When each player in the match was last heard from, when their game was hidden, and the idle check their game asked for. */
|
|
471
|
+
presence = new Map();
|
|
472
|
+
/** Players this room is sending to its lobby for walking away, so onMoved doesn't hear of it. */
|
|
473
|
+
sentAway = new Set();
|
|
474
|
+
/** What a reserved room started with, kept so a room that starts over starts the same way. */
|
|
475
|
+
setupData = null;
|
|
476
|
+
/** When this room last reserved rooms, for the limit. */
|
|
477
|
+
reservedAtMs = [];
|
|
478
|
+
buckets = new Map();
|
|
479
|
+
/** Probes this room has sent, to every player: each probe's number. */
|
|
480
|
+
probeCount = 0;
|
|
481
|
+
/** What each player was last sent of the state (stateSyncEnabled), unless the game server runs state sync itself (SYNCS_STATE). */
|
|
482
|
+
stateTracker;
|
|
483
|
+
/** For a game server that runs state sync itself: players with a new connection, who need a full copy. */
|
|
484
|
+
fullCopyPlayerIds = new Set();
|
|
485
|
+
/** A turn-based room's players state sync skipped as backed up, who get a full copy once they catch up. */
|
|
486
|
+
stateSkippedPlayerIds = new Set();
|
|
487
|
+
/** Latest-only messages waiting for each backed-up player, by "<kind>:<latestKey>", least recently replaced first. */
|
|
488
|
+
latestSlots = new Map();
|
|
489
|
+
now;
|
|
490
|
+
maximumPlayerCount;
|
|
491
|
+
maximumSpectatorCount;
|
|
492
|
+
api;
|
|
493
|
+
/** The room as canJoin and stateView see it: reading works, and any call that changes or sends throws room_call_unavailable. */
|
|
494
|
+
canJoinRoom;
|
|
495
|
+
stateViewRoom;
|
|
496
|
+
server;
|
|
497
|
+
transport;
|
|
498
|
+
/**
|
|
499
|
+
* Set once a handler returns a promise (a game server in a sandbox): from then on handlers run
|
|
500
|
+
* one after another, each with the state the last one left.
|
|
501
|
+
*/
|
|
502
|
+
queue = null;
|
|
503
|
+
/** Handlers waiting in the queue, so ticks don't pile up behind a slow game server. */
|
|
504
|
+
waitingCount = 0;
|
|
505
|
+
/** Handlers running right now, and what waits for them to finish (a kicked player's onLeave). */
|
|
506
|
+
depth = 0;
|
|
507
|
+
afterHandler = [];
|
|
508
|
+
/**
|
|
509
|
+
* startOptions.start makes a reserved room: closed, open only to the players it was made for, and
|
|
510
|
+
* set up with its setup data. A saved room (startOptions.room) wakes as it was, with
|
|
511
|
+
* startOptions.presence saying when each of its players was last heard from and the idle check
|
|
512
|
+
* their game asked for. A restarted real-time room (startOptions.restoreInfo, with its saved state)
|
|
513
|
+
* skips setup: startOptions.lostPlayers, the players in its match at the save who aren't connected,
|
|
514
|
+
* come back as away (reason "lost"), and onRestored runs once. startOptions.spectators are spectators
|
|
515
|
+
* still connected to a room waking from sleep.
|
|
516
|
+
*/
|
|
517
|
+
constructor(server, transport, startOptions = {}) {
|
|
518
|
+
this.server = server;
|
|
519
|
+
this.transport = transport;
|
|
520
|
+
this.now = startOptions.now ?? Date.now;
|
|
521
|
+
this.maximumPlayerCount = server.maximumPlayerCount ?? ROOM_LIMITS.defaultMaximumPlayerCount;
|
|
522
|
+
this.maximumSpectatorCount = server.maximumSpectatorCount ?? 0;
|
|
523
|
+
this.stateTracker = server.stateSyncEnabled && !server[SYNCS_STATE] ? newStateTracker(ROOM_LIMITS.maximumStateViewSizeBytes) : null;
|
|
524
|
+
for (const spectator of startOptions.spectators ?? []) {
|
|
525
|
+
this.spectators.set(spectator.id, spectator);
|
|
526
|
+
this.presence.set(spectator.id, newPresence(this.now(), {}));
|
|
527
|
+
}
|
|
528
|
+
for (const player of startOptions.players ?? []) {
|
|
529
|
+
this.players.set(player.id, player);
|
|
530
|
+
const was = startOptions.presence?.[player.id];
|
|
531
|
+
this.presence.set(player.id, newPresence(was?.seenAtMs ?? this.now(), clientIdle(was?.clientIdle)));
|
|
532
|
+
}
|
|
533
|
+
const saved = startOptions.room;
|
|
534
|
+
if (saved) {
|
|
535
|
+
this.isListed = saved.isListed !== false;
|
|
536
|
+
this.isOpen = saved.isOpen !== false;
|
|
537
|
+
this.friendJoinsEnabled = saved.friendJoinsEnabled !== false;
|
|
538
|
+
this.name = saved.name ?? null;
|
|
539
|
+
this.labels = saved.labels ?? {};
|
|
540
|
+
for (const id of saved.knownPlayerIds ?? [])
|
|
541
|
+
this.known.add(id);
|
|
542
|
+
this.setupData = saved.setupData ?? null;
|
|
543
|
+
this.keepOut(saved.keptOut);
|
|
544
|
+
for (const [id, expected] of Object.entries(saved.expected ?? {}))
|
|
545
|
+
this.expected.set(id, expected);
|
|
546
|
+
for (const [id, held] of Object.entries(saved.held ?? {}))
|
|
547
|
+
if (!this.players.has(id))
|
|
548
|
+
this.held.set(id, held);
|
|
549
|
+
// A room waking with its state keeps players whose connection dropped: they have no connection to be found by.
|
|
550
|
+
if (startOptions.state !== undefined) {
|
|
551
|
+
for (const [id, reconnecting] of Object.entries(saved.reconnecting ?? {})) {
|
|
552
|
+
if (this.players.has(id))
|
|
553
|
+
continue;
|
|
554
|
+
this.players.set(id, reconnecting.player);
|
|
555
|
+
this.presence.set(id, newPresence(this.now(), {}));
|
|
556
|
+
this.kept.set(id, "lost");
|
|
557
|
+
this.reconnectUntilMs.set(id, reconnecting.untilMs);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const [id, reason] of Object.entries(saved.kept ?? {}))
|
|
561
|
+
if (this.players.has(id) && !this.kept.has(id) && reason !== "lost")
|
|
562
|
+
this.kept.set(id, reason);
|
|
563
|
+
this.lobbyRoomCode = saved.lobbyRoomCode ?? null;
|
|
564
|
+
this.endsAt = saved.endsAt ?? null;
|
|
565
|
+
this.isKept = saved.isKept === true;
|
|
566
|
+
for (const [first, second] of saved.accountBlocks ?? [])
|
|
567
|
+
this.accountBlocks.add(pairKey(first, second));
|
|
568
|
+
for (const [blocker, blocked] of saved.sessionBlocks ?? [])
|
|
569
|
+
this.sessionBlocks.add(blockKey(blocker, blocked));
|
|
570
|
+
for (const [first, second] of saved.friendships ?? [])
|
|
571
|
+
this.friendships.add(pairKey(first, second));
|
|
572
|
+
}
|
|
573
|
+
else if (startOptions.start) {
|
|
574
|
+
const start = startOptions.start;
|
|
575
|
+
this.isOpen = false;
|
|
576
|
+
this.isListed = start.isListed;
|
|
577
|
+
this.friendJoinsEnabled = start.friendJoinsEnabled !== false;
|
|
578
|
+
this.name = start.name;
|
|
579
|
+
this.labels = start.labels;
|
|
580
|
+
for (const id of start.playerIds)
|
|
581
|
+
this.known.add(id);
|
|
582
|
+
this.setupData = start.setupData ?? null;
|
|
583
|
+
this.keepOut(start.keptOut);
|
|
584
|
+
this.lobbyRoomCode = start.lobbyRoomCode ?? null;
|
|
585
|
+
this.isKept = start.isKept === true;
|
|
586
|
+
}
|
|
587
|
+
// A restarted room's players who aren't connected: in the match until leave() below makes them away, as a dropped connection does.
|
|
588
|
+
const restoreInfo = startOptions.state !== undefined ? startOptions.restoreInfo : undefined;
|
|
589
|
+
const lostPlayers = restoreInfo ? (startOptions.lostPlayers ?? []).filter((player) => !startOptions.players?.some((connected) => connected.id === player.id) && !this.held.has(player.id)) : [];
|
|
590
|
+
for (const player of lostPlayers) {
|
|
591
|
+
this.players.set(player.id, player);
|
|
592
|
+
this.presence.set(player.id, newPresence(this.now(), {}));
|
|
593
|
+
this.kept.delete(player.id);
|
|
594
|
+
this.reconnectUntilMs.delete(player.id);
|
|
595
|
+
}
|
|
596
|
+
for (const player of this.players.values())
|
|
597
|
+
this.remember(player.id);
|
|
598
|
+
// A room waking up: its players were told their blocks before it slept.
|
|
599
|
+
for (const id of this.players.keys())
|
|
600
|
+
this.toldBlocks.set(id, JSON.stringify(this.blockedWith(id)));
|
|
601
|
+
if (startOptions.state !== undefined)
|
|
602
|
+
this.state = startOptions.state;
|
|
603
|
+
else {
|
|
604
|
+
if (typeof server.setup !== "function")
|
|
605
|
+
throw noSetupError();
|
|
606
|
+
const first = server.setup(this.setupData);
|
|
607
|
+
if (isPromise(first)) {
|
|
608
|
+
this.state = undefined;
|
|
609
|
+
this.run(() => first, "setup");
|
|
610
|
+
}
|
|
611
|
+
else
|
|
612
|
+
this.state = first;
|
|
613
|
+
}
|
|
614
|
+
const core = this;
|
|
615
|
+
this.api = {
|
|
616
|
+
roomCode: transport.roomCode,
|
|
617
|
+
get players() {
|
|
618
|
+
return [...core.players.values()];
|
|
619
|
+
},
|
|
620
|
+
get spectators() {
|
|
621
|
+
return [...core.spectators.values()];
|
|
622
|
+
},
|
|
623
|
+
broadcast: (message, broadcastOptions) => {
|
|
624
|
+
const checked = checks.checkBroadcast(message, broadcastOptions);
|
|
625
|
+
this.deliver(this.audienceOf(checked.audience).filter((id) => id !== checked.exceptPlayerId), { text: JSON.stringify({ type: "message", data: message, fromPlayerId: checked.fromPlayerId }), messageType: messageTypeOf(message) }, checked.delivery);
|
|
626
|
+
},
|
|
627
|
+
send: (playerId, message, sendOptions) => {
|
|
628
|
+
const checked = checks.checkSend(playerId, message, sendOptions);
|
|
629
|
+
const text = JSON.stringify({ type: "message", data: message, fromPlayerId: checked.fromPlayerId });
|
|
630
|
+
if (this.isViewer(checked.playerId))
|
|
631
|
+
this.deliver([checked.playerId], { text, messageType: messageTypeOf(message) }, checked.delivery);
|
|
632
|
+
},
|
|
633
|
+
broadcastBytes: (bytes, broadcastOptions) => {
|
|
634
|
+
const checked = checks.checkBroadcastBytes(bytes, broadcastOptions);
|
|
635
|
+
this.deliver(this.audienceOf(checked.audience).filter((id) => id !== checked.exceptPlayerId), { frame: bytesFrame(checked.bytes, checked.fromPlayerId), messageType: BYTES_MESSAGE_TYPE }, checked.delivery);
|
|
636
|
+
},
|
|
637
|
+
sendBytes: (playerId, bytes, sendOptions) => {
|
|
638
|
+
const checked = checks.checkSendBytes(playerId, bytes, sendOptions);
|
|
639
|
+
if (this.isViewer(checked.playerId))
|
|
640
|
+
this.deliver([checked.playerId], { frame: bytesFrame(checked.bytes, checked.fromPlayerId), messageType: BYTES_MESSAGE_TYPE }, checked.delivery);
|
|
641
|
+
},
|
|
642
|
+
end: (message) => this.end(checks.checkEnd(message) ?? "The match is over.", "game"),
|
|
643
|
+
get endsAt() {
|
|
644
|
+
return core.endsAt;
|
|
645
|
+
},
|
|
646
|
+
get isListed() {
|
|
647
|
+
return core.isListed;
|
|
648
|
+
},
|
|
649
|
+
setListed: (isListed) => this.settle({ isListed: Boolean(isListed) }),
|
|
650
|
+
get isOpen() {
|
|
651
|
+
return core.isOpen;
|
|
652
|
+
},
|
|
653
|
+
setOpen: (isOpen) => this.settle({ isOpen: Boolean(isOpen) }),
|
|
654
|
+
get friendJoinsEnabled() {
|
|
655
|
+
return core.friendJoinsEnabled;
|
|
656
|
+
},
|
|
657
|
+
setFriendJoinsEnabled: (isEnabled) => this.settle({ friendJoinsEnabled: Boolean(isEnabled) }),
|
|
658
|
+
get isKept() {
|
|
659
|
+
return core.isKept;
|
|
660
|
+
},
|
|
661
|
+
setKept: (isKept) => this.setKept(Boolean(isKept)),
|
|
662
|
+
get name() {
|
|
663
|
+
return core.name;
|
|
664
|
+
},
|
|
665
|
+
get labels() {
|
|
666
|
+
return { ...core.labels };
|
|
667
|
+
},
|
|
668
|
+
setInfo: (nameAndLabels) => this.settle(checks.checkSetInfo(nameAndLabels)),
|
|
669
|
+
reserve: (reserveOptions) => this.reserve(reserveOptions, null),
|
|
670
|
+
kick: (playerId, kickOptions) => {
|
|
671
|
+
const checked = checks.checkKick(playerId, kickOptions);
|
|
672
|
+
this.kick(checked.playerId, { message: checked.message, keepOut: checked.isKeptOut });
|
|
673
|
+
},
|
|
674
|
+
moveTo: (toRoomCode, moveOptions) => this.moveTo(toRoomCode, moveOptions),
|
|
675
|
+
database: transport.database ?? NO_DATABASE,
|
|
676
|
+
secrets: transport.secrets ?? NO_SECRETS,
|
|
677
|
+
verifyReceipt: transport.verifyReceipt ?? NO_RECEIPT_CHECK,
|
|
678
|
+
hasBlockBetween: (firstPlayerId, secondPlayerId) => this.hasBlockBetween(firstPlayerId, secondPlayerId),
|
|
679
|
+
areFriends: (firstPlayerId, secondPlayerId) => this.areFriends(firstPlayerId, secondPlayerId),
|
|
680
|
+
pingDurationMs: (playerId) => this.presence.get(playerId)?.pingDurationMs ?? null,
|
|
681
|
+
serverTimeMs: () => this.now(),
|
|
682
|
+
awayReason: (playerId) => (this.players.has(playerId) ? (this.kept.get(playerId) ?? null) : null),
|
|
683
|
+
applyVersionUpdate: () => {
|
|
684
|
+
if (this.versionUpdate)
|
|
685
|
+
this.soon(() => this.transport.applyVersionUpdate?.());
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
// A game server in a sandbox picks the room code itself, since it tells the game at once.
|
|
689
|
+
Object.defineProperty(this.api, RESERVE_WITH_ROOM_CODE, { value: (reserveOptions, newRoomCode) => this.reserve(reserveOptions, newRoomCode) });
|
|
690
|
+
Object.defineProperty(this.api, PAIR_CHANGE_COUNT, { get: () => this.pairChangeCount });
|
|
691
|
+
Object.defineProperty(this.api, STATE_SYNC, { value: { viewers: () => this.stateViewers(), send: (playerIds, frame) => this.sendStateFrom(playerIds, frame) } });
|
|
692
|
+
this.canJoinRoom = readOnlyRoom(this.api, "canJoin");
|
|
693
|
+
this.stateViewRoom = readOnlyRoom(this.api, "stateView");
|
|
694
|
+
if (restoreInfo)
|
|
695
|
+
this.restored(restoreInfo, lostPlayers);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* A restarted room's players who aren't connected are away as lost, with the whole reconnectTimeoutSec
|
|
699
|
+
* from now, whatever they had before the restart; then onRestored runs, and onAway for each.
|
|
700
|
+
*/
|
|
701
|
+
restored(restoreInfo, lostPlayers) {
|
|
702
|
+
const reconnectTimeoutSec = this.server.reconnectTimeoutSec ?? ROOM_LIMITS.defaultReconnectTimeoutSec;
|
|
703
|
+
if (reconnectTimeoutSec > 0) {
|
|
704
|
+
for (const player of lostPlayers) {
|
|
705
|
+
this.kept.set(player.id, "lost");
|
|
706
|
+
this.reconnectUntilMs.set(player.id, this.now() + reconnectTimeoutSec * 1000);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
this.run(() => this.server.onRestored?.(this.state, restoreInfo, this.api), "onRestored");
|
|
710
|
+
for (const player of lostPlayers) {
|
|
711
|
+
// With reconnectTimeoutSec 0, they leave at once, as a dropped connection does.
|
|
712
|
+
if (reconnectTimeoutSec === 0)
|
|
713
|
+
this.leave(player.id, "lost");
|
|
714
|
+
else if (this.server.onAway)
|
|
715
|
+
this.run(() => this.server.onAway(this.state, player, this.api, { reason: "lost" }), "onAway");
|
|
716
|
+
}
|
|
717
|
+
this.listingChanged = true;
|
|
718
|
+
this.dirty = true;
|
|
719
|
+
this.broadcastPlayers();
|
|
720
|
+
}
|
|
721
|
+
/** What a sleeping room saves besides the game's state. */
|
|
722
|
+
get saved() {
|
|
723
|
+
this.countHeldSeats();
|
|
724
|
+
return {
|
|
725
|
+
...this.settings(),
|
|
726
|
+
knownPlayerIds: [...this.known],
|
|
727
|
+
...(this.setupData !== null ? { setupData: this.setupData } : {}),
|
|
728
|
+
...(this.keptOut.size ? { keptOut: Object.fromEntries(this.keptOut) } : {}),
|
|
729
|
+
...(this.expected.size ? { expected: Object.fromEntries(this.expected) } : {}),
|
|
730
|
+
...(this.held.size ? { held: Object.fromEntries(this.held) } : {}),
|
|
731
|
+
...(this.kept.size ? { kept: Object.fromEntries(this.kept) } : {}),
|
|
732
|
+
...(this.reconnectUntilMs.size ? { reconnecting: Object.fromEntries([...this.reconnectUntilMs].map(([id, untilMs]) => [id, { player: this.players.get(id), untilMs }])) } : {}),
|
|
733
|
+
...(this.lobbyRoomCode ? { lobbyRoomCode: this.lobbyRoomCode } : {}),
|
|
734
|
+
...(this.endsAt ? { endsAt: this.endsAt } : {}),
|
|
735
|
+
...(this.isKept ? { isKept: true } : {}),
|
|
736
|
+
...this.savedBlocks(),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/** The blocks worth keeping while the room sleeps: those between players it still remembers. */
|
|
740
|
+
savedBlocks() {
|
|
741
|
+
const remembered = (key) => key.split(" ").every((id) => this.known.has(id));
|
|
742
|
+
const accountBlocks = [...this.accountBlocks].filter(remembered).map((key) => key.split(" "));
|
|
743
|
+
const sessionBlocks = [...this.sessionBlocks].filter(remembered).map((key) => key.split(" "));
|
|
744
|
+
const friendships = [...this.friendships].filter(remembered).map((key) => key.split(" "));
|
|
745
|
+
return { ...(accountBlocks.length ? { accountBlocks } : {}), ...(sessionBlocks.length ? { sessionBlocks } : {}), ...(friendships.length ? { friendships } : {}) };
|
|
746
|
+
}
|
|
747
|
+
/** Whether two players are friends, from their accounts as the later of them joined. */
|
|
748
|
+
areFriends(firstPlayerId, secondPlayerId) {
|
|
749
|
+
return firstPlayerId !== secondPlayerId && this.friendships.has(pairKey(firstPlayerId, secondPlayerId));
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* A player's friends among the others this room remembers, from their accounts, as the room found them
|
|
753
|
+
* as the player joined (Zaisma's own record, or the passes on your own server): they replace what the
|
|
754
|
+
* room knew of that player's, so a friend removed since is gone. Call before join.
|
|
755
|
+
*/
|
|
756
|
+
setFriends(playerId, friendPlayerIds) {
|
|
757
|
+
let isChanged = false;
|
|
758
|
+
for (const key of [...this.friendships]) {
|
|
759
|
+
if (!key.split(" ").includes(playerId))
|
|
760
|
+
continue;
|
|
761
|
+
this.friendships.delete(key);
|
|
762
|
+
isChanged = true;
|
|
763
|
+
}
|
|
764
|
+
for (const otherPlayerId of friendPlayerIds) {
|
|
765
|
+
if (otherPlayerId === playerId)
|
|
766
|
+
continue;
|
|
767
|
+
this.friendships.add(pairKey(playerId, otherPlayerId));
|
|
768
|
+
isChanged = true;
|
|
769
|
+
}
|
|
770
|
+
if (!isChanged)
|
|
771
|
+
return;
|
|
772
|
+
this.dirty = true;
|
|
773
|
+
this.pairChangeCount++;
|
|
774
|
+
}
|
|
775
|
+
/** Whether two players have a block between them, either way: from their accounts, or one a player's game said it made. */
|
|
776
|
+
hasBlockBetween(firstPlayerId, secondPlayerId) {
|
|
777
|
+
if (firstPlayerId === secondPlayerId)
|
|
778
|
+
return false;
|
|
779
|
+
return this.accountBlocks.has(pairKey(firstPlayerId, secondPlayerId)) || this.sessionBlocks.has(blockKey(firstPlayerId, secondPlayerId)) || this.sessionBlocks.has(blockKey(secondPlayerId, firstPlayerId));
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* The blocks between a player and the others this room remembers, from their accounts, as the room
|
|
783
|
+
* found them as the player joined (Zaisma's own record, or the passes on your own server): they replace
|
|
784
|
+
* what the room knew of that player's, so a block taken back since is gone. Call before join.
|
|
785
|
+
*/
|
|
786
|
+
setAccountBlocks(playerId, blockPlayerIds) {
|
|
787
|
+
let isChanged = false;
|
|
788
|
+
for (const key of [...this.accountBlocks]) {
|
|
789
|
+
if (!key.split(" ").includes(playerId))
|
|
790
|
+
continue;
|
|
791
|
+
this.accountBlocks.delete(key);
|
|
792
|
+
isChanged = true;
|
|
793
|
+
}
|
|
794
|
+
for (const otherPlayerId of blockPlayerIds) {
|
|
795
|
+
if (otherPlayerId === playerId)
|
|
796
|
+
continue;
|
|
797
|
+
this.accountBlocks.add(pairKey(playerId, otherPlayerId));
|
|
798
|
+
isChanged = true;
|
|
799
|
+
}
|
|
800
|
+
if (!isChanged)
|
|
801
|
+
return;
|
|
802
|
+
this.dirty = true;
|
|
803
|
+
this.listingChanged = true;
|
|
804
|
+
this.pairChangeCount++;
|
|
805
|
+
}
|
|
806
|
+
/** The players in the room this one has a block with, either way. */
|
|
807
|
+
blockedWith(playerId) {
|
|
808
|
+
return [...this.players.keys()].filter((otherPlayerId) => this.hasBlockBetween(playerId, otherPlayerId));
|
|
809
|
+
}
|
|
810
|
+
/** Tell each player the players in the room they have a block with, when that changed. */
|
|
811
|
+
tellBlocks() {
|
|
812
|
+
for (const id of this.players.keys()) {
|
|
813
|
+
const playerIds = this.blockedWith(id);
|
|
814
|
+
const told = JSON.stringify(playerIds);
|
|
815
|
+
if ((this.toldBlocks.get(id) ?? "[]") === told)
|
|
816
|
+
continue;
|
|
817
|
+
this.toldBlocks.set(id, told);
|
|
818
|
+
this.sendText(id, JSON.stringify({ type: "blocks", playerIds }));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/** A player's game blocked a player in the room (a guest's block, or one just made), or took one back. */
|
|
822
|
+
sessionBlock(playerId, message, isBlocking) {
|
|
823
|
+
const otherPlayerId = message.playerId;
|
|
824
|
+
if (typeof otherPlayerId !== "string" || otherPlayerId === playerId || !this.known.has(otherPlayerId))
|
|
825
|
+
return "bad_message";
|
|
826
|
+
const key = blockKey(playerId, otherPlayerId);
|
|
827
|
+
if (isBlocking === this.sessionBlocks.has(key))
|
|
828
|
+
return null;
|
|
829
|
+
if (isBlocking)
|
|
830
|
+
this.sessionBlocks.add(key);
|
|
831
|
+
else
|
|
832
|
+
this.sessionBlocks.delete(key);
|
|
833
|
+
this.pairChangeCount++;
|
|
834
|
+
this.dirty = true;
|
|
835
|
+
this.listingChanged = true;
|
|
836
|
+
this.tellBlocks();
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
/** Players in the match who haven't walked away: what room lists and "playing now" count. */
|
|
840
|
+
get playingCount() {
|
|
841
|
+
return this.players.size - this.kept.size;
|
|
842
|
+
}
|
|
843
|
+
/** Seats the room holds for players on their way (room.moveTo) or out of the match for now. */
|
|
844
|
+
get heldSeatCount() {
|
|
845
|
+
return this.countHeldSeats();
|
|
846
|
+
}
|
|
847
|
+
/** Resolves once every handler started so far has finished (at once for game servers that answer at once). */
|
|
848
|
+
async settled() {
|
|
849
|
+
while (this.queue) {
|
|
850
|
+
const queued = this.queue;
|
|
851
|
+
await queued;
|
|
852
|
+
if (this.queue === queued)
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
get ticking() {
|
|
857
|
+
return this.server.ticksPerSec !== undefined;
|
|
858
|
+
}
|
|
859
|
+
/** How many are watching. */
|
|
860
|
+
get spectatorCount() {
|
|
861
|
+
return this.spectators.size;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Whether a player may come in: the room's own rules (over, kept out, closed, full), then the game
|
|
865
|
+
* server's canJoin with the join data the player brought. Call before join; join checks the room's rules again.
|
|
866
|
+
* admitOptions.isSpectator: they come to watch, which a closed or full room still takes (spectatorDoor).
|
|
867
|
+
*/
|
|
868
|
+
async admit(player, joinData = null, admitOptions = {}) {
|
|
869
|
+
if (admitOptions.isSpectator) {
|
|
870
|
+
const spectatorDoor = this.spectatorDoor(player.id);
|
|
871
|
+
if (spectatorDoor)
|
|
872
|
+
return spectatorDoor;
|
|
873
|
+
// A spectator reconnecting doesn't ask again.
|
|
874
|
+
return this.spectators.has(player.id) ? { isAllowed: true } : this.ask(player, joinData, WATCHING);
|
|
875
|
+
}
|
|
876
|
+
const door = this.door(player.id);
|
|
877
|
+
if (door)
|
|
878
|
+
return door;
|
|
879
|
+
// Players already in, players another room sent (asked when it sent them) and players coming back to a held seat don't ask again.
|
|
880
|
+
if (this.players.has(player.id) || this.seat(player.id) || this.holding(player.id))
|
|
881
|
+
return { isAllowed: true };
|
|
882
|
+
return this.ask(player, joinData, NOBODY_JOINED);
|
|
883
|
+
}
|
|
884
|
+
/** The game server's canJoin, then the room's rules again, since things may change while it decides. */
|
|
885
|
+
async ask(player, joinData, joinInfo) {
|
|
886
|
+
const isMoving = joinInfo.fromRoomCode !== null;
|
|
887
|
+
const door = () => (joinInfo.isSpectator ? this.spectatorDoor(player.id) : this.door(player.id, isMoving));
|
|
888
|
+
if (!this.server.canJoin)
|
|
889
|
+
return door() ?? { isAllowed: true };
|
|
890
|
+
await this.settled();
|
|
891
|
+
let answer;
|
|
892
|
+
try {
|
|
893
|
+
answer = await this.server.canJoin(this.state, player, this.canJoinRoom, joinData, joinInfo);
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
console.error("Game server error:", error);
|
|
897
|
+
return { isAllowed: false, code: "refused" };
|
|
898
|
+
}
|
|
899
|
+
const read = readJoinAnswer(answer);
|
|
900
|
+
if ("mistake" in read) {
|
|
901
|
+
console.error("Game server error:", read.mistake);
|
|
902
|
+
return { isAllowed: false, code: "refused" };
|
|
903
|
+
}
|
|
904
|
+
if (!read.isAllowed) {
|
|
905
|
+
const message = checks.tidy(read.refusalMessage, ROOM_LIMITS.maximumRefusalMessageCharacterCount);
|
|
906
|
+
return { isAllowed: false, code: "refused", ...(message ? { message } : {}) };
|
|
907
|
+
}
|
|
908
|
+
return door() ?? { isAllowed: true };
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* The room's own rules for a player coming in, or null if they may. A player already in may always
|
|
912
|
+
* come back, and a player another room sent, or whose seat is held, has a seat. isMoving: the game is
|
|
913
|
+
* sending this player from another room, which a closed room still takes (closed keeps out
|
|
914
|
+
* strangers, not the game).
|
|
915
|
+
*/
|
|
916
|
+
door(id, isMoving = false) {
|
|
917
|
+
if (this.ended)
|
|
918
|
+
return { isAllowed: false, code: "ended" };
|
|
919
|
+
const keptOutMessage = this.keptOut.get(id);
|
|
920
|
+
if (keptOutMessage !== undefined)
|
|
921
|
+
return { isAllowed: false, code: "kept_out", message: keptOutMessage };
|
|
922
|
+
if (this.players.has(id) || this.seat(id) || this.holding(id))
|
|
923
|
+
return null;
|
|
924
|
+
if (!isMoving && !this.isOpen && !this.known.has(id))
|
|
925
|
+
return { isAllowed: false, code: "room_closed" };
|
|
926
|
+
if (this.players.size + this.countHeldSeats() >= this.maximumPlayerCount)
|
|
927
|
+
return { isAllowed: false, code: "room_full" };
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* The room's own rules for a spectator coming in, or null if they may: a match that's over, and a player
|
|
932
|
+
* kept out, keep them out, and so does a full audience; a closed or full room doesn't, since watching a
|
|
933
|
+
* match under way is the point. A player in the match can't watch it too.
|
|
934
|
+
*/
|
|
935
|
+
spectatorDoor(id) {
|
|
936
|
+
if (this.ended)
|
|
937
|
+
return { isAllowed: false, code: "ended" };
|
|
938
|
+
const keptOutMessage = this.keptOut.get(id);
|
|
939
|
+
if (keptOutMessage !== undefined)
|
|
940
|
+
return { isAllowed: false, code: "kept_out", message: keptOutMessage };
|
|
941
|
+
if (this.players.has(id))
|
|
942
|
+
return { isAllowed: false, code: "already_playing" };
|
|
943
|
+
if (this.spectators.has(id))
|
|
944
|
+
return null;
|
|
945
|
+
if (this.spectators.size >= this.maximumSpectatorCount)
|
|
946
|
+
return { isAllowed: false, code: "spectators_full" };
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
/** Whether this room is holding a place for a player: they've been in it, another room is sending them, or their seat is held. */
|
|
950
|
+
expects(id) {
|
|
951
|
+
return this.known.has(id) || this.seat(id) !== null || this.holding(id) !== null;
|
|
952
|
+
}
|
|
953
|
+
/** The seat held for a player out of the match, if it hasn't run out. */
|
|
954
|
+
holding(id) {
|
|
955
|
+
const held = this.held.get(id);
|
|
956
|
+
if (held && held.untilMs > this.now())
|
|
957
|
+
return held;
|
|
958
|
+
if (held) {
|
|
959
|
+
this.held.delete(id);
|
|
960
|
+
this.listingChanged = true;
|
|
961
|
+
this.dirty = true;
|
|
962
|
+
}
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
/** The seat held for a player another room is sending, if it hasn't run out. */
|
|
966
|
+
seat(id) {
|
|
967
|
+
const expected = this.expected.get(id);
|
|
968
|
+
if (expected && expected.untilMs > this.now())
|
|
969
|
+
return expected;
|
|
970
|
+
if (expected)
|
|
971
|
+
this.expected.delete(id);
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
/** Seats held for players other rooms are sending and players out of the match, dropping those that ran out. */
|
|
975
|
+
countHeldSeats() {
|
|
976
|
+
let heldSeatCount = 0;
|
|
977
|
+
for (const id of [...this.expected.keys()])
|
|
978
|
+
if (!this.players.has(id) && this.seat(id))
|
|
979
|
+
heldSeatCount++;
|
|
980
|
+
for (const id of [...this.held.keys()])
|
|
981
|
+
if (this.holding(id))
|
|
982
|
+
heldSeatCount++;
|
|
983
|
+
return heldSeatCount;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Another room of the game wants to send players here (its room.moveTo). Each is checked like a
|
|
987
|
+
* newcomer, with the room's rules and canJoin given the sending room's join data; those taken get a
|
|
988
|
+
* seat for a minute. Says who was taken and who wasn't, and why.
|
|
989
|
+
*/
|
|
990
|
+
async expect(ask) {
|
|
991
|
+
const answer = { movedPlayerIds: [], refusals: [] };
|
|
992
|
+
const checked = checkRoomData(ask.joinData, ROOM_LIMITS.maximumJoinDataSizeBytes);
|
|
993
|
+
const joinData = checked.isValid ? checked.checkedData : null;
|
|
994
|
+
for (const player of ask.players) {
|
|
995
|
+
const verdict = this.players.has(player.id)
|
|
996
|
+
? { isAllowed: false, code: "bad_message", message: "Already in that room." }
|
|
997
|
+
: await this.ask(player, joinData, { fromRoomCode: ask.fromRoomCode, isReturning: false, isSpectator: false });
|
|
998
|
+
if (verdict.isAllowed) {
|
|
999
|
+
this.expected.set(player.id, { joinData, fromRoomCode: ask.fromRoomCode, untilMs: this.now() + SEAT_MS });
|
|
1000
|
+
answer.movedPlayerIds.push(player.id);
|
|
1001
|
+
}
|
|
1002
|
+
else
|
|
1003
|
+
answer.refusals.push({ playerId: player.id, code: verdict.code, message: verdict.message || ERROR_TEXT[verdict.code] });
|
|
1004
|
+
}
|
|
1005
|
+
if (answer.movedPlayerIds.length)
|
|
1006
|
+
this.dirty = true;
|
|
1007
|
+
return answer;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* A player arrives. Joining again with the same ID (a reconnect) keeps their place. The player who
|
|
1011
|
+
* makes the room can hide it from the start (a private match), turn friends' Join off, and name and label it. joinData is
|
|
1012
|
+
* what the player's game sent along, for onJoin; clientIdle is the idle check it asked for (idleCheck "client").
|
|
1013
|
+
* isSpectator: they come to watch (watch).
|
|
1014
|
+
*/
|
|
1015
|
+
join(player, joinOptions = {}) {
|
|
1016
|
+
if (joinOptions.isSpectator)
|
|
1017
|
+
return this.watch(player);
|
|
1018
|
+
// A player whose time to reconnect ran out while the room slept comes back to their held seat instead.
|
|
1019
|
+
this.expireReconnects();
|
|
1020
|
+
const isRejoin = this.players.has(player.id);
|
|
1021
|
+
const door = this.door(player.id);
|
|
1022
|
+
if (door && !door.isAllowed)
|
|
1023
|
+
return door;
|
|
1024
|
+
// A spectator who joins the match stops watching first.
|
|
1025
|
+
this.stopWatching(player.id);
|
|
1026
|
+
// A player another room sent brings what that room's game server passed, not what their browser sent.
|
|
1027
|
+
const seat = isRejoin ? null : this.seat(player.id);
|
|
1028
|
+
const back = isRejoin || seat ? null : this.holding(player.id);
|
|
1029
|
+
this.expected.delete(player.id);
|
|
1030
|
+
if (back)
|
|
1031
|
+
this.held.delete(player.id);
|
|
1032
|
+
const joinData = seat ? seat.joinData : (joinOptions.joinData ?? null);
|
|
1033
|
+
const joinInfo = seat ? { fromRoomCode: seat.fromRoomCode, isReturning: false, isSpectator: false } : back ? { fromRoomCode: null, isReturning: true, isSpectator: false } : NOBODY_JOINED;
|
|
1034
|
+
if (this.known.size === 0) {
|
|
1035
|
+
if (joinOptions.isListed === true)
|
|
1036
|
+
this.isListed = true;
|
|
1037
|
+
if (joinOptions.friendJoinsEnabled === false)
|
|
1038
|
+
this.friendJoinsEnabled = false;
|
|
1039
|
+
Object.assign(this, tidyRoomInfo({ name: joinOptions.name, labels: joinOptions.labels }));
|
|
1040
|
+
}
|
|
1041
|
+
// Back from a dropped connection: in the match again before the welcome, so it reaches them and lists them as playing.
|
|
1042
|
+
const isReconnect = isRejoin && this.kept.get(player.id) === "lost";
|
|
1043
|
+
if (isReconnect) {
|
|
1044
|
+
this.kept.delete(player.id);
|
|
1045
|
+
this.reconnectUntilMs.delete(player.id);
|
|
1046
|
+
}
|
|
1047
|
+
this.players.set(player.id, player);
|
|
1048
|
+
this.remember(player.id);
|
|
1049
|
+
this.presence.set(player.id, newPresence(this.now(), joinOptions.clientIdle === undefined ? (this.presence.get(player.id)?.clientIdle ?? {}) : clientIdle(joinOptions.clientIdle)));
|
|
1050
|
+
const probeIntervalSec = this.ticking ? ROOM_TUNING.probeIntervalSec : null;
|
|
1051
|
+
this.sendText(player.id, JSON.stringify({ type: "welcome", me: shownPlayer(player), isSpectator: false, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: this.checks(player.id), endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER }));
|
|
1052
|
+
// Every room gets a first ping and clock sample right away, ticking or not.
|
|
1053
|
+
this.probe(player.id);
|
|
1054
|
+
// A new connection starts with nothing of the state: a full copy comes with the next round.
|
|
1055
|
+
if (this.server.stateSyncEnabled) {
|
|
1056
|
+
this.stateTracker?.forget(player.id);
|
|
1057
|
+
this.fullCopyPlayerIds.add(player.id);
|
|
1058
|
+
}
|
|
1059
|
+
// A new connection hears its blocks afresh, and everyone else before the game or the player list says they're here.
|
|
1060
|
+
this.toldBlocks.delete(player.id);
|
|
1061
|
+
this.tellBlocks();
|
|
1062
|
+
if (!isRejoin) {
|
|
1063
|
+
this.listingChanged = true;
|
|
1064
|
+
this.run(() => this.server.onJoin?.(this.state, player, this.api, joinData, joinInfo), "onJoin");
|
|
1065
|
+
this.broadcastPlayers();
|
|
1066
|
+
}
|
|
1067
|
+
else if (isReconnect)
|
|
1068
|
+
this.cameBack(player);
|
|
1069
|
+
else if (this.kept.has(player.id))
|
|
1070
|
+
this.back(player.id);
|
|
1071
|
+
// A turn-based room has no tick to bring a returning connection its copy of the state.
|
|
1072
|
+
if (isRejoin && !this.ticking)
|
|
1073
|
+
this.syncSoon();
|
|
1074
|
+
return { isAllowed: true, isRejoin };
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* A spectator comes to watch: welcomed with the players and the room, probed for their ping and the
|
|
1078
|
+
* room's clock like a player, and the game hears it (onSpectatorJoin). Nothing they do counts as playing.
|
|
1079
|
+
*/
|
|
1080
|
+
watch(spectator) {
|
|
1081
|
+
const door = this.spectatorDoor(spectator.id);
|
|
1082
|
+
if (door && !door.isAllowed)
|
|
1083
|
+
return door;
|
|
1084
|
+
const isRejoin = this.spectators.has(spectator.id);
|
|
1085
|
+
this.spectators.set(spectator.id, spectator);
|
|
1086
|
+
this.presence.set(spectator.id, newPresence(this.now(), {}));
|
|
1087
|
+
const probeIntervalSec = this.ticking ? ROOM_TUNING.probeIntervalSec : null;
|
|
1088
|
+
this.sendText(spectator.id, JSON.stringify({ type: "welcome", me: shownPlayer(spectator), isSpectator: true, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: { idleTimeoutSec: null, hiddenTimeoutSec: null }, endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER }));
|
|
1089
|
+
this.probe(spectator.id);
|
|
1090
|
+
// A new connection starts with nothing of the state: a full copy comes with the next round.
|
|
1091
|
+
if (this.server.stateSyncEnabled) {
|
|
1092
|
+
this.stateTracker?.forget(spectator.id);
|
|
1093
|
+
this.fullCopyPlayerIds.add(spectator.id);
|
|
1094
|
+
}
|
|
1095
|
+
if (!isRejoin) {
|
|
1096
|
+
this.listingChanged = true;
|
|
1097
|
+
this.run(() => this.server.onSpectatorJoin?.(this.state, spectator, this.api), "onSpectatorJoin");
|
|
1098
|
+
this.tellRoom();
|
|
1099
|
+
}
|
|
1100
|
+
// A turn-based room has no tick to bring them their copy of the state, nor a handler that did.
|
|
1101
|
+
if (!this.ticking && (isRejoin || !this.server.onSpectatorJoin))
|
|
1102
|
+
this.syncSoon();
|
|
1103
|
+
return { isAllowed: true, isRejoin };
|
|
1104
|
+
}
|
|
1105
|
+
/** A spectator stops watching: gone at once, and the game hears it (onSpectatorLeave) once the handler running now is done. */
|
|
1106
|
+
stopWatching(spectatorId) {
|
|
1107
|
+
const spectator = this.spectators.get(spectatorId);
|
|
1108
|
+
if (!spectator)
|
|
1109
|
+
return;
|
|
1110
|
+
this.spectators.delete(spectatorId);
|
|
1111
|
+
this.presence.delete(spectatorId);
|
|
1112
|
+
this.buckets.delete(spectatorId);
|
|
1113
|
+
this.latestSlots.delete(spectatorId);
|
|
1114
|
+
this.stateSkippedPlayerIds.delete(spectatorId);
|
|
1115
|
+
this.fullCopyPlayerIds.delete(spectatorId);
|
|
1116
|
+
this.listingChanged = true;
|
|
1117
|
+
this.soon(() => {
|
|
1118
|
+
if (this.ended)
|
|
1119
|
+
return;
|
|
1120
|
+
this.run(() => this.server.onSpectatorLeave?.(this.state, spectator, this.api), "onSpectatorLeave");
|
|
1121
|
+
this.tellRoom();
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* A player's connection closed: they left (closed the game, or followed a move), or it was lost. A
|
|
1126
|
+
* lost player stays in the match as away for reconnectTimeoutSec (onAway, reason "lost"), then
|
|
1127
|
+
* leaves it with their seat held for a while, so they can still come straight back. A spectator just goes.
|
|
1128
|
+
*/
|
|
1129
|
+
leave(playerId, reason = "left") {
|
|
1130
|
+
if (this.spectators.has(playerId))
|
|
1131
|
+
return this.stopWatching(playerId);
|
|
1132
|
+
if (this.held.has(playerId)) {
|
|
1133
|
+
// A player out of the match went for good: their seat is free. A lost connection keeps it held.
|
|
1134
|
+
if (reason === "left") {
|
|
1135
|
+
this.held.delete(playerId);
|
|
1136
|
+
this.listingChanged = true;
|
|
1137
|
+
this.dirty = true;
|
|
1138
|
+
}
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const player = this.players.get(playerId);
|
|
1142
|
+
if (!player)
|
|
1143
|
+
return;
|
|
1144
|
+
const reconnectTimeoutSec = this.server.reconnectTimeoutSec ?? ROOM_LIMITS.defaultReconnectTimeoutSec;
|
|
1145
|
+
if (reason === "lost" && !this.ended && reconnectTimeoutSec > 0) {
|
|
1146
|
+
// A second close, or the runtime's close after the room already dropped them, changes nothing.
|
|
1147
|
+
if (this.kept.get(playerId) === "lost")
|
|
1148
|
+
return;
|
|
1149
|
+
this.kept.set(playerId, "lost");
|
|
1150
|
+
this.reconnectUntilMs.set(playerId, this.now() + reconnectTimeoutSec * 1000);
|
|
1151
|
+
this.presence.get(playerId)?.unansweredProbes.clear();
|
|
1152
|
+
this.listingChanged = true;
|
|
1153
|
+
this.dirty = true;
|
|
1154
|
+
if (this.server.onAway)
|
|
1155
|
+
this.run(() => this.server.onAway(this.state, player, this.api, { reason: "lost" }), "onAway");
|
|
1156
|
+
this.broadcastPlayers();
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
this.out(playerId);
|
|
1160
|
+
if (this.ended)
|
|
1161
|
+
return;
|
|
1162
|
+
if (reason === "lost")
|
|
1163
|
+
this.hold(player, "lost");
|
|
1164
|
+
this.run(() => this.server.onLeave?.(this.state, player, this.api, { reason }), "onLeave");
|
|
1165
|
+
this.broadcastPlayers();
|
|
1166
|
+
}
|
|
1167
|
+
/** Take a player out of the match, forgetting what the room knew about them while they were in. */
|
|
1168
|
+
out(playerId) {
|
|
1169
|
+
this.players.delete(playerId);
|
|
1170
|
+
this.latestSlots.delete(playerId);
|
|
1171
|
+
this.stateSkippedPlayerIds.delete(playerId);
|
|
1172
|
+
this.fullCopyPlayerIds.delete(playerId);
|
|
1173
|
+
this.kept.delete(playerId);
|
|
1174
|
+
this.reconnectUntilMs.delete(playerId);
|
|
1175
|
+
this.presence.delete(playerId);
|
|
1176
|
+
this.buckets.delete(playerId);
|
|
1177
|
+
this.listingChanged = true;
|
|
1178
|
+
this.dirty = true;
|
|
1179
|
+
}
|
|
1180
|
+
/** Hold a seat for a player out of the match, for the game's seatHoldDurationSec. */
|
|
1181
|
+
hold(player, reason) {
|
|
1182
|
+
const seatHoldDurationSec = this.server.seatHoldDurationSec ?? ROOM_LIMITS.defaultSeatHoldDurationSec;
|
|
1183
|
+
if (seatHoldDurationSec > 0)
|
|
1184
|
+
this.held.set(player.id, { player, reason, untilMs: this.now() + seatHoldDurationSec * 1000 });
|
|
1185
|
+
this.listingChanged = true;
|
|
1186
|
+
this.dirty = true;
|
|
1187
|
+
return seatHoldDurationSec;
|
|
1188
|
+
}
|
|
1189
|
+
/** When this room counts a player as away (welcome's awayChecks), by the game's settings and what their game asked for. */
|
|
1190
|
+
checks(playerId) {
|
|
1191
|
+
return { idleTimeoutSec: this.idleTimeoutSecFor(playerId), hiddenTimeoutSec: this.ticking ? ROOM_LIMITS.hiddenTimeoutSec : null };
|
|
1192
|
+
}
|
|
1193
|
+
/** Seconds without input before a player counts as idle, or null if nobody checks for them. */
|
|
1194
|
+
idleTimeoutSecFor(playerId) {
|
|
1195
|
+
const idleCheck = this.server.idleCheck ?? "server";
|
|
1196
|
+
const serverTimeoutSec = this.server.idleTimeoutSec ?? ROOM_LIMITS.defaultIdleTimeoutSec;
|
|
1197
|
+
if (idleCheck === "off")
|
|
1198
|
+
return null;
|
|
1199
|
+
if (idleCheck === "server")
|
|
1200
|
+
return serverTimeoutSec;
|
|
1201
|
+
const asked = this.presence.get(playerId)?.clientIdle ?? {};
|
|
1202
|
+
if (asked.idleCheckEnabled === false)
|
|
1203
|
+
return null;
|
|
1204
|
+
return asked.idleTimeoutSec ?? serverTimeoutSec;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* A player walked away: idle (they didn't answer "Still there?", or the room heard nothing for
|
|
1208
|
+
* longer), or their game hidden too long in a real-time room. The game's onAway decides; otherwise
|
|
1209
|
+
* they leave the match with their seat held, and with awayMode "lobby" are sent to the lobby.
|
|
1210
|
+
*/
|
|
1211
|
+
walkAway(playerId, reason) {
|
|
1212
|
+
const player = this.players.get(playerId);
|
|
1213
|
+
if (!player || this.ended || this.kept.has(playerId))
|
|
1214
|
+
return;
|
|
1215
|
+
if (this.server.onAway) {
|
|
1216
|
+
const presence = this.presence.get(playerId);
|
|
1217
|
+
if (presence)
|
|
1218
|
+
presence.hiddenAtMs = undefined;
|
|
1219
|
+
this.kept.set(playerId, reason);
|
|
1220
|
+
this.listingChanged = true;
|
|
1221
|
+
this.dirty = true;
|
|
1222
|
+
this.run(() => this.server.onAway(this.state, player, this.api, { reason }), "onAway");
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
this.out(playerId);
|
|
1226
|
+
const seatHoldDurationSec = this.hold(player, reason);
|
|
1227
|
+
this.sendText(playerId, JSON.stringify({ type: "away", awayReason: reason, seatHoldDurationSec }));
|
|
1228
|
+
this.run(() => this.server.onLeave?.(this.state, player, this.api, { reason }), "onLeave");
|
|
1229
|
+
this.broadcastPlayers();
|
|
1230
|
+
if (this.server.awayMode === "lobby" && this.lobbyRoomCode && this.transport.moveTo) {
|
|
1231
|
+
this.sentAway.add(playerId);
|
|
1232
|
+
this.transport.moveTo(this.lobbyRoomCode, { players: [player], joinData: null });
|
|
1233
|
+
}
|
|
1234
|
+
else if (!seatHoldDurationSec)
|
|
1235
|
+
this.transport.close(playerId, SEAT_GONE_CLOSE_CODE, "Seat no longer held");
|
|
1236
|
+
}
|
|
1237
|
+
/** A player who walked away is back: kept in the room by onAway, or coming back to their held seat. */
|
|
1238
|
+
back(playerId, joinData, asked) {
|
|
1239
|
+
const player = this.players.get(playerId);
|
|
1240
|
+
if (player) {
|
|
1241
|
+
if (!this.kept.delete(playerId))
|
|
1242
|
+
return null;
|
|
1243
|
+
this.reconnectUntilMs.delete(playerId);
|
|
1244
|
+
this.cameBack(player);
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
const held = this.holding(playerId);
|
|
1248
|
+
if (!held)
|
|
1249
|
+
return null;
|
|
1250
|
+
const checked = checkRoomData(joinData, ROOM_LIMITS.maximumJoinDataSizeBytes);
|
|
1251
|
+
const joined = this.join(held.player, { joinData: checked.isValid ? checked.checkedData : null, clientIdle: asked });
|
|
1252
|
+
return joined.isAllowed ? null : joined.code;
|
|
1253
|
+
}
|
|
1254
|
+
/** A player kept away is playing again: the game hears it (onBack), and every player's list shows them playing. */
|
|
1255
|
+
cameBack(player) {
|
|
1256
|
+
this.listingChanged = true;
|
|
1257
|
+
this.dirty = true;
|
|
1258
|
+
this.run(() => this.server.onBack?.(this.state, player, this.api), "onBack");
|
|
1259
|
+
this.broadcastPlayers();
|
|
1260
|
+
}
|
|
1261
|
+
/** Players whose time to reconnect ran out leave the match, with their seat held (onLeave, reason "lost"). */
|
|
1262
|
+
expireReconnects() {
|
|
1263
|
+
if (this.ended)
|
|
1264
|
+
return;
|
|
1265
|
+
const nowMs = this.now();
|
|
1266
|
+
for (const [id, untilMs] of [...this.reconnectUntilMs]) {
|
|
1267
|
+
if (untilMs > nowMs)
|
|
1268
|
+
continue;
|
|
1269
|
+
const player = this.players.get(id);
|
|
1270
|
+
this.reconnectUntilMs.delete(id);
|
|
1271
|
+
if (!player)
|
|
1272
|
+
continue;
|
|
1273
|
+
this.out(id);
|
|
1274
|
+
this.hold(player, "lost");
|
|
1275
|
+
this.run(() => this.server.onLeave?.(this.state, player, this.api, { reason: "lost" }), "onLeave");
|
|
1276
|
+
this.broadcastPlayers();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
/** When the first player whose connection dropped runs out of time to reconnect, or null if nobody is reconnecting. */
|
|
1280
|
+
nextReconnectDeadlineMs() {
|
|
1281
|
+
let deadlineMs = null;
|
|
1282
|
+
for (const untilMs of this.reconnectUntilMs.values())
|
|
1283
|
+
if (deadlineMs === null || untilMs < deadlineMs)
|
|
1284
|
+
deadlineMs = untilMs;
|
|
1285
|
+
return deadlineMs;
|
|
1286
|
+
}
|
|
1287
|
+
/** Whether a player is in the match without a connection: dropped, and still within reconnectTimeoutSec. */
|
|
1288
|
+
isLost(playerId) {
|
|
1289
|
+
return this.kept.get(playerId) === "lost";
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Check on the room's players, every half a minute or so: players the room hasn't heard from in
|
|
1293
|
+
* longer than the idle check allows (a game that doesn't ask "Still there?"), and players whose
|
|
1294
|
+
* game has been hidden too long in a real-time room, walk away; held seats that ran out are freed,
|
|
1295
|
+
* and those players' connections close.
|
|
1296
|
+
*/
|
|
1297
|
+
sweep() {
|
|
1298
|
+
if (this.ended)
|
|
1299
|
+
return;
|
|
1300
|
+
this.expireReconnects();
|
|
1301
|
+
const nowMs = this.now();
|
|
1302
|
+
for (const [id, presence] of [...this.presence]) {
|
|
1303
|
+
// Spectators give no input, so they're never idle or away; a dead connection still drops them.
|
|
1304
|
+
if (this.kept.has(id) || this.spectators.has(id))
|
|
1305
|
+
continue;
|
|
1306
|
+
const idleTimeoutSec = this.idleTimeoutSecFor(id);
|
|
1307
|
+
if (idleTimeoutSec !== null && nowMs - presence.seenAtMs >= idleTimeoutSec * 1000 + IDLE_ASK_MS + IDLE_GRACE_MS)
|
|
1308
|
+
this.walkAway(id, "idle");
|
|
1309
|
+
else if (this.ticking && presence.hiddenAtMs !== undefined && nowMs - presence.hiddenAtMs >= ROOM_LIMITS.hiddenTimeoutSec * 1000)
|
|
1310
|
+
this.walkAway(id, "hidden");
|
|
1311
|
+
}
|
|
1312
|
+
// Rooms that don't tick check their probes here.
|
|
1313
|
+
for (const id of [...this.presence.keys()])
|
|
1314
|
+
this.checkAnswers(id);
|
|
1315
|
+
for (const [id, held] of [...this.held]) {
|
|
1316
|
+
if (held.untilMs > nowMs)
|
|
1317
|
+
continue;
|
|
1318
|
+
this.held.delete(id);
|
|
1319
|
+
this.listingChanged = true;
|
|
1320
|
+
this.dirty = true;
|
|
1321
|
+
this.transport.close(id, SEAT_GONE_CLOSE_CODE, "Seat no longer held");
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
/** A raw text message from a client. Returns an error code if it was refused. */
|
|
1325
|
+
receive(playerId, text) {
|
|
1326
|
+
if (this.spectators.has(playerId))
|
|
1327
|
+
return this.receiveFromSpectator(playerId, text);
|
|
1328
|
+
const player = this.players.get(playerId);
|
|
1329
|
+
const isHeld = !player && this.held.has(playerId);
|
|
1330
|
+
if ((!player && !isHeld) || this.ended)
|
|
1331
|
+
return "ended";
|
|
1332
|
+
const sizeBytes = text.length > ROOM_LIMITS.maximumGameMessageSizeBytes ? text.length : utf8ByteCount(text);
|
|
1333
|
+
const isTooBig = sizeBytes > ROOM_LIMITS.maximumGameMessageSizeBytes;
|
|
1334
|
+
let parsed;
|
|
1335
|
+
let isParsed = false;
|
|
1336
|
+
if (!isTooBig) {
|
|
1337
|
+
try {
|
|
1338
|
+
parsed = JSON.parse(text);
|
|
1339
|
+
isParsed = true;
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
// Refused below, once it's counted.
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const message = (parsed && typeof parsed === "object" ? parsed : {});
|
|
1346
|
+
// A probe answer is automatic, not the player's input: it isn't counted, takes no token and doesn't count as seen.
|
|
1347
|
+
if (message.type === "probe_answer" && this.answer(playerId, message.probeNumber))
|
|
1348
|
+
return null;
|
|
1349
|
+
this.messageCount++;
|
|
1350
|
+
this.countTraffic("inbound", message.type === "message" ? messageTypeOf(message.data) : typeof message.type === "string" ? ZAISMA_MESSAGE_TYPE : NO_MESSAGE_TYPE, 1, sizeBytes);
|
|
1351
|
+
if (isTooBig)
|
|
1352
|
+
return "too_big";
|
|
1353
|
+
if (!this.take(playerId))
|
|
1354
|
+
return "too_fast";
|
|
1355
|
+
if (!isParsed)
|
|
1356
|
+
return "bad_message";
|
|
1357
|
+
// Out of the match with their seat held, a player can only come back; anything else waits.
|
|
1358
|
+
if (isHeld)
|
|
1359
|
+
return message.type === "back" ? this.back(playerId, message.joinData, message) : null;
|
|
1360
|
+
const presence = this.presence.get(playerId);
|
|
1361
|
+
if (presence)
|
|
1362
|
+
presence.seenAtMs = this.now();
|
|
1363
|
+
switch (message.type) {
|
|
1364
|
+
case "room":
|
|
1365
|
+
return this.hostChange(playerId, message);
|
|
1366
|
+
case "kick":
|
|
1367
|
+
return this.hostKick(playerId, message);
|
|
1368
|
+
case "here":
|
|
1369
|
+
return null;
|
|
1370
|
+
case "probe_answer":
|
|
1371
|
+
// It answers no probe: counted and limited like any message, so answers can't dodge the limit.
|
|
1372
|
+
return null;
|
|
1373
|
+
case "idle":
|
|
1374
|
+
this.walkAway(playerId, "idle");
|
|
1375
|
+
return null;
|
|
1376
|
+
case "hidden":
|
|
1377
|
+
if (presence && this.ticking)
|
|
1378
|
+
presence.hiddenAtMs ??= this.now();
|
|
1379
|
+
return null;
|
|
1380
|
+
case "shown":
|
|
1381
|
+
if (presence)
|
|
1382
|
+
presence.hiddenAtMs = undefined;
|
|
1383
|
+
return this.kept.get(playerId) === "hidden" ? this.back(playerId) : null;
|
|
1384
|
+
case "back":
|
|
1385
|
+
return this.back(playerId);
|
|
1386
|
+
case "message":
|
|
1387
|
+
this.input(() => this.server.onMessage?.(this.state, player, this.api, message.data), "onMessage");
|
|
1388
|
+
return null;
|
|
1389
|
+
case "block":
|
|
1390
|
+
return this.sessionBlock(playerId, message, true);
|
|
1391
|
+
case "unblock":
|
|
1392
|
+
return this.sessionBlock(playerId, message, false);
|
|
1393
|
+
default:
|
|
1394
|
+
// A type this room doesn't know, maybe from a newer SDK: counted and limited above, then ignored.
|
|
1395
|
+
return typeof message.type === "string" ? null : "bad_message";
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/** Bytes from a client (a binary frame). Returns an error code if they were refused. */
|
|
1399
|
+
receiveBytes(playerId, bytes) {
|
|
1400
|
+
// Spectators send nothing; limited like anyone's messages, so refusing them costs little.
|
|
1401
|
+
if (this.spectators.has(playerId))
|
|
1402
|
+
return this.ended ? "ended" : this.take(playerId) ? "spectator_cannot_send" : "too_fast";
|
|
1403
|
+
const player = this.players.get(playerId);
|
|
1404
|
+
const isHeld = !player && this.held.has(playerId);
|
|
1405
|
+
if ((!player && !isHeld) || this.ended)
|
|
1406
|
+
return "ended";
|
|
1407
|
+
this.messageCount++;
|
|
1408
|
+
this.countTraffic("inbound", BYTES_MESSAGE_TYPE, 1, bytes.length);
|
|
1409
|
+
if (bytes.length > ROOM_LIMITS.maximumGameMessageSizeBytes)
|
|
1410
|
+
return "too_big";
|
|
1411
|
+
if (!this.take(playerId))
|
|
1412
|
+
return "too_fast";
|
|
1413
|
+
// Out of the match with their seat held, a player's bytes wait like their messages.
|
|
1414
|
+
if (isHeld)
|
|
1415
|
+
return null;
|
|
1416
|
+
if (!this.server.onBytes)
|
|
1417
|
+
return "bad_message";
|
|
1418
|
+
const presence = this.presence.get(playerId);
|
|
1419
|
+
if (presence)
|
|
1420
|
+
presence.seenAtMs = this.now();
|
|
1421
|
+
this.input(() => this.server.onBytes(this.state, player, this.api, bytes), "onBytes");
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* A spectator's text: answers to the room's probes, and nothing else (spectator_cannot_send). Kept out of
|
|
1426
|
+
* the room's message count and traffic, so watching stays off the meter, and limited like anyone's messages.
|
|
1427
|
+
*/
|
|
1428
|
+
receiveFromSpectator(spectatorId, text) {
|
|
1429
|
+
if (this.ended)
|
|
1430
|
+
return "ended";
|
|
1431
|
+
if (text.length <= ROOM_LIMITS.maximumGameMessageSizeBytes) {
|
|
1432
|
+
try {
|
|
1433
|
+
const message = JSON.parse(text);
|
|
1434
|
+
if (message?.type === "probe_answer" && this.answer(spectatorId, message.probeNumber))
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
catch {
|
|
1438
|
+
// Refused below.
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return this.take(spectatorId) ? "spectator_cannot_send" : "too_fast";
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* A player's message or bytes for the game server: through the queue of handlers, or at once for a
|
|
1445
|
+
* game server that only queues them for its next call (QUEUES_INPUT).
|
|
1446
|
+
*/
|
|
1447
|
+
input(handler, handlerName) {
|
|
1448
|
+
if (!this.server[QUEUES_INPUT])
|
|
1449
|
+
return this.run(handler, handlerName);
|
|
1450
|
+
try {
|
|
1451
|
+
handler();
|
|
1452
|
+
}
|
|
1453
|
+
catch (error) {
|
|
1454
|
+
console.error("Game server error:", error);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
/** Count messages in or out for the room's traffic: how many, their bytes and the largest of them, and how many went to a backed-up player. */
|
|
1458
|
+
countTraffic(direction, messageType, messageCount, sizeBytes, backedUpMessageCount = 0) {
|
|
1459
|
+
if (messageCount === 0)
|
|
1460
|
+
return;
|
|
1461
|
+
const traffic = this.traffic;
|
|
1462
|
+
if (direction === "inbound")
|
|
1463
|
+
traffic.inboundSizeBytes += sizeBytes;
|
|
1464
|
+
else {
|
|
1465
|
+
traffic.outboundMessageCount += messageCount;
|
|
1466
|
+
traffic.outboundSizeBytes += sizeBytes * messageCount;
|
|
1467
|
+
}
|
|
1468
|
+
let kept = traffic.messageTypes.get(`${direction} ${messageType}`);
|
|
1469
|
+
if (!kept) {
|
|
1470
|
+
const typeCountKey = direction === "inbound" ? "inboundTypeCount" : "outboundTypeCount";
|
|
1471
|
+
if (traffic[typeCountKey] >= MAXIMUM_MESSAGE_TYPE_COUNT && messageType !== OTHER_MESSAGE_TYPE)
|
|
1472
|
+
return this.countTraffic(direction, OTHER_MESSAGE_TYPE, messageCount, sizeBytes, backedUpMessageCount);
|
|
1473
|
+
if (messageType !== OTHER_MESSAGE_TYPE)
|
|
1474
|
+
traffic[typeCountKey]++;
|
|
1475
|
+
kept = { messageType, direction, messageCount: 0, sizeBytes: 0, largestSizeBytes: 0, backedUpMessageCount: 0 };
|
|
1476
|
+
traffic.messageTypes.set(`${direction} ${messageType}`, kept);
|
|
1477
|
+
}
|
|
1478
|
+
kept.messageCount += messageCount;
|
|
1479
|
+
kept.sizeBytes += sizeBytes * messageCount;
|
|
1480
|
+
kept.largestSizeBytes = Math.max(kept.largestSizeBytes, sizeBytes);
|
|
1481
|
+
kept.backedUpMessageCount += backedUpMessageCount;
|
|
1482
|
+
}
|
|
1483
|
+
/** The room's traffic since the last time it was taken, and start counting afresh. */
|
|
1484
|
+
takeTraffic() {
|
|
1485
|
+
const traffic = this.traffic;
|
|
1486
|
+
this.traffic = { inboundSizeBytes: 0, outboundMessageCount: 0, outboundSizeBytes: 0, messageTypes: new Map(), inboundTypeCount: 0, outboundTypeCount: 0 };
|
|
1487
|
+
return { inboundSizeBytes: traffic.inboundSizeBytes, outboundMessageCount: traffic.outboundMessageCount, outboundSizeBytes: traffic.outboundSizeBytes, messageTypes: [...traffic.messageTypes.values()] };
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* The host: the room's first player, when the game server has host controls on. A host whose
|
|
1491
|
+
* connection dropped hands over to the next player meanwhile, so host controls never wait on them.
|
|
1492
|
+
*/
|
|
1493
|
+
get hostPlayerId() {
|
|
1494
|
+
if (!this.server.hostControlsEnabled)
|
|
1495
|
+
return null;
|
|
1496
|
+
for (const id of this.players.keys())
|
|
1497
|
+
if (!this.isLost(id))
|
|
1498
|
+
return id;
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* The host kicks a player, when the game server allows it. Players can't type the message (no free
|
|
1503
|
+
* text between strangers), so the kicked player is told the host removed them.
|
|
1504
|
+
*/
|
|
1505
|
+
hostKick(playerId, message) {
|
|
1506
|
+
if (this.hostPlayerId !== playerId)
|
|
1507
|
+
return "not_allowed";
|
|
1508
|
+
const kickedId = message.playerId;
|
|
1509
|
+
if (typeof kickedId !== "string" || kickedId === playerId || !this.players.has(kickedId))
|
|
1510
|
+
return "bad_message";
|
|
1511
|
+
this.kick(kickedId, { message: "The host removed you from this room.", keepOut: message.keepOut === true });
|
|
1512
|
+
return null;
|
|
1513
|
+
}
|
|
1514
|
+
/** The host hides, shows, opens, closes, turns friends' Join on or off, names or labels the room, when the game server allows it. */
|
|
1515
|
+
hostChange(playerId, message) {
|
|
1516
|
+
if (this.hostPlayerId !== playerId)
|
|
1517
|
+
return "not_allowed";
|
|
1518
|
+
const asked = message.room;
|
|
1519
|
+
if (!asked || typeof asked !== "object" || Array.isArray(asked))
|
|
1520
|
+
return "bad_message";
|
|
1521
|
+
const room = asked;
|
|
1522
|
+
const change = tidyRoomInfo(room);
|
|
1523
|
+
if (typeof room.isListed === "boolean")
|
|
1524
|
+
change.isListed = room.isListed;
|
|
1525
|
+
if (typeof room.isOpen === "boolean")
|
|
1526
|
+
change.isOpen = room.isOpen;
|
|
1527
|
+
if (typeof room.friendJoinsEnabled === "boolean")
|
|
1528
|
+
change.friendJoinsEnabled = room.friendJoinsEnabled;
|
|
1529
|
+
this.settle(change);
|
|
1530
|
+
this.dirty = true;
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
settings() {
|
|
1534
|
+
return { isListed: this.isListed, isOpen: this.isOpen, friendJoinsEnabled: this.friendJoinsEnabled, name: this.name, labels: { ...this.labels } };
|
|
1535
|
+
}
|
|
1536
|
+
/** Change isListed, isOpen, friendJoinsEnabled, the name or labels, and tell everyone in the room. */
|
|
1537
|
+
settle(change) {
|
|
1538
|
+
const next = { ...this.settings(), ...change };
|
|
1539
|
+
if (JSON.stringify(next) === JSON.stringify(this.settings()))
|
|
1540
|
+
return;
|
|
1541
|
+
this.isListed = next.isListed;
|
|
1542
|
+
this.isOpen = next.isOpen;
|
|
1543
|
+
this.friendJoinsEnabled = next.friendJoinsEnabled;
|
|
1544
|
+
this.name = next.name;
|
|
1545
|
+
this.labels = next.labels;
|
|
1546
|
+
this.listingChanged = true;
|
|
1547
|
+
this.tellRoom();
|
|
1548
|
+
}
|
|
1549
|
+
/** Tell every player and spectator the room's settings and how many are watching. */
|
|
1550
|
+
tellRoom() {
|
|
1551
|
+
this.sendEach(this.everyone(), JSON.stringify({ type: "room", room: this.settings(), spectatorCount: this.spectators.size }));
|
|
1552
|
+
}
|
|
1553
|
+
/** The players and the spectators: everyone the room tells what happens in it. */
|
|
1554
|
+
everyone() {
|
|
1555
|
+
return [...this.players.keys(), ...this.spectators.keys()];
|
|
1556
|
+
}
|
|
1557
|
+
/** Whether the room sends to this player: they're in the match, or watching. */
|
|
1558
|
+
isViewer(playerId) {
|
|
1559
|
+
return this.players.has(playerId) || this.spectators.has(playerId);
|
|
1560
|
+
}
|
|
1561
|
+
/** Who a broadcast reaches (BroadcastOptions.audience, already checked). */
|
|
1562
|
+
audienceOf(audience) {
|
|
1563
|
+
if (audience === "players")
|
|
1564
|
+
return [...this.players.keys()];
|
|
1565
|
+
if (audience === "spectators")
|
|
1566
|
+
return [...this.spectators.keys()];
|
|
1567
|
+
return this.everyone();
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Keep the room, or stop keeping it (room.setKept). The transport may turn a keep back (a game past its
|
|
1571
|
+
* free storage), so it hears through listingChanged; players are told nothing.
|
|
1572
|
+
*/
|
|
1573
|
+
setKept(isKept) {
|
|
1574
|
+
if (this.isKept === isKept || this.ended)
|
|
1575
|
+
return;
|
|
1576
|
+
this.isKept = isKept;
|
|
1577
|
+
this.listingChanged = true;
|
|
1578
|
+
this.dirty = true;
|
|
1579
|
+
}
|
|
1580
|
+
/** A room made for some of this room's players (room.reserve). Mistakes in the game's call throw. newRoomCode is the one a game server in a sandbox chose. */
|
|
1581
|
+
reserve(reserveOptions, chosenRoomCode) {
|
|
1582
|
+
this.requireCall("room.reserve", this.transport.reserve !== undefined);
|
|
1583
|
+
const checked = checks.checkReserve(reserveOptions, [...this.players.keys()]);
|
|
1584
|
+
this.limit("room.reserve");
|
|
1585
|
+
const newRoomCode = chosenRoomCode !== null && isRoomCode(chosenRoomCode) ? chosenRoomCode : roomCode();
|
|
1586
|
+
this.transport.reserve(newRoomCode, {
|
|
1587
|
+
playerIds: checked.playerIds,
|
|
1588
|
+
setupData: checked.setupData,
|
|
1589
|
+
isListed: checked.isListed,
|
|
1590
|
+
...(checked.friendJoinsEnabled ? {} : { friendJoinsEnabled: false }),
|
|
1591
|
+
...(checked.isKept ? { isKept: true } : {}),
|
|
1592
|
+
name: checked.name,
|
|
1593
|
+
labels: checked.labels,
|
|
1594
|
+
...(this.keptOut.size ? { keptOut: Object.fromEntries(this.keptOut) } : {}),
|
|
1595
|
+
});
|
|
1596
|
+
return newRoomCode;
|
|
1597
|
+
}
|
|
1598
|
+
/** Send players to another room of the game (room.moveTo). Mistakes in the game's call throw. */
|
|
1599
|
+
moveTo(toRoomCode, moveOptions) {
|
|
1600
|
+
this.requireCall("room.moveTo", this.transport.moveTo !== undefined);
|
|
1601
|
+
const checked = checks.checkMoveTo(toRoomCode, moveOptions, [...this.players.keys()]);
|
|
1602
|
+
this.limit("room.moveTo");
|
|
1603
|
+
const players = checked.playerIds.map((id) => this.players.get(id));
|
|
1604
|
+
this.transport.moveTo(checked.toRoomCode, { players, joinData: checked.joinData });
|
|
1605
|
+
}
|
|
1606
|
+
/** A call that needs the transport to offer it and the room to be going: otherwise it throws room_call_unavailable. */
|
|
1607
|
+
requireCall(call, isOffered) {
|
|
1608
|
+
if (!isOffered)
|
|
1609
|
+
throw new GameServerError("room_call_unavailable", call, `${call} isn't available here.`, "Reserving rooms and moving players need Zaisma's rooms or the Node runner.");
|
|
1610
|
+
if (this.ended)
|
|
1611
|
+
throw new GameServerError("room_call_unavailable", call, `${call} was called after this room ended.`, "Check room.endsAt, and stop making room calls once you've called room.end().");
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* The other room's answer to room.moveTo, for onMoved. Players it took who already left, or who
|
|
1615
|
+
* aren't here, are left out.
|
|
1616
|
+
*/
|
|
1617
|
+
moved(toRoomCode, answer, sent) {
|
|
1618
|
+
// The room sending a player who walked away to its lobby: if the lobby turned them away, their seat
|
|
1619
|
+
// here stays held, and with no seat held (seatHoldDurationSec: 0) there's nothing to stay connected for.
|
|
1620
|
+
if (sent.length === 1 && this.sentAway.delete(sent[0].id)) {
|
|
1621
|
+
const id = sent[0].id;
|
|
1622
|
+
if (!answer.movedPlayerIds.includes(id) && !this.players.has(id) && !this.holding(id))
|
|
1623
|
+
this.transport.close(id, SEAT_GONE_CLOSE_CODE, "Seat no longer held");
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
if (this.ended || !this.server.onMoved)
|
|
1627
|
+
return;
|
|
1628
|
+
const who = new Map(sent.map((player) => [player.id, player]));
|
|
1629
|
+
const moveResult = {
|
|
1630
|
+
roomCode: toRoomCode,
|
|
1631
|
+
movedPlayers: answer.movedPlayerIds.map((id) => who.get(id)).filter((player) => Boolean(player)),
|
|
1632
|
+
refusals: answer.refusals.filter((refusal) => who.has(refusal.playerId)).map((refusal) => ({ player: who.get(refusal.playerId), code: refusal.code, message: refusal.message })),
|
|
1633
|
+
};
|
|
1634
|
+
this.run(() => this.server.onMoved(this.state, moveResult, this.api), "onMoved");
|
|
1635
|
+
}
|
|
1636
|
+
/** Reserving rooms and moving players share one limit a minute. */
|
|
1637
|
+
limit(call) {
|
|
1638
|
+
const nowMs = this.now();
|
|
1639
|
+
this.reservedAtMs = this.reservedAtMs.filter((reservedAtMs) => nowMs - reservedAtMs < 60_000);
|
|
1640
|
+
if (this.reservedAtMs.length >= ROOM_LIMITS.maximumReservesPerMin)
|
|
1641
|
+
throw new GameServerError("room_call_limit", call, `A room can reserve rooms or move players up to ${ROOM_LIMITS.maximumReservesPerMin} times a minute.`, "Reserve one room for a whole group, or wait a minute.");
|
|
1642
|
+
this.reservedAtMs.push(nowMs);
|
|
1643
|
+
}
|
|
1644
|
+
/** Remove a player (room.kick). Their onLeave runs once the handler that kicked them is done. */
|
|
1645
|
+
kick(playerId, kickOptions) {
|
|
1646
|
+
const player = this.players.get(playerId);
|
|
1647
|
+
const held = this.held.get(playerId);
|
|
1648
|
+
const isSpectator = this.spectators.has(playerId);
|
|
1649
|
+
if ((!player && !held && !isSpectator) || this.ended)
|
|
1650
|
+
return;
|
|
1651
|
+
const message = kickOptions?.message || ERROR_TEXT.kept_out;
|
|
1652
|
+
const isKeptOut = kickOptions?.keepOut === true;
|
|
1653
|
+
if (isSpectator) {
|
|
1654
|
+
if (isKeptOut)
|
|
1655
|
+
this.keepOut({ [playerId]: message });
|
|
1656
|
+
this.sendText(playerId, JSON.stringify({ type: "kicked", message, isKeptOut }));
|
|
1657
|
+
this.transport.close(playerId, REMOVED_CLOSE_CODE, "Removed from the room");
|
|
1658
|
+
this.stopWatching(playerId);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
this.known.delete(playerId);
|
|
1662
|
+
if (isKeptOut)
|
|
1663
|
+
this.keepOut({ [playerId]: message });
|
|
1664
|
+
// A player whose connection dropped has none to tell or close.
|
|
1665
|
+
if (!this.isLost(playerId)) {
|
|
1666
|
+
this.sendText(playerId, JSON.stringify({ type: "kicked", message, isKeptOut }));
|
|
1667
|
+
this.transport.close(playerId, REMOVED_CLOSE_CODE, "Removed from the room");
|
|
1668
|
+
}
|
|
1669
|
+
this.held.delete(playerId);
|
|
1670
|
+
this.out(playerId);
|
|
1671
|
+
// A player out of the match with their seat held already left it: there's no onLeave to run.
|
|
1672
|
+
if (!player)
|
|
1673
|
+
return;
|
|
1674
|
+
this.soon(() => {
|
|
1675
|
+
if (this.ended)
|
|
1676
|
+
return;
|
|
1677
|
+
this.run(() => this.server.onLeave?.(this.state, player, this.api, { reason: "kicked" }), "onLeave");
|
|
1678
|
+
this.broadcastPlayers();
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
/** Keep players out, newest last, up to as many as the room remembers. */
|
|
1682
|
+
keepOut(players) {
|
|
1683
|
+
for (const [id, message] of Object.entries(players ?? {})) {
|
|
1684
|
+
if (typeof message !== "string")
|
|
1685
|
+
continue;
|
|
1686
|
+
this.keptOut.delete(id);
|
|
1687
|
+
this.keptOut.set(id, message.slice(0, ROOM_LIMITS.maximumKickMessageCharacterCount));
|
|
1688
|
+
if (this.keptOut.size > REMEMBERED_PLAYER_COUNT)
|
|
1689
|
+
this.keptOut.delete(this.keptOut.keys().next().value);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
/** Run now, or once the handler running now is done, so a handler never runs inside another. */
|
|
1693
|
+
soon(work) {
|
|
1694
|
+
if (this.depth)
|
|
1695
|
+
this.afterHandler.push(work);
|
|
1696
|
+
else
|
|
1697
|
+
work();
|
|
1698
|
+
}
|
|
1699
|
+
flush() {
|
|
1700
|
+
while (!this.depth && this.afterHandler.length)
|
|
1701
|
+
this.afterHandler.shift()();
|
|
1702
|
+
}
|
|
1703
|
+
remember(id) {
|
|
1704
|
+
this.known.delete(id);
|
|
1705
|
+
this.known.add(id);
|
|
1706
|
+
// Forget the oldest who isn't here or holding a seat, so no player in the match drops out of what the room knows of them.
|
|
1707
|
+
if (this.known.size > REMEMBERED_PLAYER_COUNT) {
|
|
1708
|
+
for (const oldest of this.known) {
|
|
1709
|
+
if (oldest === id || this.players.has(oldest) || this.holding(oldest) !== null)
|
|
1710
|
+
continue;
|
|
1711
|
+
this.known.delete(oldest);
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
tick(tickDurationSec) {
|
|
1717
|
+
if (this.ended)
|
|
1718
|
+
return;
|
|
1719
|
+
this.expireReconnects();
|
|
1720
|
+
this.probeDue();
|
|
1721
|
+
this.catchUp();
|
|
1722
|
+
// A room where everyone walked away stops running until someone's back.
|
|
1723
|
+
if (this.playingCount === 0)
|
|
1724
|
+
return;
|
|
1725
|
+
// State sync's round comes after onTick; without one, every tick.
|
|
1726
|
+
if (!this.server.onTick)
|
|
1727
|
+
return this.syncState();
|
|
1728
|
+
// A game server that hasn't caught up skips this tick; tickDurationSec carries the time over to the next one.
|
|
1729
|
+
if (this.waitingCount > 1)
|
|
1730
|
+
return;
|
|
1731
|
+
this.run(() => this.server.onTick(this.state, tickDurationSec, this.api), "onTick", true);
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Players who caught up: their latest-only messages go now, and in a turn-based room, players state sync
|
|
1735
|
+
* skipped get a full copy. Ticking rooms check each tick and at each probe answer; your own server's
|
|
1736
|
+
* runner also every 50 ms while isCatchingUp.
|
|
1737
|
+
*/
|
|
1738
|
+
catchUp() {
|
|
1739
|
+
if (this.ended)
|
|
1740
|
+
return;
|
|
1741
|
+
for (const id of [...this.latestSlots.keys()]) {
|
|
1742
|
+
if (!this.isViewer(id) || this.isLost(id))
|
|
1743
|
+
this.latestSlots.delete(id);
|
|
1744
|
+
else if (!this.isBackedUp(id))
|
|
1745
|
+
this.sendLatest(id);
|
|
1746
|
+
}
|
|
1747
|
+
let hasCaughtUp = false;
|
|
1748
|
+
for (const id of [...this.stateSkippedPlayerIds]) {
|
|
1749
|
+
if (this.isViewer(id) && !this.isLost(id) && this.isBackedUp(id))
|
|
1750
|
+
continue;
|
|
1751
|
+
this.stateSkippedPlayerIds.delete(id);
|
|
1752
|
+
hasCaughtUp ||= this.isViewer(id);
|
|
1753
|
+
}
|
|
1754
|
+
if (hasCaughtUp)
|
|
1755
|
+
this.syncSoon();
|
|
1756
|
+
}
|
|
1757
|
+
/** Whether anything waits for a player to catch up: latest-only messages, or a turn-based room's state. */
|
|
1758
|
+
get isCatchingUp() {
|
|
1759
|
+
return this.latestSlots.size > 0 || this.stateSkippedPlayerIds.size > 0;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Whether a player's connection is backed up: the transport says (the socket's bufferedAmount on your
|
|
1763
|
+
* own server), or, in Zaisma rooms, a probe of theirs is ROOM_TUNING.backedUpDelayMs later than their
|
|
1764
|
+
* ping with ROOM_TUNING.backedUpSizeBytes or more sent since the last probe they answered.
|
|
1765
|
+
*/
|
|
1766
|
+
isBackedUp(playerId) {
|
|
1767
|
+
if (!this.isViewer(playerId) || this.isLost(playerId))
|
|
1768
|
+
return false;
|
|
1769
|
+
if (this.transport.isBackedUp)
|
|
1770
|
+
return this.transport.isBackedUp(playerId);
|
|
1771
|
+
const presence = this.presence.get(playerId);
|
|
1772
|
+
const oldest = presence?.unansweredProbes.values().next().value;
|
|
1773
|
+
if (!presence || !oldest)
|
|
1774
|
+
return false;
|
|
1775
|
+
return this.now() - oldest.sentAtMs > (presence.pingDurationMs ?? 0) + ROOM_TUNING.backedUpDelayMs && presence.sentByteCount - presence.answeredSentByteCount >= ROOM_TUNING.backedUpSizeBytes;
|
|
1776
|
+
}
|
|
1777
|
+
/** The players who see the state: everyone in the match with a connection, and the spectators. */
|
|
1778
|
+
stateViewerIds() {
|
|
1779
|
+
return this.everyone().filter((id) => !this.isLost(id));
|
|
1780
|
+
}
|
|
1781
|
+
/** Which viewers are backed up this round; a turn-based room remembers them, to send a full copy once they catch up. */
|
|
1782
|
+
backedUpViewers(viewerIds) {
|
|
1783
|
+
const backedUpPlayerIds = new Set(viewerIds.filter((id) => this.isBackedUp(id)));
|
|
1784
|
+
if (!this.ticking)
|
|
1785
|
+
for (const id of backedUpPlayerIds)
|
|
1786
|
+
this.stateSkippedPlayerIds.add(id);
|
|
1787
|
+
return backedUpPlayerIds;
|
|
1788
|
+
}
|
|
1789
|
+
/** A round of state sync (stateSyncEnabled): each player's view, in full or what changed since their last. */
|
|
1790
|
+
syncState() {
|
|
1791
|
+
const tracker = this.stateTracker;
|
|
1792
|
+
if (!tracker || this.ended)
|
|
1793
|
+
return;
|
|
1794
|
+
const viewerIds = this.stateViewerIds();
|
|
1795
|
+
const stateView = this.server.stateView;
|
|
1796
|
+
const state = this.state;
|
|
1797
|
+
const round = tracker.round(state, viewerIds, stateView ? (viewerId) => stateView(state, (this.players.get(viewerId) ?? this.spectators.get(viewerId)), this.stateViewRoom) : null, this.backedUpViewers(viewerIds));
|
|
1798
|
+
for (const problem of round.problems)
|
|
1799
|
+
console.error(`Game server error: ${problem}`);
|
|
1800
|
+
for (const { playerIds, frame } of round.frames)
|
|
1801
|
+
this.sendState(playerIds, frame);
|
|
1802
|
+
}
|
|
1803
|
+
/** A round of state sync once the handler running now is done, for a room with no handler about to run. */
|
|
1804
|
+
syncSoon() {
|
|
1805
|
+
if (!this.server.stateSyncEnabled || this.ended)
|
|
1806
|
+
return;
|
|
1807
|
+
const syncs = this.server[SYNCS_STATE];
|
|
1808
|
+
if (syncs)
|
|
1809
|
+
return this.run(() => syncs(this.state, this.api), "stateSync");
|
|
1810
|
+
if (this.queue)
|
|
1811
|
+
this.queue = this.queue.then(() => this.syncState());
|
|
1812
|
+
else
|
|
1813
|
+
this.soon(() => this.syncState());
|
|
1814
|
+
}
|
|
1815
|
+
/** For a game server that runs state sync itself: who's backed up this round, and who needs a full copy (said once). */
|
|
1816
|
+
stateViewers() {
|
|
1817
|
+
const fullCopyPlayerIds = [...this.fullCopyPlayerIds].filter((id) => this.isViewer(id));
|
|
1818
|
+
this.fullCopyPlayerIds.clear();
|
|
1819
|
+
return { backedUpPlayerIds: [...this.backedUpViewers(this.stateViewerIds())], fullCopyPlayerIds };
|
|
1820
|
+
}
|
|
1821
|
+
/** A frame from a game server that runs state sync itself, checked like anything a game hands the room. */
|
|
1822
|
+
sendStateFrom(playerIds, frame) {
|
|
1823
|
+
if (!Array.isArray(playerIds) || !frame || typeof frame !== "object")
|
|
1824
|
+
return;
|
|
1825
|
+
const keys = Object.keys(frame);
|
|
1826
|
+
if (keys.length !== 1 || (keys[0] !== "full" && !(keys[0] === "changes" && Array.isArray(frame.changes))))
|
|
1827
|
+
return;
|
|
1828
|
+
this.sendState(playerIds.filter((id) => typeof id === "string" && this.isViewer(id)), frame);
|
|
1829
|
+
}
|
|
1830
|
+
/** Send a state frame to players in the match, with the room's clock, counted as state sync's traffic. */
|
|
1831
|
+
sendState(playerIds, frame) {
|
|
1832
|
+
if (!playerIds.length)
|
|
1833
|
+
return;
|
|
1834
|
+
this.sendEach(playerIds, JSON.stringify({ type: "state", serverTimeMs: this.now(), ...frame }), STATE_MESSAGE_TYPE);
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* A game's message or bytes for some players. Reliable ones go to all of them, counting those that went to
|
|
1838
|
+
* a backed-up player; latest-only ones wait in a slot for each backed-up player, replacing an older one there.
|
|
1839
|
+
*/
|
|
1840
|
+
deliver(playerIds, outgoing, delivery) {
|
|
1841
|
+
const ids = playerIds.filter((id) => !this.isLost(id));
|
|
1842
|
+
if (!delivery.isLatestOnly)
|
|
1843
|
+
return this.sendOut(ids, outgoing, ids.filter((id) => this.isBackedUp(id)).length);
|
|
1844
|
+
const ready = [];
|
|
1845
|
+
for (const id of ids) {
|
|
1846
|
+
if (!this.isBackedUp(id)) {
|
|
1847
|
+
// Anything still waiting for them goes first, so the newest arrives last.
|
|
1848
|
+
if (this.latestSlots.has(id))
|
|
1849
|
+
this.sendLatest(id);
|
|
1850
|
+
ready.push(id);
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
let slots = this.latestSlots.get(id);
|
|
1854
|
+
if (!slots)
|
|
1855
|
+
this.latestSlots.set(id, (slots = new Map()));
|
|
1856
|
+
const slotKey = `${"frame" in outgoing ? "bytes" : "message"}:${delivery.latestKey}`;
|
|
1857
|
+
slots.delete(slotKey);
|
|
1858
|
+
slots.set(slotKey, outgoing);
|
|
1859
|
+
if (slots.size > ROOM_LIMITS.maximumLatestOnlySlotCount)
|
|
1860
|
+
slots.delete(slots.keys().next().value);
|
|
1861
|
+
}
|
|
1862
|
+
this.sendOut(ready, outgoing, 0);
|
|
1863
|
+
}
|
|
1864
|
+
/** A player's waiting latest-only messages, least recently replaced first. */
|
|
1865
|
+
sendLatest(playerId) {
|
|
1866
|
+
const slots = this.latestSlots.get(playerId);
|
|
1867
|
+
this.latestSlots.delete(playerId);
|
|
1868
|
+
for (const outgoing of slots?.values() ?? [])
|
|
1869
|
+
this.sendOut([playerId], outgoing, 0);
|
|
1870
|
+
}
|
|
1871
|
+
sendOut(playerIds, outgoing, backedUpMessageCount) {
|
|
1872
|
+
if ("frame" in outgoing)
|
|
1873
|
+
this.sendFrame(playerIds, outgoing.frame, backedUpMessageCount);
|
|
1874
|
+
else
|
|
1875
|
+
this.sendEach(playerIds, outgoing.text, outgoing.messageType, backedUpMessageCount);
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* The match moves to a version published "now": every player hears it's coming at appliesAt, and when
|
|
1879
|
+
* isHeld (a game server with onVersionUpdate) the game server hears it too and may switch sooner.
|
|
1880
|
+
*/
|
|
1881
|
+
startVersionUpdate(versionUpdate, isHeld) {
|
|
1882
|
+
if (this.ended || this.versionUpdate?.versionId === versionUpdate.versionId)
|
|
1883
|
+
return;
|
|
1884
|
+
this.versionUpdate = versionUpdate;
|
|
1885
|
+
this.sendEach(this.everyone(), JSON.stringify({ type: "version_update", step: "coming", ...versionUpdate }));
|
|
1886
|
+
if (isHeld && this.server.onVersionUpdate)
|
|
1887
|
+
this.run(() => this.server.onVersionUpdate(this.state, { ...versionUpdate }, this.api), "onVersionUpdate");
|
|
1888
|
+
}
|
|
1889
|
+
/** The switch is happening: every player hears it just before the room closes their connections. */
|
|
1890
|
+
tellVersionUpdateNow() {
|
|
1891
|
+
if (!this.versionUpdate)
|
|
1892
|
+
return;
|
|
1893
|
+
this.sendEach(this.everyone(), JSON.stringify({ type: "version_update", step: "now", ...this.versionUpdate }));
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Zaisma will end this match at endsAt (an ISO time): every player hears it and onEnding runs, once.
|
|
1897
|
+
* Only Zaisma rooms call it, when the game's hosting pauses.
|
|
1898
|
+
*/
|
|
1899
|
+
startEnding(endsAt) {
|
|
1900
|
+
if (this.ended || this.endsAt !== null)
|
|
1901
|
+
return;
|
|
1902
|
+
this.endsAt = endsAt;
|
|
1903
|
+
this.dirty = true;
|
|
1904
|
+
this.sendEach(this.everyone(), JSON.stringify({ type: "ending", endsAt }));
|
|
1905
|
+
if (this.server.onEnding)
|
|
1906
|
+
this.run(() => this.server.onEnding(this.state, this.api), "onEnding");
|
|
1907
|
+
}
|
|
1908
|
+
/** End the match: everyone hears message and why (endReason), and their connections close. */
|
|
1909
|
+
end(message, endReason) {
|
|
1910
|
+
if (this.ended)
|
|
1911
|
+
return;
|
|
1912
|
+
this.ended = true;
|
|
1913
|
+
this.listingChanged = true;
|
|
1914
|
+
const text = JSON.stringify({ type: "ended", message, endReason });
|
|
1915
|
+
for (const id of [...this.players.keys(), ...this.held.keys(), ...this.spectators.keys()]) {
|
|
1916
|
+
if (this.isLost(id))
|
|
1917
|
+
continue;
|
|
1918
|
+
this.sendText(id, text);
|
|
1919
|
+
this.transport.close(id, ENDED_CLOSE_CODE, "Match ended");
|
|
1920
|
+
}
|
|
1921
|
+
this.players.clear();
|
|
1922
|
+
this.spectators.clear();
|
|
1923
|
+
this.held.clear();
|
|
1924
|
+
this.kept.clear();
|
|
1925
|
+
this.reconnectUntilMs.clear();
|
|
1926
|
+
this.presence.clear();
|
|
1927
|
+
this.latestSlots.clear();
|
|
1928
|
+
this.stateSkippedPlayerIds.clear();
|
|
1929
|
+
}
|
|
1930
|
+
/** The players as every player's game lists them, with why each isn't playing. */
|
|
1931
|
+
list() {
|
|
1932
|
+
return [...this.players.values()].map((player) => ({ ...shownPlayer(player), awayReason: this.kept.get(player.id) ?? null }));
|
|
1933
|
+
}
|
|
1934
|
+
/** Send a binary frame to some players, counting it like text: toward each one's backlog, and the room's traffic. */
|
|
1935
|
+
sendFrame(given, frame, backedUpMessageCount = 0) {
|
|
1936
|
+
if (!this.transport.sendBytes)
|
|
1937
|
+
throw new Error("Sending bytes isn't available here");
|
|
1938
|
+
const playerIds = given.filter((id) => !this.isLost(id));
|
|
1939
|
+
this.countTraffic("outbound", BYTES_MESSAGE_TYPE, playerIds.length, frame.length, backedUpMessageCount);
|
|
1940
|
+
this.depth++;
|
|
1941
|
+
try {
|
|
1942
|
+
for (const id of playerIds) {
|
|
1943
|
+
this.transport.sendBytes(id, frame);
|
|
1944
|
+
this.sent(id, frame.length);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
finally {
|
|
1948
|
+
this.depth--;
|
|
1949
|
+
this.flush();
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
broadcastPlayers() {
|
|
1953
|
+
this.sendEach(this.everyone(), JSON.stringify({ type: "players", players: this.list(), hostPlayerId: this.hostPlayerId }));
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Send one text to several players, counting its bytes once. A player it drops for falling behind
|
|
1957
|
+
* leaves once everyone has it, so nobody hears of the leave before the rest of this text.
|
|
1958
|
+
*/
|
|
1959
|
+
sendEach(playerIds, text, messageType = ZAISMA_MESSAGE_TYPE, backedUpMessageCount = 0) {
|
|
1960
|
+
const byteCount = utf8ByteCount(text);
|
|
1961
|
+
const ids = [...playerIds].filter((id) => !this.isLost(id));
|
|
1962
|
+
this.countTraffic("outbound", messageType, ids.length, byteCount, backedUpMessageCount);
|
|
1963
|
+
this.depth++;
|
|
1964
|
+
try {
|
|
1965
|
+
for (const id of ids)
|
|
1966
|
+
this.sendText(id, text, byteCount, null);
|
|
1967
|
+
}
|
|
1968
|
+
finally {
|
|
1969
|
+
this.depth--;
|
|
1970
|
+
this.flush();
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Send a player a text, counting the bytes that went out to them: past
|
|
1975
|
+
* ROOM_TUNING.maximumUnansweredSentSizeBytes since the last probe they answered, they're dropped;
|
|
1976
|
+
* every ROOM_TUNING.probeSentSizeBytes, they're probed, so a backed-up connection shows as a late answer.
|
|
1977
|
+
* A player whose connection dropped gets nothing: nothing is sent, counted or probed until they're back.
|
|
1978
|
+
*/
|
|
1979
|
+
sendText(playerId, text, byteCount = utf8ByteCount(text), messageType = ZAISMA_MESSAGE_TYPE) {
|
|
1980
|
+
if (this.isLost(playerId))
|
|
1981
|
+
return;
|
|
1982
|
+
this.transport.send(playerId, text);
|
|
1983
|
+
// sendEach counts its text once for everyone (messageType null).
|
|
1984
|
+
if (messageType !== null)
|
|
1985
|
+
this.countTraffic("outbound", messageType, 1, byteCount);
|
|
1986
|
+
this.sent(playerId, byteCount);
|
|
1987
|
+
}
|
|
1988
|
+
/** Bytes that went out to a player, for the probes and the backlog. */
|
|
1989
|
+
sent(playerId, byteCount) {
|
|
1990
|
+
const presence = this.presence.get(playerId);
|
|
1991
|
+
if (!presence)
|
|
1992
|
+
return;
|
|
1993
|
+
presence.sentByteCount += byteCount;
|
|
1994
|
+
if (presence.sentByteCount - presence.answeredSentByteCount > ROOM_TUNING.maximumUnansweredSentSizeBytes)
|
|
1995
|
+
this.dropBehind(playerId);
|
|
1996
|
+
else if (presence.sentByteCount - presence.lastProbeSentByteCount >= ROOM_TUNING.probeSentSizeBytes)
|
|
1997
|
+
this.probe(playerId);
|
|
1998
|
+
}
|
|
1999
|
+
/** Probe a player for their ping and a clock sample. Probes aren't counted as bytes sent, so a probe never sets off another. */
|
|
2000
|
+
probe(playerId) {
|
|
2001
|
+
const presence = this.presence.get(playerId);
|
|
2002
|
+
if (!presence)
|
|
2003
|
+
return;
|
|
2004
|
+
const probeNumber = ++this.probeCount;
|
|
2005
|
+
const nowMs = this.now();
|
|
2006
|
+
presence.unansweredProbes.set(probeNumber, { sentAtMs: nowMs, sentByteCount: presence.sentByteCount });
|
|
2007
|
+
if (presence.unansweredProbes.size > ROOM_TUNING.maximumUnansweredProbeCount)
|
|
2008
|
+
presence.unansweredProbes.delete(presence.unansweredProbes.keys().next().value);
|
|
2009
|
+
presence.lastProbeAtMs = nowMs;
|
|
2010
|
+
presence.lastProbeSentByteCount = presence.sentByteCount;
|
|
2011
|
+
const text = JSON.stringify({ type: "probe", probeNumber, serverTimeMs: nowMs, pingDurationMs: presence.pingDurationMs });
|
|
2012
|
+
this.transport.send(playerId, text);
|
|
2013
|
+
this.countTraffic("outbound", ZAISMA_MESSAGE_TYPE, 1, text.length);
|
|
2014
|
+
}
|
|
2015
|
+
/** A ticking room probes each player in the match, and each spectator, every ROOM_TUNING.probeIntervalSec, and drops those who stopped answering. */
|
|
2016
|
+
probeDue() {
|
|
2017
|
+
const nowMs = this.now();
|
|
2018
|
+
for (const id of this.everyone()) {
|
|
2019
|
+
const presence = this.presence.get(id);
|
|
2020
|
+
if (presence && !this.kept.has(id) && nowMs - presence.lastProbeAtMs >= ROOM_TUNING.probeIntervalSec * 1000)
|
|
2021
|
+
this.probe(id);
|
|
2022
|
+
}
|
|
2023
|
+
for (const id of [...this.presence.keys()])
|
|
2024
|
+
this.checkAnswers(id);
|
|
2025
|
+
}
|
|
2026
|
+
/** A player whose oldest unanswered probe is ROOM_TUNING.probeAnswerTimeoutSec old is dropped. */
|
|
2027
|
+
checkAnswers(playerId) {
|
|
2028
|
+
if (this.isLost(playerId))
|
|
2029
|
+
return;
|
|
2030
|
+
const oldest = this.presence.get(playerId)?.unansweredProbes.values().next().value;
|
|
2031
|
+
if (oldest && this.now() - oldest.sentAtMs >= ROOM_TUNING.probeAnswerTimeoutSec * 1000)
|
|
2032
|
+
this.dropBehind(playerId);
|
|
2033
|
+
}
|
|
2034
|
+
/** A probe answer: the player's ping moves a quarter of the way to this sample. False if it answers no probe of theirs. */
|
|
2035
|
+
answer(playerId, probeNumber) {
|
|
2036
|
+
const presence = this.presence.get(playerId);
|
|
2037
|
+
const probe = typeof probeNumber === "number" ? presence?.unansweredProbes.get(probeNumber) : undefined;
|
|
2038
|
+
if (!presence || !probe)
|
|
2039
|
+
return false;
|
|
2040
|
+
const sampleMs = this.now() - probe.sentAtMs;
|
|
2041
|
+
const previousMs = presence.pingDurationMs;
|
|
2042
|
+
presence.pingDurationMs = Math.round(previousMs === null ? sampleMs : previousMs * 0.75 + sampleMs * 0.25);
|
|
2043
|
+
presence.answeredSentByteCount = probe.sentByteCount;
|
|
2044
|
+
for (const number of [...presence.unansweredProbes.keys()])
|
|
2045
|
+
if (number <= probeNumber)
|
|
2046
|
+
presence.unansweredProbes.delete(number);
|
|
2047
|
+
// The first measurement: probe again at once, so the player's game hears its ping too, even in a room that doesn't tick.
|
|
2048
|
+
if (previousMs === null)
|
|
2049
|
+
this.probe(playerId);
|
|
2050
|
+
// An answer is when a backed-up connection shows it caught up.
|
|
2051
|
+
this.soon(() => this.catchUp());
|
|
2052
|
+
return true;
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* A player whose connection can't keep up: closed at once, so the room holds no growing backlog for
|
|
2056
|
+
* them, and out of the match like any lost connection, once the handler running now is done. Their
|
|
2057
|
+
* game reconnects by itself. The runtime's close event comes later and finds them gone.
|
|
2058
|
+
*/
|
|
2059
|
+
dropBehind(playerId) {
|
|
2060
|
+
if (!this.presence.delete(playerId))
|
|
2061
|
+
return;
|
|
2062
|
+
this.transport.close(playerId, NO_ANSWER_CLOSE_CODE, "Connection too far behind");
|
|
2063
|
+
this.soon(() => this.leave(playerId, "lost"));
|
|
2064
|
+
}
|
|
2065
|
+
/** Token bucket per player: a short burst is fine, a flood is dropped. */
|
|
2066
|
+
take(playerId) {
|
|
2067
|
+
const nowMs = this.now();
|
|
2068
|
+
const bucket = this.buckets.get(playerId) ?? { tokenCount: ROOM_LIMITS.maximumGameMessageBurstCount, filledAtMs: nowMs };
|
|
2069
|
+
bucket.tokenCount = Math.min(ROOM_LIMITS.maximumGameMessageBurstCount, bucket.tokenCount + ((nowMs - bucket.filledAtMs) / 1000) * ROOM_LIMITS.maximumGameMessagesPerSec);
|
|
2070
|
+
bucket.filledAtMs = nowMs;
|
|
2071
|
+
this.buckets.set(playerId, bucket);
|
|
2072
|
+
if (bucket.tokenCount < 1)
|
|
2073
|
+
return false;
|
|
2074
|
+
bucket.tokenCount -= 1;
|
|
2075
|
+
return true;
|
|
2076
|
+
}
|
|
2077
|
+
/** A handler's answer becomes the state if it is one (a plain object or an array); nothing keeps the state, and anything else is a mistake that leaves it. */
|
|
2078
|
+
keepAnswer(handlerName, answer) {
|
|
2079
|
+
// A sandboxed game server hands back its state after every call; one that is not an object or an array and did not change is not an answer.
|
|
2080
|
+
if (answer === undefined || answer === this.state)
|
|
2081
|
+
return;
|
|
2082
|
+
if (handlerName === "setup" || isStateAnswer(answer))
|
|
2083
|
+
this.state = answer;
|
|
2084
|
+
else
|
|
2085
|
+
console.error("Game server error:", badHandlerAnswer(handlerName, answer));
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Run a game server handler. A crash in game code is logged and never takes the room down. State sync's
|
|
2089
|
+
* round follows a turn-based room's handlers and a real-time room's tick (isTick).
|
|
2090
|
+
*/
|
|
2091
|
+
run(handler, handlerName, isTick = false) {
|
|
2092
|
+
const isSyncing = this.stateTracker !== null && (isTick || !this.ticking);
|
|
2093
|
+
if (!this.queue) {
|
|
2094
|
+
let next;
|
|
2095
|
+
this.depth++;
|
|
2096
|
+
try {
|
|
2097
|
+
next = handler();
|
|
2098
|
+
}
|
|
2099
|
+
catch (error) {
|
|
2100
|
+
console.error("Game server error:", error);
|
|
2101
|
+
this.depth--;
|
|
2102
|
+
this.flush();
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
this.depth--;
|
|
2106
|
+
if (!isPromise(next)) {
|
|
2107
|
+
this.keepAnswer(handlerName, next);
|
|
2108
|
+
this.dirty = true;
|
|
2109
|
+
if (isSyncing)
|
|
2110
|
+
this.syncState();
|
|
2111
|
+
this.flush();
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
this.queue = Promise.resolve();
|
|
2115
|
+
const started = next;
|
|
2116
|
+
handler = () => started;
|
|
2117
|
+
}
|
|
2118
|
+
this.waitingCount++;
|
|
2119
|
+
this.queue = this.queue.then(async () => {
|
|
2120
|
+
this.depth++;
|
|
2121
|
+
try {
|
|
2122
|
+
const next = await handler();
|
|
2123
|
+
this.keepAnswer(handlerName, next);
|
|
2124
|
+
this.dirty = true;
|
|
2125
|
+
if (isSyncing)
|
|
2126
|
+
this.syncState();
|
|
2127
|
+
}
|
|
2128
|
+
catch (error) {
|
|
2129
|
+
console.error("Game server error:", error);
|
|
2130
|
+
}
|
|
2131
|
+
finally {
|
|
2132
|
+
this.waitingCount--;
|
|
2133
|
+
this.depth--;
|
|
2134
|
+
this.flush();
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
/** The calls that change the room or send from it: not for canJoin or stateView, which only decide and answer. */
|
|
2140
|
+
const CHANGING_CALLS = ["broadcast", "send", "broadcastBytes", "sendBytes", "end", "setListed", "setOpen", "setFriendJoinsEnabled", "setKept", "setInfo", "reserve", "kick", "moveTo", "applyVersionUpdate"];
|
|
2141
|
+
/** A room that reads like the room but throws room_call_unavailable for every call that changes it, for the handler named. */
|
|
2142
|
+
function readOnlyRoom(room, handlerName) {
|
|
2143
|
+
const view = Object.create(room);
|
|
2144
|
+
for (const call of CHANGING_CALLS) {
|
|
2145
|
+
Object.defineProperty(view, call, {
|
|
2146
|
+
value: () => {
|
|
2147
|
+
throw new GameServerError("room_call_unavailable", `room.${call}`, `${handlerName} can't change the room.`, `Only read from room in ${handlerName}; change the room in the handlers that run after it, like onJoin or onMessage.`);
|
|
2148
|
+
},
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
return view;
|
|
2152
|
+
}
|
|
2153
|
+
/** Whether a handler's answer can be the state: a plain object (prototype Object.prototype or null) or an array. */
|
|
2154
|
+
export function isStateAnswer(given) {
|
|
2155
|
+
if (Array.isArray(given))
|
|
2156
|
+
return true;
|
|
2157
|
+
if (typeof given !== "object" || given === null)
|
|
2158
|
+
return false;
|
|
2159
|
+
const prototype = Object.getPrototypeOf(given);
|
|
2160
|
+
return prototype === Object.prototype || prototype === null;
|
|
2161
|
+
}
|
|
2162
|
+
/** The mistake of a handler answering something that isn't a state; the state stays as it was. */
|
|
2163
|
+
export function badHandlerAnswer(handlerName, answer) {
|
|
2164
|
+
const given = typeof answer === "string" ? `the text ${JSON.stringify(answer.slice(0, 60))}` : answer === null ? "null" : typeof answer === "object" ? `a ${answer.constructor?.name ?? "non-plain"} object` : String(answer).slice(0, 60);
|
|
2165
|
+
return new GameServerError("bad_handler_answer", handlerName, `${handlerName} answered ${given}, so the state stayed as it was.`, "Answer the new state as a plain object or an array, or answer nothing to keep the state.");
|
|
2166
|
+
}
|
|
2167
|
+
function isPromise(given) {
|
|
2168
|
+
return typeof given?.then === "function";
|
|
2169
|
+
}
|
|
2170
|
+
/** A player's presence as they come in: nothing sent and nothing measured yet. A room waking from sleep starts its probes afresh. */
|
|
2171
|
+
function newPresence(seenAtMs, clientIdle) {
|
|
2172
|
+
return { seenAtMs, clientIdle, sentByteCount: 0, answeredSentByteCount: 0, lastProbeSentByteCount: 0, lastProbeAtMs: 0, unansweredProbes: new Map(), pingDurationMs: null };
|
|
2173
|
+
}
|
|
2174
|
+
/** How many bytes a text takes on the wire. */
|
|
2175
|
+
function utf8ByteCount(text) {
|
|
2176
|
+
return new TextEncoder().encode(text).length;
|
|
2177
|
+
}
|
|
2178
|
+
/** The idle check a player's game asked for, from its hello or back message: a timeout within the limits, and whether to check at all. */
|
|
2179
|
+
export function clientIdle(given) {
|
|
2180
|
+
const asked = (given && typeof given === "object" ? given : {});
|
|
2181
|
+
const out = {};
|
|
2182
|
+
if (typeof asked.idleTimeoutSec === "number" && Number.isFinite(asked.idleTimeoutSec)) {
|
|
2183
|
+
out.idleTimeoutSec = Math.min(ROOM_LIMITS.maximumIdleTimeoutSec, Math.max(ROOM_LIMITS.minimumIdleTimeoutSec, Math.round(asked.idleTimeoutSec)));
|
|
2184
|
+
}
|
|
2185
|
+
if (typeof asked.idleCheckEnabled === "boolean")
|
|
2186
|
+
out.idleCheckEnabled = asked.idleCheckEnabled;
|
|
2187
|
+
return out;
|
|
2188
|
+
}
|
|
2189
|
+
/** A name and labels within the limits, with hidden characters dropped. Fields left out stay out. */
|
|
2190
|
+
export function tidyRoomInfo(nameAndLabels) {
|
|
2191
|
+
return checks.tidyRoomInfo(nameAndLabels);
|
|
2192
|
+
}
|