@zaisma/game-server 1.12.0 → 1.14.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.
@@ -3,10 +3,13 @@
3
3
  // Not a command: local-play.js starts it with one JSON argument and hears ZAISMA_LOCAL_RUNNER_READY when it listens.
4
4
  import { createServer } from "node:net";
5
5
  import { pathToFileURL } from "node:url";
6
- import { relay } from "../src/internal.js";
6
+ import { ROOM_MESSAGE_LIMITS, checkRoomData, isRoomCode, relay } from "../src/internal.js";
7
7
  import { localDatabase } from "./node-parts.js";
8
8
  import { startServer } from "./node.js";
9
9
 
10
+ // How startServer hands this runner its way to send room messages as the developer (node.js).
11
+ const LOCAL_BENCH_ROOM_MESSAGE = Symbol.for("zaisma.localBenchRoomMessage");
12
+
10
13
  const config = JSON.parse(process.argv[2] ?? "{}");
11
14
  try {
12
15
  // Said plainly before anything listens: the runner's own listen error would end this process with a stack trace.
@@ -33,6 +36,29 @@ try {
33
36
  for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, stop);
34
37
  // The bench holds this pipe open while it runs: when it ends, however it ended, this game server stops too (rooms save first).
35
38
  process.stdin.once("close", stop);
39
+ // The bench's sends to rooms come as JSON lines on the same pipe, { askNumber, message, roomCode }, each answered on a line of its own.
40
+ let rest = "";
41
+ process.stdin.setEncoding("utf8");
42
+ process.stdin.on("data", (chunk) => {
43
+ rest += chunk;
44
+ const lines = rest.split("\n");
45
+ rest = lines.pop();
46
+ for (const line of lines) {
47
+ let asked;
48
+ try {
49
+ asked = JSON.parse(line);
50
+ } catch {
51
+ continue;
52
+ }
53
+ const checked = checkRoomData(asked?.message, ROOM_MESSAGE_LIMITS.maximumRoomMessageSizeBytes);
54
+ const roomCode = typeof asked?.roomCode === "string" && isRoomCode(asked.roomCode) ? asked.roomCode : null;
55
+ const sending = checked.isValid && asked.message !== null ? Promise.resolve().then(() => running[LOCAL_BENCH_ROOM_MESSAGE](checked.checkedData, roomCode)) : Promise.reject(new Error("message isn't plain JSON of up to 16 KB."));
56
+ void sending.then(
57
+ (answer) => console.log(`ZAISMA_LOCAL_RUNNER_ANSWER ${JSON.stringify({ askNumber: asked.askNumber, ...answer })}`),
58
+ (error) => console.log(`ZAISMA_LOCAL_RUNNER_ANSWER ${JSON.stringify({ askNumber: asked.askNumber, errorMessage: error instanceof Error ? error.message : String(error) })}`),
59
+ );
60
+ }
61
+ });
36
62
  process.stdin.resume();
37
63
  const isListening = () => console.log("ZAISMA_LOCAL_RUNNER_READY");
38
64
  if (running.httpServer.listening) isListening();
package/bin/local-play.js CHANGED
@@ -560,6 +560,14 @@ export async function startLocalPlay(givenOptions) {
560
560
  });
561
561
  }
562
562
  if (path === "/bench/api/log" && method === "GET") return reply(200, { log_lines: [...logLines] });
563
+ // A room message to one room (room_code) or every room with someone in it, as send_room_message and send_to_every_room send on Zaisma (senderKind "developer").
564
+ if (path === "/bench/api/room-messages" && method === "POST") {
565
+ const roomCode = body.room_code === undefined || body.room_code === null ? null : String(body.room_code).toUpperCase();
566
+ const answer = await askRunner(body.message ?? null, roomCode);
567
+ if (!answer) return fail(503, "game_unavailable", "Your game server isn't running.", "Fix what its output says and save the file: it starts again by itself.");
568
+ if (answer.errorMessage) return fail(400, answer.errorMessage.includes("plain JSON") ? "bad_room_message" : "every_room_send_limit", answer.errorMessage, "Send plain JSON up to 16 KB, at most 10 sends to every room a minute.");
569
+ return reply(200, roomCode ? { is_delivered: answer.isDelivered } : { room_count: answer.roomCount });
570
+ }
563
571
  if (path === "/bench/api/players" && method === "GET") return reply(200, { players: playersByName().map(apiPlayer) });
564
572
  // A new player: any of a name, an age band and a social mode, each left out taking the next example name and Zaisma's defaults.
