@zaisma/game-server 1.2.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.js CHANGED
@@ -284,14 +284,15 @@ export function startServer(options) {
284
284
  .catch((error) => console.error("Couldn't move players:", error));
285
285
  },
286
286
  },
287
- // As in Zaisma's rooms: a room that ticks comes back with its state and onRestored, its match's players away until they
288
- // reconnect; a turn-based room comes back with its state and its room settings alone.
289
- 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 },
290
290
  );
291
291
  if (saved) core.savedAtMs = saved.savedAtMs;
292
292
  // passes: each player's checked pass, newest, for the blocks and friendships between them (passesHaveBlockBetween, passesAreFriends).
293
293
  // savedStateKey: what the room last saved (its state and its match's players), so an unchanged one isn't written again.
294
- room = { core, sockets, passes: new Map(), timer: null, waiting: false, savedStateKey: saved ? stateKeyOf(saved.stateJson, saved.matchPlayers, saved.savedRoom.isKept === true) : null, hasReportedStateSize: false };
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 };
295
296
  rooms.set(code, room);
296
297
  return room;
297
298
  }
@@ -358,6 +359,8 @@ export function startServer(options) {
358
359
  if (room.timer) return;
359
360
  let lastTickAtMs = Date.now();
360
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);
361
364
  room.timer = setInterval(() => {
362
365
  const nowMs = Date.now();
363
366
  room.core.tick((nowMs - lastTickAtMs) / 1000);
@@ -365,12 +368,39 @@ export function startServer(options) {
365
368
  if (nowMs - room.core.savedAtMs >= stateSaveIntervalMs) saveRoom(code, room);
366
369
  }, 1000 / definition.ticksPerSec);
367
370
  } else if (room.timer) {
368
- clearInterval(room.timer);
369
- room.timer = null;
371
+ stopTimers(room);
370
372
  saveRoom(code, room);
371
373
  }
372
374
  }
373
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
+
374
404
  /** Keep a room for players on their way, even if it empties meanwhile. */
375
405
  function wait(code, room, durationMs) {
376
406
  room.waiting = true;
@@ -398,7 +428,7 @@ export function startServer(options) {
398
428
  // One that empties keeps its saved state for a day (a kept one until it ends); one that ended keeps none.
399
429
  function closeIfEmpty(code, room) {
400
430
  if (rooms.get(code) !== room || room.waiting || (!room.core.ended && (room.sockets.size > 0 || room.core.heldSeatCount > 0 || room.core.nextReconnectDeadlineMs() !== null))) return;
401
- if (room.timer) clearInterval(room.timer);
431
+ stopTimers(room);
402
432
  rooms.delete(code);
403
433
  try {
404
434
  if (room.core.ended) roomStates?.remove(code);
@@ -513,6 +543,8 @@ export function startServer(options) {
513
543
  room.core.setFriends(player.id, [...room.passes].filter(([id, other]) => id !== player.id && passesAreFriends(claims, other)).map(([id]) => id));
514
544
  }
515
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);
516
548
  const joined = room.core.join(player, { ...made, isSpectator });
517
549
  if (!joined.isAllowed) {
518
550
  room.sockets.delete(player.id);
@@ -521,6 +553,7 @@ export function startServer(options) {
521
553
  }
522
554
  playerId = player.id;
523
555
  room.waiting = false;
556
+ if (takesSeat) takeSeat(player.id, code, room);
524
557
  pace(code, room);
525
558
  };
526
559
  // Whatever a message hits, it ends that connection or that message, never the process and the matches on it.
@@ -554,7 +587,7 @@ export function startServer(options) {
554
587
  clearInterval(catcher);
555
588
  schedules.stop();
556
589
  for (const [code, room] of rooms) {
557
- if (room.timer) clearInterval(room.timer);
590
+ stopTimers(room);
558
591
  saveRoom(code, room);
559
592
  }
560
593
  // Players' games hear a restart (1012) and reconnect, to the same room once this server is back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
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
- /** Seats the room holds for players on their way (room.moveTo) or out of the match for now. */
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
- /** Resolves once every handler started so far has finished (at once for game servers that answer at once). */
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 as many as the room remembers. */
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
- /** A ticking room probes each player in the match, and each spectator, every ROOM_TUNING.probeIntervalSec, and drops those who stopped answering. */
1507
- private probeDue;
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
- /** Seats the room holds for players on their way (room.moveTo) or out of the match for now. */
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
- /** Resolves once every handler started so far has finished (at once for game servers that answer at once). */
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
- while (this.queue || this.purchaseDeliveries.size) {
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 queued = this.queue;
866
- if (!queued)
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
- await queued;
869
- if (this.queue === queued && !this.purchaseDeliveries.size)
870
- return;
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.keys()])
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 as many as the room remembers. */
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 > REMEMBERED_PLAYER_COUNT)
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
- while (!this.depth && this.afterHandler.length)
1723
- this.afterHandler.shift()();
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
- // 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.
1729
- if (this.known.size > REMEMBERED_PLAYER_COUNT) {
1730
- for (const oldest of this.known) {
1731
- if (oldest === id || this.players.has(oldest) || this.holding(oldest) !== null)
1732
- continue;
1733
- this.known.delete(oldest);
1734
- break;
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.queue = this.queue.then(() => this.syncState());
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
- /** A ticking room probes each player in the match, and each spectator, every ROOM_TUNING.probeIntervalSec, and drops those who stopped answering. */
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.queue = this.queue.then(async () => {
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) {