@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.
- package/bench/brand/favicon-32.png +0 -0
- package/bench/brand/favicon.ico +0 -0
- package/bench/brand/favicon.svg +1 -1
- package/bench/glass.js +8955 -757
- package/bin/local-play-runner.js +27 -1
- package/bin/local-play.js +25 -0
- package/bin/node-parts.js +5 -1
- package/bin/node.d.ts +10 -1
- package/bin/node.js +167 -36
- package/package.json +1 -1
- package/sdk/zaisma.d.ts +80 -8
- package/sdk/zaisma.js +77 -18
- package/src/chat.d.ts +9 -4
- package/src/chat.js +20 -4
- package/src/index.d.ts +2 -1
- package/src/index.js +2 -1
- package/src/internal.d.ts +1 -0
- package/src/internal.js +1 -0
- package/src/outside-players.d.ts +65 -0
- package/src/outside-players.js +162 -0
- package/src/pass.d.ts +7 -2
- package/src/pass.js +1 -1
- package/src/requests.d.ts +45 -4
- package/src/requests.js +15 -8
- package/src/room-calls.d.ts +16 -0
- package/src/room-calls.js +31 -1
- package/src/room.d.ts +155 -4
- package/src/room.js +137 -4
package/bin/local-play-runner.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
}
|