565
573
  if (path === "/bench/api/players" && method === "POST") {
@@ -1445,6 +1453,19 @@ export async function startLocalPlay(givenOptions) {
1445
1453
  // --- The game server: the Node runner, as its own process, so a change starts it again with fresh code ---
1446
1454
  let runner = null;
1447
1455
  let runnerStartCount = 0;
1456
+ // The bench's sends to rooms waiting for the game server's answer, by their number (askRunner).
1457
+ const runnerAsks = new Map();
1458
+ let runnerAskCount = 0;
1459
+ /** A send to rooms through the running game server, as the developer API sends: its answer, or null when no game server is running. */
1460
+ function askRunner(message, roomCode) {
1461
+ if (!runner?.stdin.writable) return Promise.resolve(null);
1462
+ const askNumber = ++runnerAskCount;
1463
+ return new Promise((resolve) => {
1464
+ runnerAsks.set(askNumber, resolve);
1465
+ runner.stdin.write(`${JSON.stringify({ askNumber, message, roomCode })}\n`);
1466
+ setTimeout(() => runnerAsks.delete(askNumber) && resolve(null), RUNNER_START_TIMEOUT_MS).unref?.();
1467
+ });
1468
+ }
1448
1469
  // The game server's source as the running one started with it: a change event with nothing changed (a late or repeated one) starts nothing.
1449
1470
  let runnerSource = null;
1450
1471
  let isClosing = false;
@@ -1483,6 +1504,10 @@ export async function startLocalPlay(givenOptions) {
1483
1504
  isReady = true;
1484
1505
  clearTimeout(timeout);
1485
1506
  done(child);
1507
+ } else if (line.startsWith("ZAISMA_LOCAL_RUNNER_ANSWER ")) {
1508
+ const answer = JSON.parse(line.slice("ZAISMA_LOCAL_RUNNER_ANSWER ".length));
1509
+ runnerAsks.get(answer.askNumber)?.(answer);
1510
+ runnerAsks.delete(answer.askNumber);
1486
1511
  } else log(`${label}${line}`);
1487
1512
  }
1488
1513
  });
package/bin/node-parts.js CHANGED
@@ -289,7 +289,7 @@ export function secretsFromEnvironment() {
289
289
  return Object.freeze(Object.fromEntries(Object.entries(process.env).filter(([, environmentValue]) => typeof environmentValue === "string")));
290
290
  }
291
291
 
292
- const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile", "zaismaKey", "developerApiUrl", "webtransportPort", "certificatePath", "certificateKeyPath"];
292
+ const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile", "zaismaKey", "developerApiUrl", "webtransportPort", "certificatePath", "certificateKeyPath", "identifyPlayer", "outsideGuestsEnabled"];
293
293
 
294
294
  /**
295
295
  * Check what startServer was given: unknown options and options of the wrong kind are a GameServerError,
@@ -325,6 +325,10 @@ export function checkedStartServerOptions(given, issuerUrlDefault) {
325
325
  if (options.roomStateFile !== undefined && options.roomStateFile !== null && !isText(options.roomStateFile)) throw bad("roomStateFile", "a file path", "Give a file path as text, like \"zaisma-room-states.sqlite\", or null to keep no states.");
326
326
  if (options.zaismaKey !== undefined && !isText(options.zaismaKey)) throw bad("zaismaKey", "an API key", "Give an API key with purchase_access write for this game as text, or leave it out to use ZAISMA_KEY.");
327
327
  if (options.developerApiUrl !== undefined && (!isText(options.developerApiUrl) || !URL.canParse(options.developerApiUrl))) throw bad("developerApiUrl", "a URL", 'Give the address of Zaisma\'s developer API, like "https://zaisma.com/api/dev".');
328
+ // Players from outside Zaisma (games played off their Zaisma page): who a playerToken is, and whether players with none come in as guests.
329
+ if (options.identifyPlayer !== undefined && !isFunction(options.identifyPlayer)) throw bad("identifyPlayer", "a function", "Give async (playerToken, { origin }) => ({ id, name }) or null, like signInWithZaismaPlayers({ signInAppIds }) makes.");
330
+ options.outsideGuestsEnabled ??= false;
331
+ if (typeof options.outsideGuestsEnabled !== "boolean") throw bad("outsideGuestsEnabled", "true or false", "Give true to let players with no playerToken in as guests, or false (the default).");
328
332
  // The fast lane (WebTransport): its UDP port and the certificate it holds itself, all three or none.
329
333
  const fastLaneFields = ["webtransportPort", "certificatePath", "certificateKeyPath"];
330
334
  const missingFastLaneFields = fastLaneFields.filter((field) => options[field] === undefined);
package/bin/node.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Server } from "node:http";
2
- import type { GameDatabase, GameServer, PublicKey } from "../src/index.js";
2
+ import type { GameDatabase, GameServer, IdentifyPlayer, PublicKey } from "../src/index.js";
3
3
 
4
4
  /** What startServer takes. Only gameServer and gameSlug are required; the rest have the defaults named here. */
