@zaisma/game-server 1.1.0 → 1.3.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/bin/node-parts.js +20 -5
- package/bin/node.js +53 -9
- package/package.json +1 -1
- package/src/room.d.ts +50 -6
- package/src/room.js +143 -32
- package/src/schedules.js +3 -1
package/bin/node-parts.js
CHANGED
|
@@ -12,14 +12,29 @@ const MAXIMUM_PROFILE_COUNT = 10_000;
|
|
|
12
12
|
/** The format the runner's saved room states are written in. A file with another is dropped, and rooms start fresh. */
|
|
13
13
|
const ROOM_STATES_FORMAT_VERSION_NUMBER = 1;
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** A refresh asked for by a pass with a key ID not in the cache waits this long after the last fetch, so passes with made-up key IDs can't make this fetch on every hello. */
|
|
16
|
+
const MINIMUM_REFRESH_INTERVAL_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch the public keys a JWKS URL publishes, caching them for an hour (fetched again at once when asked to refresh,
|
|
20
|
+
* at most once in MINIMUM_REFRESH_INTERVAL_MS). A fetch that fails keeps answering with the keys already held.
|
|
21
|
+
*/
|
|
16
22
|
function keysFrom(jwksUrl) {
|
|
17
23
|
let cache = null;
|
|
24
|
+
let fetchedAtMs = 0;
|
|
18
25
|
return async (isRefreshing = false) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
26
|
+
const nowMs = Date.now();
|
|
27
|
+
if (cache && !isRefreshing && nowMs - cache.fetchedAtMs < KEYS_KEPT_MS) return cache.keys;
|
|
28
|
+
if (cache && isRefreshing && nowMs - fetchedAtMs < MINIMUM_REFRESH_INTERVAL_MS) return cache.keys;
|
|
29
|
+
fetchedAtMs = nowMs;
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(jwksUrl);
|
|
32
|
+
if (!response.ok) throw new Error(`Couldn't load Zaisma's keys from ${jwksUrl}`);
|
|
33
|
+
cache = { fetchedAtMs: nowMs, keys: (await response.json()).keys };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (cache) return cache.keys;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
23
38
|
return cache.keys;
|
|
24
39
|
};
|
|
25
40
|
}
|
package/bin/node.js
CHANGED
|
@@ -23,8 +23,12 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
23
23
|
import { resolve } from "node:path";
|
|
24
24
|
import { realpathSync } from "node:fs";
|
|
25
25
|
import { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, ERROR_TEXT, NO_ANSWER_CLOSE_CODE, PASS_ISSUER_URL, PassError, ReceiptError, REMOVED_CLOSE_CODE, REQUEST_ERROR_HTTP_STATUS, REQUEST_LIMITS, ROOM_LIMITS, ROOM_TUNING, WEBHOOK_LIMITS, GameServerError, RoomCore, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, clientIdle, defineGameServer, errorMessage, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
|
|
26
|
+
import { OutsideCallError } from "../src/outside-calls.js";
|
|
26
27
|
import { checkedStartServerOptions, localDatabase, localRoomStates, passKeysFrom, profilesFrom, receiptKeysFrom, secretsFromEnvironment } from "./node-parts.js";
|
|
27
28
|
|
|
29
|
+
// Code that names OutsideCallError, as Zaisma's sandbox lets it, moves unchanged: nothing here refuses a call, so instanceof is simply false.
|
|
30
|
+
globalThis.OutsideCallError ??= OutsideCallError;
|
|
31
|
+
|
|
28
32
|
const HELLO_MS = 5000;
|
|
29
33
|
/**
|
|
30
34
|
* How often rooms check on their players (idle, hidden, held seats, players whose time to reconnect ran
|
|
@@ -280,14 +284,15 @@ export function startServer(options) {
|
|
|
280
284
|
.catch((error) => console.error("Couldn't move players:", error));
|
|
281
285
|
},
|
|
282
286
|
},
|
|
283
|
-
// As in Zaisma's rooms: a room that ticks
|
|
284
|
-
// reconnect; a turn-based room comes back with its state and its room settings alone.
|
|
285
|
-
saved ? { state: saved.state, room: saved.savedRoom, ...(definition.ticksPerSec ? { restoreInfo: restoreInfoOf(saved.savedAtMs, Date.now(), null, saved.isSavedEmpty), lostPlayers: saved.matchPlayers } : {}) } : { start },
|
|
287
|
+
// As in Zaisma's rooms: a room that ticks, or a turn-based one that had players in its match, comes back with its state and
|
|
288
|
+
// onRestored, its match's players away until they reconnect; a turn-based room nobody was in comes back with its state and its room settings alone.
|
|
289
|
+
saved ? { state: saved.state, room: saved.savedRoom, ...(definition.ticksPerSec || saved.matchPlayers?.length ? { restoreInfo: restoreInfoOf(saved.savedAtMs, Date.now(), null, saved.isSavedEmpty), lostPlayers: saved.matchPlayers } : {}) } : { start },
|
|
286
290
|
);
|
|
287
291
|
if (saved) core.savedAtMs = saved.savedAtMs;
|
|
288
292
|
// passes: each player's checked pass, newest, for the blocks and friendships between them (passesHaveBlockBetween, passesAreFriends).
|
|
289
293
|
// savedStateKey: what the room last saved (its state and its match's players), so an unchanged one isn't written again.
|
|
290
|
-
|
|
294
|
+
// seatedAtMs: when each player came into this room on their own, for the game's one-room rule (the oldest seat closes first).
|
|
295
|
+
room = { core, sockets, passes: new Map(), seatedAtMs: new Map(), timer: null, probeTimer: null, waiting: false, savedStateKey: saved ? stateKeyOf(saved.stateJson, saved.matchPlayers, saved.savedRoom.isKept === true) : null, hasReportedStateSize: false };
|
|
291
296
|
rooms.set(code, room);
|
|
292
297
|
return room;
|
|
293
298
|
}
|
|
@@ -354,6 +359,8 @@ export function startServer(options) {
|
|
|
354
359
|
if (room.timer) return;
|
|
355
360
|
let lastTickAtMs = Date.now();
|
|
356
361
|
room.core.savedAtMs ??= lastTickAtMs;
|
|
362
|
+
// A tick slower than the probe interval can't probe on time (the player's game reconnects after 8 seconds of silence): check twice an interval on a timer of its own, so a probe is never a whole interval late.
|
|
363
|
+
if (1000 / definition.ticksPerSec > ROOM_TUNING.probeIntervalSec * 1000) room.probeTimer = setInterval(() => room.core.probeDue(), (ROOM_TUNING.probeIntervalSec * 1000) / 2);
|
|
357
364
|
room.timer = setInterval(() => {
|
|
358
365
|
const nowMs = Date.now();
|
|
359
366
|
room.core.tick((nowMs - lastTickAtMs) / 1000);
|
|
@@ -361,12 +368,39 @@ export function startServer(options) {
|
|
|
361
368
|
if (nowMs - room.core.savedAtMs >= stateSaveIntervalMs) saveRoom(code, room);
|
|
362
369
|
}, 1000 / definition.ticksPerSec);
|
|
363
370
|
} else if (room.timer) {
|
|
364
|
-
|
|
365
|
-
room.timer = null;
|
|
371
|
+
stopTimers(room);
|
|
366
372
|
saveRoom(code, room);
|
|
367
373
|
}
|
|
368
374
|
}
|
|
369
375
|
|
|
376
|
+
function stopTimers(room) {
|
|
377
|
+
if (room.timer) clearInterval(room.timer);
|
|
378
|
+
if (room.probeTimer) clearInterval(room.probeTimer);
|
|
379
|
+
room.timer = null;
|
|
380
|
+
room.probeTimer = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A player came into a room on their own: their seats in the game's other rooms here past maximumRoomCountPerPlayer, the oldest
|
|
385
|
+
* first, are given up as in Zaisma's rooms. They hear "Joined from somewhere else", and the game's onLeave runs with reason "left".
|
|
386
|
+
*/
|
|
387
|
+
function takeSeat(playerId, code, room) {
|
|
388
|
+
room.seatedAtMs.set(playerId, Date.now());
|
|
389
|
+
const others = [...rooms]
|
|
390
|
+
.filter(([otherCode, other]) => otherCode !== code && !other.core.ended && other.core.players.has(playerId))
|
|
391
|
+
.sort(([, first], [, second]) => (first.seatedAtMs.get(playerId) ?? 0) - (second.seatedAtMs.get(playerId) ?? 0));
|
|
392
|
+
for (const [otherCode, other] of others.slice(0, Math.max(0, others.length - (room.core.maximumRoomCountPerPlayer - 1)))) {
|
|
393
|
+
const ws = other.sockets.get(playerId);
|
|
394
|
+
other.sockets.delete(playerId);
|
|
395
|
+
other.seatedAtMs.delete(playerId);
|
|
396
|
+
other.core.leave(playerId, "left");
|
|
397
|
+
ws?.close(REMOVED_CLOSE_CODE, "Joined from somewhere else");
|
|
398
|
+
dismissSpectatorsIfAlone(other);
|
|
399
|
+
pace(otherCode, other);
|
|
400
|
+
closeIfEmpty(otherCode, other);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
370
404
|
/** Keep a room for players on their way, even if it empties meanwhile. */
|
|
371
405
|
function wait(code, room, durationMs) {
|
|
372
406
|
room.waiting = true;
|
|
@@ -394,7 +428,7 @@ export function startServer(options) {
|
|
|
394
428
|
// One that empties keeps its saved state for a day (a kept one until it ends); one that ended keeps none.
|
|
395
429
|
function closeIfEmpty(code, room) {
|
|
396
430
|
if (rooms.get(code) !== room || room.waiting || (!room.core.ended && (room.sockets.size > 0 || room.core.heldSeatCount > 0 || room.core.nextReconnectDeadlineMs() !== null))) return;
|
|
397
|
-
|
|
431
|
+
stopTimers(room);
|
|
398
432
|
rooms.delete(code);
|
|
399
433
|
try {
|
|
400
434
|
if (room.core.ended) roomStates?.remove(code);
|
|
@@ -447,7 +481,7 @@ export function startServer(options) {
|
|
|
447
481
|
const helloTimer = setTimeout(() => playerId || refuse("no_hello"), HELLO_MS);
|
|
448
482
|
ws.zaismaSeenAtMs = Date.now();
|
|
449
483
|
|
|
450
|
-
|
|
484
|
+
const onMessage = async (received, isBinary) => {
|
|
451
485
|
const text = isBinary ? null : received.toString();
|
|
452
486
|
ws.zaismaSeenAtMs = Date.now();
|
|
453
487
|
// Games ping every 25 seconds or so, so a connection that stops answering can be dropped.
|
|
@@ -509,6 +543,8 @@ export function startServer(options) {
|
|
|
509
543
|
room.core.setFriends(player.id, [...room.passes].filter(([id, other]) => id !== player.id && passesAreFriends(claims, other)).map(([id]) => id));
|
|
510
544
|
}
|
|
511
545
|
// Rooms here aren't in Zaisma's room lists, but listed, open and closed work the same for the game.
|
|
546
|
+
// Players who come on their own take a seat; those the game moved here (or reserved this room for), reconnects and spectators don't.
|
|
547
|
+
const takesSeat = !isSpectator && !room.core.players.has(player.id) && !room.core.isArrivingByMove(player.id);
|
|
512
548
|
const joined = room.core.join(player, { ...made, isSpectator });
|
|
513
549
|
if (!joined.isAllowed) {
|
|
514
550
|
room.sockets.delete(player.id);
|
|
@@ -517,7 +553,15 @@ export function startServer(options) {
|
|
|
517
553
|
}
|
|
518
554
|
playerId = player.id;
|
|
519
555
|
room.waiting = false;
|
|
556
|
+
if (takesSeat) takeSeat(player.id, code, room);
|
|
520
557
|
pace(code, room);
|
|
558
|
+
};
|
|
559
|
+
// Whatever a message hits, it ends that connection or that message, never the process and the matches on it.
|
|
560
|
+
ws.on("message", (received, isBinary) => {
|
|
561
|
+
onMessage(received, isBinary).catch((error) => {
|
|
562
|
+
console.error("Couldn't handle a message:", error);
|
|
563
|
+
if (!playerId) refuse("busy");
|
|
564
|
+
});
|
|
521
565
|
});
|
|
522
566
|
|
|
523
567
|
// A frame over the size limit (or a broken one) is an error on this one socket: it closes and the close handler below takes it out, the runner keeps going.
|
|
@@ -543,7 +587,7 @@ export function startServer(options) {
|
|
|
543
587
|
clearInterval(catcher);
|
|
544
588
|
schedules.stop();
|
|
545
589
|
for (const [code, room] of rooms) {
|
|
546
|
-
|
|
590
|
+
stopTimers(room);
|
|
547
591
|
saveRoom(code, room);
|
|
548
592
|
}
|
|
549
593
|
// Players' games hear a restart (1012) and reconnect, to the same room once this server is back.
|
package/package.json
CHANGED
package/src/room.d.ts
CHANGED
|
@@ -395,6 +395,13 @@ export interface GameServer<S = any> {
|
|
|
395
395
|
* run, so you can freeze their character; held seats come after (seatHoldDurationSec).
|
|
396
396
|
*/
|
|
397
397
|
reconnectTimeoutSec?: number;
|
|
398
|
+
/**
|
|
399
|
+
* How many of your game's rooms one player may sit in at once: 1 to 10, 1 by default, as on Steam. When
|
|
400
|
+
* a player joins another room of the game, their seat in the oldest is closed ("Joined from somewhere
|
|
401
|
+
* else") and the room's onLeave runs with reason "left". Raise it for a hangout hub beside matches.
|
|
402
|
+
* Moving a player (room.moveTo, room.reserve) and watching (isSpectator) never close a seat.
|
|
403
|
+
*/
|
|
404
|
+
maximumRoomCountPerPlayer?: number;
|
|
398
405
|
/**
|
|
399
406
|
* Send each player the state, then only what changes: a full copy when they join (and after their
|
|
400
407
|
* connection was backed up), then the changes after each tick, or after each handler in a turn-based
|
|
@@ -587,7 +594,7 @@ export declare function readJoinAnswer(answer: unknown): {
|
|
|
587
594
|
mistake: GameServerError;
|
|
588
595
|
};
|
|
589
596
|
/** Everything a game server object may have, in the order the docs give them. Anything else is refused. */
|
|
590
|
-
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "reconnectTimeoutSec", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
597
|
+
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
591
598
|
export declare const ROOM_LIMITS: {
|
|
592
599
|
/** Players in one room: what one room carries at 60 ticks a second (test/load/room-load.test.ts). */
|
|
593
600
|
readonly maximumPlayerCount: 100;
|
|
@@ -628,6 +635,9 @@ export declare const ROOM_LIMITS: {
|
|
|
628
635
|
/** How long a player whose connection dropped stays in the match by default, and at most (reconnectTimeoutSec). */
|
|
629
636
|
readonly defaultReconnectTimeoutSec: 30;
|
|
630
637
|
readonly maximumReconnectTimeoutSec: 300;
|
|
638
|
+
/** Rooms of one game a player sits in at once by default, and at most (maximumRoomCountPerPlayer); a newer seat past it closes the oldest. */
|
|
639
|
+
readonly defaultMaximumRoomCountPerPlayer: 1;
|
|
640
|
+
readonly maximumRoomCountPerPlayer: 10;
|
|
631
641
|
/** How often a real-time room saves its state by default, and the range (stateSaveIntervalSec). */
|
|
632
642
|
readonly defaultStateSaveIntervalSec: 5;
|
|
633
643
|
readonly minimumStateSaveIntervalSec: 1;
|
|
@@ -907,6 +917,8 @@ export interface SavedRoom extends RoomSettings {
|
|
|
907
917
|
expected?: Record<string, Expected>;
|
|
908
918
|
held?: Record<string, Held>;
|
|
909
919
|
kept?: Record<string, AwayReason>;
|
|
920
|
+
/** Players a reserved room was made for who haven't arrived yet: the room keeps remembering them. */
|
|
921
|
+
reservedPlayerIds?: string[];
|
|
910
922
|
/** Players whose connection dropped, with when their time to come back runs out (reconnectTimeoutSec). */
|
|
911
923
|
reconnecting?: Record<string, Reconnecting>;
|
|
912
924
|
lobbyRoomCode?: string;
|
|
@@ -1131,6 +1143,8 @@ export declare class RoomCore<S = any> {
|
|
|
1131
1143
|
listingChanged: boolean;
|
|
1132
1144
|
/** Players who have been in this room, newest last: they may come back after it closes. */
|
|
1133
1145
|
readonly known: Set<string>;
|
|
1146
|
+
/** Players a reserved room was made for who haven't arrived: never forgotten until they do. */
|
|
1147
|
+
private readonly reservedPlayerIds;
|
|
1134
1148
|
/** Players kicked and kept out for the rest of the match, with the message they were given. */
|
|
1135
1149
|
readonly keptOut: Map<string, string>;
|
|
1136
1150
|
/** Players other rooms are sending here (room.moveTo), with a seat each until they arrive. */
|
|
@@ -1184,6 +1198,15 @@ export declare class RoomCore<S = any> {
|
|
|
1184
1198
|
* one after another, each with the state the last one left.
|
|
1185
1199
|
*/
|
|
1186
1200
|
private queue;
|
|
1201
|
+
/** Handlers queued so far, to tell the work a handler scheduled itself from the ticks and messages queued later (settled). */
|
|
1202
|
+
private linkCount;
|
|
1203
|
+
/** The queued handler running now, so what it schedules as it ends knows whose it is. */
|
|
1204
|
+
private runningLinkIndex;
|
|
1205
|
+
private isFlushing;
|
|
1206
|
+
/** Handlers the room has started, so a runtime can tell whether a message started one. */
|
|
1207
|
+
private startedCount;
|
|
1208
|
+
/** Handlers a handler scheduled itself (a kicked player's onLeave), until they finish: settled waits for those of the handlers it waits for. */
|
|
1209
|
+
private readonly followUps;
|
|
1187
1210
|
/** Handlers waiting in the queue, so ticks don't pile up behind a slow game server. */
|
|
1188
1211
|
private waitingCount;
|
|
1189
1212
|
/** Handlers running right now, and what waits for them to finish (a kicked player's onLeave). */
|
|
@@ -1252,9 +1275,25 @@ export declare class RoomCore<S = any> {
|
|
|
1252
1275
|
private sessionBlock;
|
|
1253
1276
|
/** Players in the match who haven't walked away: what room lists and "playing now" count. */
|
|
1254
1277
|
get playingCount(): number;
|
|
1255
|
-
/**
|
|
1278
|
+
/**
|
|
1279
|
+
* Seats the room holds for players on their way (room.moveTo) and for players out of the match for now,
|
|
1280
|
+
* and the seats of players it keeps (dropped, or kept by onAway), who are in players but not in
|
|
1281
|
+
* playingCount: playingCount + heldSeatCount is exactly what door() counts, so a room list never offers a seat the door refuses.
|
|
1282
|
+
*/
|
|
1256
1283
|
get heldSeatCount(): number;
|
|
1257
|
-
/**
|
|
1284
|
+
/** How many game server handlers this room has started. */
|
|
1285
|
+
get handlerStartCount(): number;
|
|
1286
|
+
/** Whether the game server only queues players' input for its next call (a real-time game in a sandbox), so input starts no handler. */
|
|
1287
|
+
get queuesInput(): boolean;
|
|
1288
|
+
/** How many of the game's rooms a player may sit in at once (the option, or one). */
|
|
1289
|
+
get maximumRoomCountPerPlayer(): number;
|
|
1290
|
+
/** Whether a player comes here because the game sent them (room.moveTo) or reserved this room for them: their seat elsewhere stays. */
|
|
1291
|
+
isArrivingByMove(playerId: string): boolean;
|
|
1292
|
+
/**
|
|
1293
|
+
* Resolves once every handler started so far has finished, and the work they scheduled themselves
|
|
1294
|
+
* (a kicked player's onLeave, purchase hand-overs), not the ticks and messages queued after the call:
|
|
1295
|
+
* a ticking room whose game server takes longer than a tick never goes idle. At once for game servers that answer at once.
|
|
1296
|
+
*/
|
|
1258
1297
|
settled(): Promise<void>;
|
|
1259
1298
|
get ticking(): boolean;
|
|
1260
1299
|
/** How many are watching. */
|
|
@@ -1421,7 +1460,7 @@ export declare class RoomCore<S = any> {
|
|
|
1421
1460
|
private limit;
|
|
1422
1461
|
/** Remove a player (room.kick). Their onLeave runs once the handler that kicked them is done. */
|
|
1423
1462
|
kick(playerId: string, kickOptions?: KickOptions): void;
|
|
1424
|
-
/** Keep players out, newest last, up to
|
|
1463
|
+
/** Keep players out, newest last, up to KEPT_OUT_PLAYER_COUNT: well past a full room, so nobody kicked for the match is let back by the cap. */
|
|
1425
1464
|
private keepOut;
|
|
1426
1465
|
/** Run now, or once the handler running now is done, so a handler never runs inside another. */
|
|
1427
1466
|
private soon;
|
|
@@ -1503,8 +1542,11 @@ export declare class RoomCore<S = any> {
|
|
|
1503
1542
|
private sent;
|
|
1504
1543
|
/** Probe a player for their ping and a clock sample. Probes aren't counted as bytes sent, so a probe never sets off another. */
|
|
1505
1544
|
private probe;
|
|
1506
|
-
/**
|
|
1507
|
-
|
|
1545
|
+
/**
|
|
1546
|
+
* A ticking room probes each player in the match, and each spectator, every ROOM_TUNING.probeIntervalSec, and drops those who stopped answering.
|
|
1547
|
+
* The runtime calls this on its own 2-second timer when the room ticks slower than that, so its players never hear 8 seconds of silence.
|
|
1548
|
+
*/
|
|
1549
|
+
probeDue(): void;
|
|
1508
1550
|
/** A player whose oldest unanswered probe is ROOM_TUNING.probeAnswerTimeoutSec old is dropped. */
|
|
1509
1551
|
private checkAnswers;
|
|
1510
1552
|
/** A probe answer: the player's ping moves a quarter of the way to this sample. False if it answers no probe of theirs. */
|
|
@@ -1539,6 +1581,8 @@ export declare class RoomCore<S = any> {
|
|
|
1539
1581
|
* round follows a turn-based room's handlers and a real-time room's tick (isTick).
|
|
1540
1582
|
*/
|
|
1541
1583
|
private run;
|
|
1584
|
+
/** Queue work after the handlers already queued. Work queued as a handler ends is that handler's own (settled waits for it). */
|
|
1585
|
+
private chain;
|
|
1542
1586
|
}
|
|
1543
1587
|
/** Whether a handler's answer can be the state: a plain object (prototype Object.prototype or null) or an array. */
|
|
1544
1588
|
export declare function isStateAnswer(given: unknown): boolean;
|
package/src/room.js
CHANGED
|
@@ -60,6 +60,7 @@ export const GAME_SERVER_OPTIONS = [
|
|
|
60
60
|
"seatHoldDurationSec",
|
|
61
61
|
"stateSyncEnabled",
|
|
62
62
|
"reconnectTimeoutSec",
|
|
63
|
+
"maximumRoomCountPerPlayer",
|
|
63
64
|
"stateSaveIntervalSec",
|
|
64
65
|
"setup",
|
|
65
66
|
"onJoin",
|
|
@@ -124,6 +125,9 @@ export const ROOM_LIMITS = {
|
|
|
124
125
|
/** How long a player whose connection dropped stays in the match by default, and at most (reconnectTimeoutSec). */
|
|
125
126
|
defaultReconnectTimeoutSec: 30,
|
|
126
127
|
maximumReconnectTimeoutSec: 300,
|
|
128
|
+
/** Rooms of one game a player sits in at once by default, and at most (maximumRoomCountPerPlayer); a newer seat past it closes the oldest. */
|
|
129
|
+
defaultMaximumRoomCountPerPlayer: 1,
|
|
130
|
+
maximumRoomCountPerPlayer: 10,
|
|
127
131
|
/** How often a real-time room saves its state by default, and the range (stateSaveIntervalSec). */
|
|
128
132
|
defaultStateSaveIntervalSec: 5,
|
|
129
133
|
minimumStateSaveIntervalSec: 1,
|
|
@@ -222,7 +226,7 @@ export function defineGameServer(server) {
|
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
checkSchedules(server.schedules, typeof server.onSchedule === "function");
|
|
225
|
-
const { maximumPlayerCount, maximumSpectatorCount, ticksPerSec, hostControlsEnabled, idleCheck, idleTimeoutSec, awayMode, seatHoldDurationSec, reconnectTimeoutSec, stateSaveIntervalSec } = server;
|
|
229
|
+
const { maximumPlayerCount, maximumSpectatorCount, ticksPerSec, hostControlsEnabled, idleCheck, idleTimeoutSec, awayMode, seatHoldDurationSec, reconnectTimeoutSec, maximumRoomCountPerPlayer, stateSaveIntervalSec } = server;
|
|
226
230
|
if (maximumPlayerCount !== undefined && !(Number.isInteger(maximumPlayerCount) && maximumPlayerCount >= 1 && maximumPlayerCount <= ROOM_LIMITS.maximumPlayerCount)) {
|
|
227
231
|
throw bad("maximumPlayerCount", `maximumPlayerCount is ${JSON.stringify(maximumPlayerCount)}.`, `Give a whole number from 1 to ${ROOM_LIMITS.maximumPlayerCount}, like 8.`);
|
|
228
232
|
}
|
|
@@ -252,6 +256,9 @@ export function defineGameServer(server) {
|
|
|
252
256
|
if (reconnectTimeoutSec !== undefined && !(typeof reconnectTimeoutSec === "number" && reconnectTimeoutSec >= 0 && reconnectTimeoutSec <= ROOM_LIMITS.maximumReconnectTimeoutSec)) {
|
|
253
257
|
throw bad("reconnectTimeoutSec", `reconnectTimeoutSec is ${JSON.stringify(reconnectTimeoutSec)}.`, `Give seconds from 0 (leave at once) to ${ROOM_LIMITS.maximumReconnectTimeoutSec}, like 30.`);
|
|
254
258
|
}
|
|
259
|
+
if (maximumRoomCountPerPlayer !== undefined && !(Number.isInteger(maximumRoomCountPerPlayer) && maximumRoomCountPerPlayer >= 1 && maximumRoomCountPerPlayer <= ROOM_LIMITS.maximumRoomCountPerPlayer)) {
|
|
260
|
+
throw bad("maximumRoomCountPerPlayer", `maximumRoomCountPerPlayer is ${JSON.stringify(maximumRoomCountPerPlayer)}.`, `Give an integer from 1 to ${ROOM_LIMITS.maximumRoomCountPerPlayer}; leave it out for ${ROOM_LIMITS.defaultMaximumRoomCountPerPlayer}.`);
|
|
261
|
+
}
|
|
255
262
|
if (stateSaveIntervalSec !== undefined && !(typeof stateSaveIntervalSec === "number" && stateSaveIntervalSec >= ROOM_LIMITS.minimumStateSaveIntervalSec && stateSaveIntervalSec <= ROOM_LIMITS.maximumStateSaveIntervalSec)) {
|
|
256
263
|
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}.`);
|
|
257
264
|
}
|
|
@@ -412,6 +419,8 @@ function messageTypeOf(messageData) {
|
|
|
412
419
|
}
|
|
413
420
|
/** 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. */
|
|
414
421
|
const REMEMBERED_PLAYER_COUNT = ROOM_LIMITS.maximumPlayerCount;
|
|
422
|
+
/** How many players a room keeps out (keepOut) for the match: well past what a full room can hold, since a kicked player must stay out for the rest of it. */
|
|
423
|
+
const KEPT_OUT_PLAYER_COUNT = ROOM_LIMITS.maximumPlayerCount * 10;
|
|
415
424
|
const NOBODY_JOINED = { fromRoomCode: null, isReturning: false, isSpectator: false };
|
|
416
425
|
const WATCHING = { fromRoomCode: null, isReturning: false, isSpectator: true };
|
|
417
426
|
/** Two players with a block between them, in either order (player IDs have no spaces). */
|
|
@@ -453,6 +462,8 @@ export class RoomCore {
|
|
|
453
462
|
listingChanged = false;
|
|
454
463
|
/** Players who have been in this room, newest last: they may come back after it closes. */
|
|
455
464
|
known = new Set();
|
|
465
|
+
/** Players a reserved room was made for who haven't arrived: never forgotten until they do. */
|
|
466
|
+
reservedPlayerIds = new Set();
|
|
456
467
|
/** Players kicked and kept out for the rest of the match, with the message they were given. */
|
|
457
468
|
keptOut = new Map();
|
|
458
469
|
/** Players other rooms are sending here (room.moveTo), with a seat each until they arrive. */
|
|
@@ -506,6 +517,15 @@ export class RoomCore {
|
|
|
506
517
|
* one after another, each with the state the last one left.
|
|
507
518
|
*/
|
|
508
519
|
queue = null;
|
|
520
|
+
/** Handlers queued so far, to tell the work a handler scheduled itself from the ticks and messages queued later (settled). */
|
|
521
|
+
linkCount = 0;
|
|
522
|
+
/** The queued handler running now, so what it schedules as it ends knows whose it is. */
|
|
523
|
+
runningLinkIndex = 0;
|
|
524
|
+
isFlushing = false;
|
|
525
|
+
/** Handlers the room has started, so a runtime can tell whether a message started one. */
|
|
526
|
+
startedCount = 0;
|
|
527
|
+
/** Handlers a handler scheduled itself (a kicked player's onLeave), until they finish: settled waits for those of the handlers it waits for. */
|
|
528
|
+
followUps = new Set();
|
|
509
529
|
/** Handlers waiting in the queue, so ticks don't pile up behind a slow game server. */
|
|
510
530
|
waitingCount = 0;
|
|
511
531
|
/** Handlers running right now, and what waits for them to finish (a kicked player's onLeave). */
|
|
@@ -552,6 +572,8 @@ export class RoomCore {
|
|
|
552
572
|
this.labels = saved.labels ?? {};
|
|
553
573
|
for (const id of saved.knownPlayerIds ?? [])
|
|
554
574
|
this.known.add(id);
|
|
575
|
+
for (const id of saved.reservedPlayerIds ?? [])
|
|
576
|
+
this.reservedPlayerIds.add(id);
|
|
555
577
|
this.setupData = saved.setupData ?? null;
|
|
556
578
|
this.keepOut(saved.keptOut);
|
|
557
579
|
for (const [id, expected] of Object.entries(saved.expected ?? {}))
|
|
@@ -590,8 +612,10 @@ export class RoomCore {
|
|
|
590
612
|
this.friendJoinsEnabled = start.friendJoinsEnabled !== false;
|
|
591
613
|
this.name = start.name;
|
|
592
614
|
this.labels = start.labels;
|
|
593
|
-
for (const id of start.playerIds)
|
|
615
|
+
for (const id of start.playerIds) {
|
|
594
616
|
this.known.add(id);
|
|
617
|
+
this.reservedPlayerIds.add(id);
|
|
618
|
+
}
|
|
595
619
|
this.setupData = start.setupData ?? null;
|
|
596
620
|
this.keepOut(start.keptOut);
|
|
597
621
|
this.lobbyRoomCode = start.lobbyRoomCode ?? null;
|
|
@@ -737,6 +761,7 @@ export class RoomCore {
|
|
|
737
761
|
return {
|
|
738
762
|
...this.settings(),
|
|
739
763
|
knownPlayerIds: [...this.known],
|
|
764
|
+
...(this.reservedPlayerIds.size ? { reservedPlayerIds: [...this.reservedPlayerIds] } : {}),
|
|
740
765
|
...(this.setupData !== null ? { setupData: this.setupData } : {}),
|
|
741
766
|
...(this.keptOut.size ? { keptOut: Object.fromEntries(this.keptOut) } : {}),
|
|
742
767
|
...(this.expected.size ? { expected: Object.fromEntries(this.expected) } : {}),
|
|
@@ -853,21 +878,59 @@ export class RoomCore {
|
|
|
853
878
|
get playingCount() {
|
|
854
879
|
return this.players.size - this.kept.size;
|
|
855
880
|
}
|
|
856
|
-
/**
|
|
881
|
+
/**
|
|
882
|
+
* Seats the room holds for players on their way (room.moveTo) and for players out of the match for now,
|
|
883
|
+
* and the seats of players it keeps (dropped, or kept by onAway), who are in players but not in
|
|
884
|
+
* playingCount: playingCount + heldSeatCount is exactly what door() counts, so a room list never offers a seat the door refuses.
|
|
885
|
+
*/
|
|
857
886
|
get heldSeatCount() {
|
|
858
|
-
return this.countHeldSeats();
|
|
887
|
+
return this.countHeldSeats() + this.kept.size;
|
|
888
|
+
}
|
|
889
|
+
/** How many game server handlers this room has started. */
|
|
890
|
+
get handlerStartCount() {
|
|
891
|
+
return this.startedCount;
|
|
892
|
+
}
|
|
893
|
+
/** Whether the game server only queues players' input for its next call (a real-time game in a sandbox), so input starts no handler. */
|
|
894
|
+
get queuesInput() {
|
|
895
|
+
return this.server[QUEUES_INPUT] === true;
|
|
859
896
|
}
|
|
860
|
-
/**
|
|
897
|
+
/** How many of the game's rooms a player may sit in at once (the option, or one). */
|
|
898
|
+
get maximumRoomCountPerPlayer() {
|
|
899
|
+
return this.server.maximumRoomCountPerPlayer ?? ROOM_LIMITS.defaultMaximumRoomCountPerPlayer;
|
|
900
|
+
}
|
|
901
|
+
/** Whether a player comes here because the game sent them (room.moveTo) or reserved this room for them: their seat elsewhere stays. */
|
|
902
|
+
isArrivingByMove(playerId) {
|
|
903
|
+
return this.seat(playerId) !== null || this.reservedPlayerIds.has(playerId);
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Resolves once every handler started so far has finished, and the work they scheduled themselves
|
|
907
|
+
* (a kicked player's onLeave, purchase hand-overs), not the ticks and messages queued after the call:
|
|
908
|
+
* a ticking room whose game server takes longer than a tick never goes idle. At once for game servers that answer at once.
|
|
909
|
+
*/
|
|
861
910
|
async settled() {
|
|
862
|
-
|
|
911
|
+
let horizonLinkIndex = this.linkCount;
|
|
912
|
+
const waited = new Set();
|
|
913
|
+
const tail = this.queue;
|
|
914
|
+
for (;;) {
|
|
863
915
|
if (this.purchaseDeliveries.size)
|
|
864
916
|
await Promise.all([...this.purchaseDeliveries]);
|
|
865
|
-
const
|
|
866
|
-
if (!
|
|
917
|
+
const links = [];
|
|
918
|
+
if (tail && !waited.has(tail))
|
|
919
|
+
links.push(tail);
|
|
920
|
+
for (const followUp of this.followUps) {
|
|
921
|
+
if (followUp.parentLinkIndex > horizonLinkIndex || waited.has(followUp.link))
|
|
922
|
+
continue;
|
|
923
|
+
links.push(followUp.link);
|
|
924
|
+
horizonLinkIndex = Math.max(horizonLinkIndex, followUp.linkIndex);
|
|
925
|
+
}
|
|
926
|
+
if (!links.length) {
|
|
927
|
+
if (!this.purchaseDeliveries.size)
|
|
928
|
+
return;
|
|
867
929
|
continue;
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
930
|
+
}
|
|
931
|
+
for (const link of links)
|
|
932
|
+
waited.add(link);
|
|
933
|
+
await Promise.all(links);
|
|
871
934
|
}
|
|
872
935
|
}
|
|
873
936
|
get ticking() {
|
|
@@ -984,8 +1047,11 @@ export class RoomCore {
|
|
|
984
1047
|
const expected = this.expected.get(id);
|
|
985
1048
|
if (expected && expected.untilMs > this.now())
|
|
986
1049
|
return expected;
|
|
987
|
-
if (expected)
|
|
1050
|
+
if (expected) {
|
|
988
1051
|
this.expected.delete(id);
|
|
1052
|
+
this.listingChanged = true;
|
|
1053
|
+
this.dirty = true;
|
|
1054
|
+
}
|
|
989
1055
|
return null;
|
|
990
1056
|
}
|
|
991
1057
|
/** Seats held for players other rooms are sending and players out of the match, dropping those that ran out. */
|
|
@@ -1019,8 +1085,10 @@ export class RoomCore {
|
|
|
1019
1085
|
else
|
|
1020
1086
|
answer.refusals.push({ playerId: player.id, code: verdict.code, message: verdict.message || ERROR_TEXT[verdict.code] });
|
|
1021
1087
|
}
|
|
1022
|
-
if (answer.movedPlayerIds.length)
|
|
1088
|
+
if (answer.movedPlayerIds.length) {
|
|
1023
1089
|
this.dirty = true;
|
|
1090
|
+
this.listingChanged = true;
|
|
1091
|
+
}
|
|
1024
1092
|
return answer;
|
|
1025
1093
|
}
|
|
1026
1094
|
/**
|
|
@@ -1239,6 +1307,7 @@ export class RoomCore {
|
|
|
1239
1307
|
this.listingChanged = true;
|
|
1240
1308
|
this.dirty = true;
|
|
1241
1309
|
this.run(() => this.server.onAway(this.state, player, this.api, { reason }), "onAway");
|
|
1310
|
+
this.broadcastPlayers();
|
|
1242
1311
|
return;
|
|
1243
1312
|
}
|
|
1244
1313
|
this.out(playerId);
|
|
@@ -1328,9 +1397,17 @@ export class RoomCore {
|
|
|
1328
1397
|
else if (this.ticking && presence.hiddenAtMs !== undefined && nowMs - presence.hiddenAtMs >= ROOM_LIMITS.hiddenTimeoutSec * 1000)
|
|
1329
1398
|
this.walkAway(id, "hidden");
|
|
1330
1399
|
}
|
|
1331
|
-
// Rooms that don't tick check their probes here.
|
|
1332
|
-
for (const id of [...this.presence
|
|
1400
|
+
// Rooms that don't tick check their probes here, and tell players a ping they haven't heard yet.
|
|
1401
|
+
for (const [id, presence] of [...this.presence]) {
|
|
1333
1402
|
this.checkAnswers(id);
|
|
1403
|
+
if (presence.isPingStale && !this.kept.has(id)) {
|
|
1404
|
+
presence.pingProbeAtMs = nowMs;
|
|
1405
|
+
this.probe(id);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
// Seats held for players on their way that ran out leave the room's row.
|
|
1409
|
+
for (const id of [...this.expected.keys()])
|
|
1410
|
+
this.seat(id);
|
|
1334
1411
|
for (const [id, held] of [...this.held]) {
|
|
1335
1412
|
if (held.untilMs > nowMs)
|
|
1336
1413
|
continue;
|
|
@@ -1700,14 +1777,14 @@ export class RoomCore {
|
|
|
1700
1777
|
this.broadcastPlayers();
|
|
1701
1778
|
});
|
|
1702
1779
|
}
|
|
1703
|
-
/** Keep players out, newest last, up to
|
|
1780
|
+
/** Keep players out, newest last, up to KEPT_OUT_PLAYER_COUNT: well past a full room, so nobody kicked for the match is let back by the cap. */
|
|
1704
1781
|
keepOut(players) {
|
|
1705
1782
|
for (const [id, message] of Object.entries(players ?? {})) {
|
|
1706
1783
|
if (typeof message !== "string")
|
|
1707
1784
|
continue;
|
|
1708
1785
|
this.keptOut.delete(id);
|
|
1709
1786
|
this.keptOut.set(id, message.slice(0, ROOM_LIMITS.maximumKickMessageCharacterCount));
|
|
1710
|
-
if (this.keptOut.size >
|
|
1787
|
+
if (this.keptOut.size > KEPT_OUT_PLAYER_COUNT)
|
|
1711
1788
|
this.keptOut.delete(this.keptOut.keys().next().value);
|
|
1712
1789
|
}
|
|
1713
1790
|
}
|
|
@@ -1719,21 +1796,26 @@ export class RoomCore {
|
|
|
1719
1796
|
work();
|
|
1720
1797
|
}
|
|
1721
1798
|
flush() {
|
|
1722
|
-
|
|
1723
|
-
|
|
1799
|
+
const wasFlushing = this.isFlushing;
|
|
1800
|
+
this.isFlushing = true;
|
|
1801
|
+
try {
|
|
1802
|
+
while (!this.depth && this.afterHandler.length)
|
|
1803
|
+
this.afterHandler.shift()();
|
|
1804
|
+
}
|
|
1805
|
+
finally {
|
|
1806
|
+
this.isFlushing = wasFlushing;
|
|
1807
|
+
}
|
|
1724
1808
|
}
|
|
1725
1809
|
remember(id) {
|
|
1726
1810
|
this.known.delete(id);
|
|
1727
1811
|
this.known.add(id);
|
|
1728
|
-
|
|
1729
|
-
if (this.known.size
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1812
|
+
this.reservedPlayerIds.delete(id);
|
|
1813
|
+
if (this.known.size <= REMEMBERED_PLAYER_COUNT)
|
|
1814
|
+
return;
|
|
1815
|
+
// Only players who left are forgotten, the oldest first, past REMEMBERED_PLAYER_COUNT of them: nobody in the match, holding a seat, expected or reserved for drops out of what the room knows of them.
|
|
1816
|
+
const gone = [...this.known].filter((other) => other !== id && !this.players.has(other) && !this.reservedPlayerIds.has(other) && this.holding(other) === null && this.seat(other) === null);
|
|
1817
|
+
for (const other of gone.slice(0, Math.max(0, gone.length - REMEMBERED_PLAYER_COUNT)))
|
|
1818
|
+
this.known.delete(other);
|
|
1737
1819
|
}
|
|
1738
1820
|
tick(tickDurationSec) {
|
|
1739
1821
|
if (this.ended)
|
|
@@ -1830,7 +1912,7 @@ export class RoomCore {
|
|
|
1830
1912
|
if (syncs)
|
|
1831
1913
|
return this.run(() => syncs(this.state, this.api), "stateSync");
|
|
1832
1914
|
if (this.queue)
|
|
1833
|
-
this.
|
|
1915
|
+
this.chain(() => this.syncState());
|
|
1834
1916
|
else
|
|
1835
1917
|
this.soon(() => this.syncState());
|
|
1836
1918
|
}
|
|
@@ -2030,11 +2112,15 @@ export class RoomCore {
|
|
|
2030
2112
|
presence.unansweredProbes.delete(presence.unansweredProbes.keys().next().value);
|
|
2031
2113
|
presence.lastProbeAtMs = nowMs;
|
|
2032
2114
|
presence.lastProbeSentByteCount = presence.sentByteCount;
|
|
2115
|
+
presence.isPingStale = false;
|
|
2033
2116
|
const text = JSON.stringify({ type: "probe", probeNumber, serverTimeMs: nowMs, pingDurationMs: presence.pingDurationMs });
|
|
2034
2117
|
this.transport.send(playerId, text);
|
|
2035
2118
|
this.countTraffic("outbound", ZAISMA_MESSAGE_TYPE, 1, text.length);
|
|
2036
2119
|
}
|
|
2037
|
-
/**
|
|
2120
|
+
/**
|
|
2121
|
+
* A ticking room probes each player in the match, and each spectator, every ROOM_TUNING.probeIntervalSec, and drops those who stopped answering.
|
|
2122
|
+
* The runtime calls this on its own 2-second timer when the room ticks slower than that, so its players never hear 8 seconds of silence.
|
|
2123
|
+
*/
|
|
2038
2124
|
probeDue() {
|
|
2039
2125
|
const nowMs = this.now();
|
|
2040
2126
|
for (const id of this.everyone()) {
|
|
@@ -2069,6 +2155,16 @@ export class RoomCore {
|
|
|
2069
2155
|
// The first measurement: probe again at once, so the player's game hears its ping too, even in a room that doesn't tick.
|
|
2070
2156
|
if (previousMs === null)
|
|
2071
2157
|
this.probe(playerId);
|
|
2158
|
+
else if (!this.ticking && presence.pingDurationMs !== previousMs) {
|
|
2159
|
+
// A room that doesn't tick has no regular probes, so tell their game the new ping: now, at most once every probe interval, and the sweep sends the rest.
|
|
2160
|
+
const nowMs = this.now();
|
|
2161
|
+
if (nowMs - presence.pingProbeAtMs >= ROOM_TUNING.probeIntervalSec * 1000) {
|
|
2162
|
+
presence.pingProbeAtMs = nowMs;
|
|
2163
|
+
this.probe(playerId);
|
|
2164
|
+
}
|
|
2165
|
+
else
|
|
2166
|
+
presence.isPingStale = true;
|
|
2167
|
+
}
|
|
2072
2168
|
// An answer is when a backed-up connection shows it caught up.
|
|
2073
2169
|
this.soon(() => this.catchUp());
|
|
2074
2170
|
return true;
|
|
@@ -2212,6 +2308,7 @@ export class RoomCore {
|
|
|
2212
2308
|
* round follows a turn-based room's handlers and a real-time room's tick (isTick).
|
|
2213
2309
|
*/
|
|
2214
2310
|
run(handler, handlerName, isTick = false) {
|
|
2311
|
+
this.startedCount++;
|
|
2215
2312
|
const isSyncing = this.stateTracker !== null && (isTick || !this.ticking);
|
|
2216
2313
|
if (!this.queue) {
|
|
2217
2314
|
let next;
|
|
@@ -2239,7 +2336,7 @@ export class RoomCore {
|
|
|
2239
2336
|
handler = () => started;
|
|
2240
2337
|
}
|
|
2241
2338
|
this.waitingCount++;
|
|
2242
|
-
this.
|
|
2339
|
+
this.chain(async () => {
|
|
2243
2340
|
this.depth++;
|
|
2244
2341
|
try {
|
|
2245
2342
|
const next = await handler();
|
|
@@ -2258,6 +2355,20 @@ export class RoomCore {
|
|
|
2258
2355
|
}
|
|
2259
2356
|
});
|
|
2260
2357
|
}
|
|
2358
|
+
/** Queue work after the handlers already queued. Work queued as a handler ends is that handler's own (settled waits for it). */
|
|
2359
|
+
chain(work) {
|
|
2360
|
+
const linkIndex = ++this.linkCount;
|
|
2361
|
+
const link = this.queue.then(() => {
|
|
2362
|
+
this.runningLinkIndex = linkIndex;
|
|
2363
|
+
return work();
|
|
2364
|
+
});
|
|
2365
|
+
if (this.isFlushing) {
|
|
2366
|
+
const followUp = { link, linkIndex, parentLinkIndex: this.runningLinkIndex };
|
|
2367
|
+
this.followUps.add(followUp);
|
|
2368
|
+
void link.then(() => this.followUps.delete(followUp));
|
|
2369
|
+
}
|
|
2370
|
+
this.queue = link;
|
|
2371
|
+
}
|
|
2261
2372
|
}
|
|
2262
2373
|
/** The calls that change the room or send from it: not for canJoin or stateView, which only decide and answer. */
|
|
2263
2374
|
const CHANGING_CALLS = ["broadcast", "send", "broadcastBytes", "sendBytes", "end", "setListed", "setOpen", "setFriendJoinsEnabled", "setKept", "setInfo", "reserve", "kick", "moveTo", "applyVersionUpdate"];
|
|
@@ -2292,7 +2403,7 @@ function isPromise(given) {
|
|
|
2292
2403
|
}
|
|
2293
2404
|
/** A player's presence as they come in: nothing sent and nothing measured yet. A room waking from sleep starts its probes afresh. */
|
|
2294
2405
|
function newPresence(seenAtMs, clientIdle) {
|
|
2295
|
-
return { seenAtMs, clientIdle, sentByteCount: 0, answeredSentByteCount: 0, lastProbeSentByteCount: 0, lastProbeAtMs: 0, unansweredProbes: new Map(), pingDurationMs: null };
|
|
2406
|
+
return { seenAtMs, clientIdle, sentByteCount: 0, answeredSentByteCount: 0, lastProbeSentByteCount: 0, lastProbeAtMs: 0, unansweredProbes: new Map(), pingDurationMs: null, pingProbeAtMs: Number.NEGATIVE_INFINITY, isPingStale: false };
|
|
2296
2407
|
}
|
|
2297
2408
|
/** How many bytes a text takes on the wire. */
|
|
2298
2409
|
function utf8ByteCount(text) {
|
package/src/schedules.js
CHANGED
|
@@ -159,7 +159,9 @@ const MAXIMUM_TIMER_MS = 24 * 3_600_000;
|
|
|
159
159
|
export function startSchedules(handler, server, onFailure) {
|
|
160
160
|
const timers = new Set();
|
|
161
161
|
let isStopped = false;
|
|
162
|
-
|
|
162
|
+
// Run from the checked list, so run.cron is the tidied cron on every runtime (get_stats lists it that way too).
|
|
163
|
+
const read = readSchedules(handler.schedules, typeof handler.onSchedule === "function");
|
|
164
|
+
for (const schedule of "schedules" in read ? read.schedules : []) {
|
|
163
165
|
const plan = (afterMs) => {
|
|
164
166
|
const atMs = nextRunAtMs(schedule.cron, afterMs);
|
|
165
167
|
if (atMs === null || isStopped)
|