5
5
  export interface StartServerOptions {
@@ -41,6 +41,15 @@ export interface StartServerOptions {
41
41
  certificatePath?: string;
42
42
  /** The fast lane's certificate key, a PEM file (Let's Encrypt's privkey.pem). */
43
43
  certificateKeyPath?: string;
44
+ /**
45
+ * Who a player from outside Zaisma is: a game played off its Zaisma page sends a playerToken in place of a pass
46
+ * (Zaisma.setUpOffZaisma), and this answers { id, name } for it, or null to refuse (bad_player_token). They join
47
+ * with kind "outside_zaisma". signInWithZaismaPlayers({ signInAppIds }) makes one for Sign in with Zaisma; your
48
+ * own checks your game's own accounts. One that throws answers busy. None by default: only Zaisma passes.
49
+ */
50
+ identifyPlayer?: IdentifyPlayer;
51
+ /** Let players from outside Zaisma with no token (playerToken: null) in as guests (guestPlayer): false by default. */
52
+ outsideGuestsEnabled?: boolean;
44
53
  }
45
54
 
46
55
  export interface RunningServer {
package/bin/node.js CHANGED
@@ -7,7 +7,9 @@
7
7
  //
8
8
  // Players connect to wss://your-server/v1/rooms/<game>/<code>, the same address
9
9
  // layout as Zaisma rooms, and prove who they are with Zaisma player passes,
10
- // checked against the keys Zaisma publishes. Put it behind HTTPS (Caddy, nginx,
10
+ // checked against the keys Zaisma publishes. A game played off its Zaisma page sends a playerToken instead
11
+ // (Zaisma.setUpOffZaisma), and identifyPlayer says who that is (signInWithZaismaPlayers for players signed in
12
+ // with Zaisma, or your own accounts); outsideGuestsEnabled lets players with none in as guests. Put it behind HTTPS (Caddy, nginx,
11
13
  // a Cloudflare Tunnel) and add the address to your game's own servers (own_server_urls, or zaisma own-servers).
12
14
  // The game's requests (onRequest) come to POST /v1/requests/<game> on the same port, and its
13
15
  // schedules (onSchedule) run while it's up, at the minutes their crons give in UTC. A Zaisma webhook
@@ -24,7 +26,7 @@ import { createServer } from "node:http";
24
26
  import { fileURLToPath, pathToFileURL } from "node:url";
25
27
  import { resolve } from "node:path";
26
28
  import { readFileSync, realpathSync } from "node:fs";
27
- 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, chatSettingsOf, checkChat, readChatMessage, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, clientIdle, defineGameServer, errorMessage, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
29
+ import { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, ERROR_TEXT, NO_ANSWER_CLOSE_CODE, PASS_ISSUER_URL, OUTSIDE_ZAISMA_CHAT_SETTINGS, PassError, ReceiptError, REMOVED_CLOSE_CODE, REQUEST_ERROR_HTTP_STATUS, REQUEST_LIMITS, ROOM_LIMITS, ROOM_MESSAGE_LIMITS, ROOM_TUNING, WEBHOOK_LIMITS, GameServerError, RoomCore, chatSettingsOf, checkChat, readChatMessage, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, checkedOutsidePlayer, clientIdle, defineGameServer, errorMessage, guestPlayer, newServer, isPlayerToken, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
28
30
  import { OutsideCallError } from "../src/outside-calls.js";
29
31
  import { startFastLane } from "./fast-lane.js";
30
32
  import { checkedStartServerOptions, localDatabase, localRoomStates, passKeysFrom, profilesFrom, receiptKeysFrom, secretsFromEnvironment } from "./node-parts.js";
@@ -74,11 +76,15 @@ const CATCH_UP_MS = 50;
74
76
  * the ZAISMA_KEY environment variable) and developerApiUrl (Zaisma's developer API; by default https://zaisma.com/api/dev).
75
77
  * webtransportPort (a UDP port), certificatePath and certificateKeyPath (the server's certificate and its key,
76
78
  * PEM files), all three or none, also serve the fast lane (WebTransport) beside the WebSocket.
79
+ * identifyPlayer (async (playerToken, { origin }) => ({ id, name }) or null; none by default) says who a player from
80
+ * outside Zaisma is by the playerToken their game sent (signInWithZaismaPlayers makes one for Sign in with Zaisma),
81
+ * and outsideGuestsEnabled (false by default) lets players with no token (playerToken: null) in as guests (guestPlayer).
82
+ * Both join with kind "outside_zaisma".
77
83
  * Answers { close(), httpServer, fastLaneReady }: close() saves every room first and finishes once the server
78
84
  * has stopped; fastLaneReady settles once the fast lane listens (at once without one), or rejects saying why it can't.
79
85
  */
80
86
  export function startServer(options) {
81
- const { gameServer, gameSlug, port: portNumber, origins, issuerUrl, loadPassKeys = passKeysFrom(`${issuerUrl}/.well-known/jwks.json`), loadReceiptKeys = receiptKeysFrom(`${issuerUrl}/.well-known/receipt-jwks.json`), loadProfile = profilesFrom(issuerUrl), database = localDatabase("zaisma-database.sqlite"), secrets = secretsFromEnvironment(), webhookSigningSecret = process.env.ZAISMA_WEBHOOK_SIGNING_SECRET, roomStateFile = "zaisma-room-states.sqlite", zaismaKey = process.env.ZAISMA_KEY, developerApiUrl = "https://zaisma.com/api/dev", webtransportPort, certificatePath, certificateKeyPath } = checkedStartServerOptions(options, PASS_ISSUER_URL);
87
+ const { gameServer, gameSlug, port: portNumber, origins, issuerUrl, loadPassKeys = passKeysFrom(`${issuerUrl}/.well-known/jwks.json`), loadReceiptKeys = receiptKeysFrom(`${issuerUrl}/.well-known/receipt-jwks.json`), loadProfile = profilesFrom(issuerUrl), database = localDatabase("zaisma-database.sqlite"), secrets = secretsFromEnvironment(), webhookSigningSecret = process.env.ZAISMA_WEBHOOK_SIGNING_SECRET, roomStateFile = "zaisma-room-states.sqlite", zaismaKey = process.env.ZAISMA_KEY, developerApiUrl = "https://zaisma.com/api/dev", webtransportPort, certificatePath, certificateKeyPath, identifyPlayer, outsideGuestsEnabled } = checkedStartServerOptions(options, PASS_ISSUER_URL);
82
88
  const definition = defineGameServer(gameServer);
83
89
  // Exactly once needs Zaisma's record of what's handled: without a key, purchases would come twice, so it doesn't start.
84
90
  if ((definition.onPurchase || definition.onRefund) && !zaismaKey) {
@@ -98,8 +104,12 @@ export function startServer(options) {
98
104
  const roomStates = roomStateFile === null ? null : localRoomStates(roomStateFile);
99
105
  const stateSaveIntervalMs = (definition.stateSaveIntervalSec ?? ROOM_LIMITS.defaultStateSaveIntervalSec) * 1000;
100
106
  // Schedules run while this server is up; one that's down when a run is due misses that run.
101
- const schedules = startSchedules(definition, { database, secrets, verifyReceipt: receiptClaims }, (run, failure) => console.error(`Game server error in onSchedule ("${run.name}", due ${run.scheduledAt}):`, failure));
107
+ // What onRequest, onSchedule and onWebhook get: the game's database, secrets, receipt checks and this server's rooms.
108
+ const server = newServer({ database, secrets, verifyReceipt: receiptClaims }, roomReachFrom(null));
109
+ const schedules = startSchedules(definition, server, (run, failure) => console.error(`Game server error in onSchedule ("${run.name}", due ${run.scheduledAt}):`, failure));
102
110
  const rooms = new Map(); // code -> { core, sockets: Map<playerId, ws>, timer }
111
+ // When the game last sent to every room, within the last minute (ROOM_MESSAGE_LIMITS.maximumEveryRoomSendsPerMin).
112
+ let everyRoomSentAtMs = [];
103
113
  const httpServer = createServer((request, response) => answerHttp(request, response));
104
114
  const wss = new WebSocketServer({ server: httpServer, maxPayload: MAXIMUM_SOCKET_MESSAGE_SIZE_BYTES });
105
115
  httpServer.listen(portNumber);
@@ -121,7 +131,7 @@ export function startServer(options) {
121
131
  * account's name from its public profile, and the pass's claims, for blocks between players. Null
122
132
  * for an account deleted since; PassError for a bad pass.
123
133
  */
124
- async function playerFrom(pass) {
134
+ async function passPlayer(pass) {
125
135
  const claims = await verifyPass(pass, await loadPassKeys(), { gameSlug, issuerUrl }).catch(async (error) => {
126
136
  // A new key may have been published since we cached them.
127
137
  if (error instanceof PassError && error.code === "unknown_key") return verifyPass(pass, await loadPassKeys(true), { gameSlug, issuerUrl });
@@ -135,8 +145,43 @@ export function startServer(options) {
135
145
  return name ? { player: { id: claims.sub, name, kind: claims.kind === "account" ? "account" : "guest", ...owned }, claims } : null;
136
146
  }
137
147
 
138
- // Requests from the game to onRequest, outside any room: POST /v1/requests/<game> with { pass, request },
139
- // sent as text/plain JSON so browsers need no preflight. Answers { answer } or { error: { code, message } }.
148
+ /**
149
+ * Who a hello or request is from: by a Zaisma pass, or, from a game played off Zaisma, by the playerToken it
150
+ * sent, as identifyPlayer says, or as a guest when there's none and outsideGuestsEnabled. Answers { player, claims }
151
+ * (claims null for a player from outside Zaisma, who has no pass), or { code } when they can't come in.
152
+ */
153
+ async function playerFrom({ pass, playerToken, origin }) {
154
+ if (typeof pass === "string") {
155
+ try {
156
+ return (await passPlayer(pass)) ?? { code: "bad_pass" };
157
+ } catch (error) {
158
+ if (error instanceof PassError) return { code: "bad_pass" };
159
+ // Zaisma couldn't be reached for the account's name: the player tries again, nothing crashes.
160
+ console.error("Couldn't check a player's pass:", error);
161
+ return { code: "busy" };
162
+ }
163
+ }
164
+ const outside = (player) => ({ player: { ...player, kind: "outside_zaisma" }, claims: null });
165
+ if (playerToken === null) return outsideGuestsEnabled ? outside(guestPlayer()) : { code: "bad_player_token", message: "This server takes players signed in, not guests: the game gives a playerToken." };
166
+ if (!identifyPlayer) return { code: "bad_player_token", message: "This server takes players from Zaisma's pages only." };
167
+ let identified;
168
+ try {
169
+ identified = await identifyPlayer(playerToken, { origin: origin ?? null });
170
+ } catch (error) {
171
+ console.error("Game server error in identifyPlayer:", error);
172
+ return { code: "busy" };
173
+ }
174
+ if (identified === null) return { code: "bad_player_token" };
175
+ const checked = checkedOutsidePlayer(identified);
176
+ if ("mistake" in checked) {
177
+ console.error(`Game server error in identifyPlayer: ${checked.mistake}`);
178
+ return { code: "bad_player_token" };
179
+ }
180
+ return outside(checked.player);
181
+ }
182
+
183
+ // Requests from the game to onRequest, outside any room: POST /v1/requests/<game> with { pass, request }, or
184
+ // { playerToken, request } from a game played off Zaisma, sent as text/plain JSON so browsers need no preflight. Answers { answer } or { error: { code, message } }.
140
185
  function answerHttp(request, response) {
141
186
  if (request.url === "/v1/webhooks") return answerWebhook(request, response);
142
187
  const origin = request.headers.origin;
@@ -167,17 +212,11 @@ export function startServer(options) {
167
212
  request.on("end", async () => {
168
213
  try {
169
214
  if (isTooBig) return fail("too_big");
170
- const body = readRequestBody(text);
215
+ const body = readRequestBody(text, true);
171
216
  if ("code" in body) return fail(body.code);
172
- let checked;
173
- try {
174
- checked = await playerFrom(body.pass);
175
- } catch (error) {
176
- if (error instanceof PassError) return fail("bad_pass");
177
- throw error;
178
- }
179
- if (!checked) return fail("bad_pass");
180
- const outcome = await answerRequest(definition, checked.player, body.request, { database, secrets, verifyReceipt: receiptClaims });
217
+ const checked = await playerFrom({ ...body, origin });
218
+ if (checked.code) return fail(checked.code, checked.message);
219
+ const outcome = await answerRequest(definition, checked.player, body.request, server);
181
220
  if ("answer" in outcome) return reply(200, { answer: outcome.answer });
182
221
  if (outcome.code === "game_server_error") console.error("Game server error in onRequest:", outcome.failure);
183
222
  fail(outcome.code);
@@ -246,7 +285,7 @@ export function startServer(options) {
246
285
  }
247
286
  }
248
287
  if (typeof definition.onWebhook !== "function") return isAccountDeleted ? reply(204, "") : reply(404, "This game server has no onWebhook.");
249
- const outcome = await runWebhook(definition, event, { database, secrets, verifyReceipt: receiptClaims });
288
+ const outcome = await runWebhook(definition, event, server);
250
289
  if ("error" in outcome) {
251
290
  console.error(`Game server error in onWebhook (${checked.event.kind}, ${checked.event.id}):`, outcome.error.message);
252
291
  return reply(500, "onWebhook failed; Zaisma tries the event again later.");
@@ -274,6 +313,7 @@ export function startServer(options) {
274
313
  secrets,
275
314
  verifyReceipt: receiptClaims,
276
315
  purchaseHandling,
316
+ roomReach: roomReachFrom(code),
277
317
  // delivery reaches a fast-lane socket, which sends latest-only ones as datagrams; ws doesn't read it.
278
318
  send: (id, text, delivery) => sockets.get(id)?.send(text, { delivery }),
279
319
  sendBytes: (id, frame, delivery) => sockets.get(id)?.send(frame, { binary: true, delivery }),
@@ -313,11 +353,85 @@ export function startServer(options) {
313
353
  // passTexts: the same passes as the players sent them, for Zaisma's chat filter (checkChat).
314
354
  // savedStateKey: what the room last saved (its state and its match's players), so an unchanged one isn't written again.
315
355
  // seatedAtMs: when each player came into this room on their own, for the game's one-room rule (the oldest seat closes first).
316
- room = { core, sockets, passes: new Map(), passTexts: 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 };
356
+ // joinedAtMs: when each player and spectator joined, for server.getRoom; createdAtMs: when the room started here, for listRooms.
357
+ room = { core, sockets, joinedAtMs: new Map(), createdAtMs: Date.now(), passes: new Map(), passTexts: 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 };
317
358
  rooms.set(code, room);
318
359
  return room;
319
360
  }
320
361
 
362
+ /**
363
+ * Room messages and the game server's room calls among this server's own rooms (room.sendToRoom, sendToEveryRoom and
364
+ * listRooms from the room fromRoomCode; server's, with getRoom, kickFromRoom and endRoom, from null), as Zaisma's rooms
365
+ * do among theirs. A message sent to a kept room asleep in the saved states by its code wakes it.
366
+ */
367
+ function roomReachFrom(fromRoomCode, senderKind = fromRoomCode === null ? "server" : "room") {
368
+ const caller = fromRoomCode === null ? "server" : "room";
369
+ return {
370
+ sendToRoom: async (code, message) => {
371
+ const room = runningRoom(code, true);
372
+ return { isDelivered: room ? deliverRoomMessage(code, room, { message, senderKind, fromRoomCode, sentAt: new Date().toISOString() }) : false };
373
+ },
374
+ sendToEveryRoom: async (message, labels) => {
375
+ const nowMs = Date.now();
376
+ everyRoomSentAtMs = everyRoomSentAtMs.filter((sentAtMs) => nowMs - sentAtMs < 60_000);
377
+ if (everyRoomSentAtMs.length >= ROOM_MESSAGE_LIMITS.maximumEveryRoomSendsPerMin) throw new GameServerError("room_call_limit", `${caller}.sendToEveryRoom`, `A game can send to every room up to ${ROOM_MESSAGE_LIMITS.maximumEveryRoomSendsPerMin} times a minute.`, "Put what you send in one message, or wait a minute.");
378
+ everyRoomSentAtMs.push(nowMs);
379
+ const roomMessage = { message, senderKind, fromRoomCode, sentAt: new Date(nowMs).toISOString() };
380
+ let roomCount = 0;
381
+ for (const [code, room] of [...rooms]) {
382
+ if (code !== fromRoomCode && !room.core.ended && room.sockets.size > 0 && hasLabels(room.core.labels, labels) && deliverRoomMessage(code, room, roomMessage)) roomCount++;
383
+ }
384
+ return { roomCount };
385
+ },
386
+ listRooms: async (labels) => ({
387
+ rooms: [...rooms]
388
+ .filter(([, room]) => !room.core.ended && hasLabels(room.core.labels, labels))
389
+ .map(([code, room]) => liveRoomOf(code, room))
390
+ .sort((first, second) => second.createdAt.localeCompare(first.createdAt))
391
+ .slice(0, ROOM_MESSAGE_LIMITS.maximumListedRoomCount),
392
+ }),
393
+ getRoom: async (code) => {
394
+ const room = runningRoom(code, false);
395
+ return room ? { room: liveRoomOf(code, room), players: room.core.roomPlayers((id) => room.joinedAtMs.get(id) ?? room.createdAtMs) } : null;
396
+ },
397
+ kickFromRoom: async (code, playerId, message, isKeptOut) => {
398
+ const room = runningRoom(code, false);
399
+ if (!room || !(room.core.players.has(playerId) || room.core.spectators.has(playerId))) return { isDone: false };
400
+ room.core.kick(playerId, { message, keepOut: isKeptOut });
401
+ pace(code, room);
402
+ return { isDone: true };
403
+ },
404
+ endRoom: async (code, message) => {
405
+ const room = runningRoom(code, false);
406
+ if (!room) return { isDone: false };
407
+ room.core.end(message ?? "The match is over.", "game");
408
+ closeIfEmpty(code, room);
409
+ return { isDone: true };
410
+ },
411
+ };
412
+ }
413
+
414
+ /** A room here by its code that hasn't ended, or, when isWaking, a kept one asleep in the saved states, woken. */
415
+ function runningRoom(code, isWaking) {
416
+ const running = rooms.get(code);
417
+ if (running) return running.core.ended ? null : running;
418
+ if (!isWaking || readSaved(code)?.savedRoom?.isKept !== true) return null;
419
+ return roomFor(code);
420
+ }
421
+
422
+ /** Hand a room a room message; a room woken for it with nobody in it goes back to sleep once its game server is done. */
423
+ function deliverRoomMessage(code, room, roomMessage) {
424
+ const isTaken = room.core.receiveRoomMessage(roomMessage);
425
+ pace(code, room);
426
+ void room.core.settled().then(() => closeIfEmpty(code, room));
427
+ return isTaken;
428
+ }
429
+
430
+ function liveRoomOf(code, room) {
431
+ const core = room.core;
432
+ return { roomCode: code, name: core.name, labels: { ...core.labels }, playerCount: core.players.size, maximumPlayerCount: definition.maximumPlayerCount ?? ROOM_LIMITS.defaultMaximumPlayerCount, spectatorCount: core.spectators.size, isListed: core.isListed, isOpen: core.isOpen, isKept: core.isKept, versionId: null, continent: null, createdAt: new Date(room.createdAtMs).toISOString() };
433
+ }
434
+
321
435
  /** A room's saved state from the file, or null if it has none (or it can't be read). */
322
436
  function readSaved(code) {
323
437
  try {
@@ -433,8 +547,10 @@ export function startServer(options) {
433
547
  if (!started) return;
434
548
  let check = { isAllowed: true, reason: null, message: null, hasSwearing: false, hasSlur: false };
435
549
  if (started.receiverPlayerIds.length) {
436
- const receiverPasses = started.receiverPlayerIds.map((id) => room.passTexts.get(id)).filter((pass) => typeof pass === "string");
437
- check = await checkChat({ text: started.text, senderPass: room.passTexts.get(playerId) ?? "", receiverPasses, isAdultFilterEnabled: definition.adultChatFilterEnabled === true });
550
+ // A player from outside Zaisma goes as null: Zaisma stops their chat with under-18 accounts, and doesn't hear chat among them alone.
551
+ const passOf = (id) => (room.core.players.get(id)?.kind === "outside_zaisma" ? null : (room.passTexts.get(id) ?? ""));
552
+ const receiverPasses = started.receiverPlayerIds.map(passOf);
553
+ check = await checkChat({ text: started.text, senderPass: passOf(playerId), receiverPasses, isAdultFilterEnabled: definition.adultChatFilterEnabled === true });
438
554
  }
439
555
  if (check.isAllowed) room.core.deliverChat(playerId, chat.chatNumber, started.receiverPlayerIds, { text: started.text, hasSwearing: check.hasSwearing, hasSlur: check.hasSlur });
440
556
  else room.core.stopChat(playerId, chat.chatNumber, check.reason ?? "unchecked", check.message ?? "");
@@ -542,13 +658,17 @@ export function startServer(options) {
542
658
  return;
543
659
  }
544
660
  let pass;
661
+ let playerToken;
545
662
  let made = {};
546
663
  // Come to watch, not to play (joinRoom({ isSpectator: true })).
547
664
  let isSpectator = false;
548
665
  try {
549
666
  const hello = JSON.parse(text ?? "");
550
- if (hello.type !== "hello" || typeof hello.pass !== "string") return refuse("no_hello");
551
- pass = hello.pass;
667
+ // A Zaisma pass, or from a game played off Zaisma, its playerToken (null for a guest).
668
+ const isOutside = hello.pass === undefined && isPlayerToken(hello.playerToken);
669
+ if (hello.type !== "hello" || (typeof hello.pass !== "string" && !isOutside)) return refuse("no_hello");
670
+ if (isOutside) playerToken = hello.playerToken;
671
+ else pass = hello.pass;
552
672
  isSpectator = hello.isSpectator === true;
553
673
  if (hello.room && typeof hello.room === "object") made = { ...(hello.room.isListed === true ? { isListed: true } : {}), ...(hello.room.friendJoinsEnabled === false ? { friendJoinsEnabled: false } : {}), ...tidyRoomInfo(hello.room) };
554
674
  made.clientIdle = clientIdle(hello);
@@ -558,16 +678,8 @@ export function startServer(options) {
558
678
  } catch {
559
679
  return refuse("no_hello");
560
680
  }
561
- let checked;
562
- try {
563
- checked = await playerFrom(pass);
564
- } catch (error) {
565
- if (error instanceof PassError) return refuse("bad_pass");
566
- // Zaisma couldn't be reached for the account's name: the player tries again, nothing crashes.
567
- console.error("Couldn't check a player's pass:", error);
568
- return refuse("busy");
569
- }
570
- if (!checked) return refuse("bad_pass");
681
+ const checked = await playerFrom({ pass, playerToken, origin });
682
+ if (checked.code) return refuse(checked.code, checked.message);
571
683
  const { player, claims } = checked;
572
684
  clearTimeout(helloTimer);
573
685
  // A spectator watches a match: one with nobody in it isn't running, and a spectator never starts one.
@@ -584,8 +696,14 @@ export function startServer(options) {
584
696
  // Blocks between accounts come in their passes: this player's and those of everyone the room remembers.
585
697
  // Spectators send nothing, so nothing of theirs reaches a player to block.
586
698
  // Who the player may chat with, from their pass: the room keeps it, and the game never sees it. Spectators send nothing, chat included.
587
- room.core.setChatSettings(player.id, isSpectator ? null : chatSettingsOf(claims));
588
- if (!isSpectator) {
699
+ // A player from outside Zaisma has no pass: no account blocks or friends, and chat as OUTSIDE_ZAISMA_CHAT_SETTINGS says.
700
+ room.core.setChatSettings(player.id, isSpectator ? null : claims ? chatSettingsOf(claims) : OUTSIDE_ZAISMA_CHAT_SETTINGS);
701
+ if (!isSpectator && !claims) {
702
+ room.passes.delete(player.id);
703
+ room.passTexts.delete(player.id);
704
+ room.core.setAccountBlocks(player.id, []);
705
+ room.core.setFriends(player.id, []);
706
+ } else if (!isSpectator) {
589
707
  room.passes.set(player.id, claims);
590
708
  room.passTexts.set(player.id, pass);
591
709
  for (const id of room.passes.keys()) {
@@ -606,6 +724,7 @@ export function startServer(options) {
606
724
  return refuse(joined.code, joined.message);
607
725
  }
608
726
  playerId = player.id;
727
+ room.joinedAtMs.set(player.id, Date.now());
609
728
  room.waiting = false;
610
729
  if (takesSeat) takeSeat(player.id, code, room);
611
730
  pace(code, room);
@@ -657,10 +776,22 @@ export function startServer(options) {
657
776
  wss.close(() => httpServer.close(() => done()));
658
777
  httpServer.closeAllConnections?.();
659
778
  });
660
- return { close, httpServer, fastLaneReady: fastLaneReady.then(() => undefined) };
779
+ const running = { close, httpServer, fastLaneReady: fastLaneReady.then(() => undefined) };
780
+ // The local bench's sends to rooms (POST /bench/api/room-messages), as the developer API sends them: local-play-runner.js reaches it.
781
+ const developerReach = roomReachFrom(null, "developer");
782
+ Object.defineProperty(running, LOCAL_BENCH_ROOM_MESSAGE, { value: (message, roomCode) => (roomCode ? developerReach.sendToRoom(roomCode, message) : developerReach.sendToEveryRoom(message, null)) });
783
+ return running;
661
784
  }
662
785
 
786
+ /** Where the local bench's runner (local-play-runner.js) finds its way to send room messages as the developer: never part of what startServer answers. */
787
+ const LOCAL_BENCH_ROOM_MESSAGE = Symbol.for("zaisma.localBenchRoomMessage");
788
+
663
789
  /** A saved state's key: its JSON, who was in the match and whether the room is kept, since any of them changing is worth a save. */
790
+ /** Whether a room's labels hold every label given (null: every room does). */
791
+ function hasLabels(roomLabels, labels) {
792
+ return !labels || Object.entries(labels).every(([labelName, labelValue]) => roomLabels[labelName] === labelValue);
793
+ }
794
+
664
795
  function stateKeyOf(stateJson, matchPlayers, isKept) {
665
796
  return `${isKept ? "kept " : ""}${matchPlayers.map((player) => player.id).join(" ")}\n${stateJson}`;
666
797
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.12.0",
3
+ "version": "1.14.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",