@zaisma/game-server 0.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -1
- package/bin/node-parts.js +313 -0
- package/bin/node.d.ts +47 -0
- package/bin/node.js +701 -0
- package/package.json +42 -1
- package/src/chat.d.ts +65 -0
- package/src/chat.js +108 -0
- package/src/database.d.ts +150 -0
- package/src/database.js +244 -0
- package/src/dump.d.ts +61 -0
- package/src/dump.js +282 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +12 -0
- package/src/internal.d.ts +13 -0
- package/src/internal.js +15 -0
- package/src/names.d.ts +4 -0
- package/src/names.js +18 -0
- package/src/outside-calls.d.ts +46 -0
- package/src/outside-calls.js +30 -0
- package/src/pass.d.ts +194 -0
- package/src/pass.js +231 -0
- package/src/relay.d.ts +7 -0
- package/src/relay.js +20 -0
- package/src/requests.d.ts +89 -0
- package/src/requests.js +127 -0
- package/src/room-calls.d.ts +92 -0
- package/src/room-calls.js +194 -0
- package/src/room.d.ts +1550 -0
- package/src/room.js +2315 -0
- package/src/schedules.d.ts +82 -0
- package/src/schedules.js +200 -0
- package/src/state-sync.d.ts +57 -0
- package/src/state-sync.js +175 -0
- package/src/webhooks.d.ts +114 -0
- package/src/webhooks.js +102 -0
package/src/requests.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requests: plain calls from a game to its game server's onRequest, outside any room, for a
|
|
3
|
+
* leaderboard, a shop or a daily reward in any game, single-player ones too. Zaisma rooms answer them
|
|
4
|
+
* at rooms.zaisma.com (apps/rooms/src/game-server.ts) and the Node runner on the developer's own
|
|
5
|
+
* server (@zaisma/game-server/node), both at POST /v1/requests/<game>, both checked here so a request that works
|
|
6
|
+
* in one works the same in the other.
|
|
7
|
+
*
|
|
8
|
+
* The game sends { pass, request } as text/plain JSON (so the browser needs no preflight): its player
|
|
9
|
+
* pass, so onRequest knows who's asking, and its request, plain JSON. It gets back { answer }, what
|
|
10
|
+
* onRequest answered, or { error: { code, message } }.
|
|
11
|
+
*/
|
|
12
|
+
export const REQUEST_LIMITS = {
|
|
13
|
+
/** A request, as JSON. */
|
|
14
|
+
maximumRequestSizeBytes: 16 * 1024,
|
|
15
|
+
/**
|
|
16
|
+
* The player pass that comes with it, or with a room's hello: the fullest pass Zaisma makes (1,000 owned
|
|
17
|
+
* items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
|
|
18
|
+
*/
|
|
19
|
+
maximumPassSizeBytes: 96 * 1024,
|
|
20
|
+
/** What onRequest answers, as JSON. */
|
|
21
|
+
maximumAnswerSizeBytes: 1024 * 1024,
|
|
22
|
+
/** How long onRequest may take, awaiting the database included, before the request fails. */
|
|
23
|
+
answerTimeoutSec: 10,
|
|
24
|
+
};
|
|
25
|
+
export const REQUEST_ERROR_TEXT = {
|
|
26
|
+
bad_request: "Requests are JSON with the player's pass and the game's request: { pass, request }.",
|
|
27
|
+
too_big: `Requests are up to ${REQUEST_LIMITS.maximumRequestSizeBytes / 1024} KB.`,
|
|
28
|
+
bad_pass: "Your player pass isn't valid for this game.",
|
|
29
|
+
account_needed: "Playing online needs a Zaisma account. Sign in on the game's page to play.",
|
|
30
|
+
game_not_open_to_player: "This game isn't open to this account.",
|
|
31
|
+
game_unavailable: "This game isn't available right now.",
|
|
32
|
+
no_request_handler: "This game's server doesn't take requests.",
|
|
33
|
+
game_server_error: "The game's server couldn't answer this request.",
|
|
34
|
+
hosting_paused: "This game's hosting is paused. It starts again when the month resets or its developer adds credit.",
|
|
35
|
+
slow_down: "Too many requests. Try again in a minute.",
|
|
36
|
+
bad_origin: "Not allowed from here.",
|
|
37
|
+
busy: "Zaisma is busy. Try again soon.",
|
|
38
|
+
};
|
|
39
|
+
/** The HTTP status each request error answers with. */
|
|
40
|
+
export const REQUEST_ERROR_HTTP_STATUS = {
|
|
41
|
+
bad_request: 400,
|
|
42
|
+
too_big: 413,
|
|
43
|
+
bad_pass: 401,
|
|
44
|
+
account_needed: 403,
|
|
45
|
+
game_not_open_to_player: 403,
|
|
46
|
+
game_unavailable: 404,
|
|
47
|
+
no_request_handler: 404,
|
|
48
|
+
game_server_error: 500,
|
|
49
|
+
hosting_paused: 403,
|
|
50
|
+
slow_down: 429,
|
|
51
|
+
bad_origin: 403,
|
|
52
|
+
busy: 503,
|
|
53
|
+
};
|
|
54
|
+
/** The body of an error answer. message replaces the usual text. */
|
|
55
|
+
export function requestError(code, message) {
|
|
56
|
+
return { error: { code, message: message || REQUEST_ERROR_TEXT[code] } };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
|
|
60
|
+
* (nothing is null). The pass's own size doesn't count.
|
|
61
|
+
*/
|
|
62
|
+
export function readRequestBody(text) {
|
|
63
|
+
let body;
|
|
64
|
+
try {
|
|
65
|
+
body = JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { code: "bad_request" };
|
|
69
|
+
}
|
|
70
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
71
|
+
return { code: "bad_request" };
|
|
72
|
+
const { pass, request } = body;
|
|
73
|
+
if (typeof pass !== "string" || pass.length > REQUEST_LIMITS.maximumPassSizeBytes)
|
|
74
|
+
return { code: "bad_request" };
|
|
75
|
+
const requestText = request === undefined ? "null" : JSON.stringify(request);
|
|
76
|
+
if (new TextEncoder().encode(requestText).length > REQUEST_LIMITS.maximumRequestSizeBytes)
|
|
77
|
+
return { code: "too_big" };
|
|
78
|
+
return { pass, request: request === undefined ? null : request };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* What onRequest answered, as the game gets it: plain JSON (nothing is null), within
|
|
82
|
+
* REQUEST_LIMITS.maximumAnswerSizeBytes, or a mistake for the game server's errors.
|
|
83
|
+
*/
|
|
84
|
+
export function readRequestAnswer(answer) {
|
|
85
|
+
if (answer === undefined || answer === null)
|
|
86
|
+
return { answer: null };
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = JSON.stringify(answer);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
text = undefined;
|
|
93
|
+
}
|
|
94
|
+
if (text === undefined)
|
|
95
|
+
return { mistake: "onRequest answered something that isn't plain JSON, like a function or an object that refers to itself. Answer numbers, text, true or false, null, lists and plain objects." };
|
|
96
|
+
if (new TextEncoder().encode(text).length > REQUEST_LIMITS.maximumAnswerSizeBytes) {
|
|
97
|
+
return { mistake: `onRequest answered over ${REQUEST_LIMITS.maximumAnswerSizeBytes / 1024 / 1024} MB. Answer less, like one page of a leaderboard at a time.` };
|
|
98
|
+
}
|
|
99
|
+
return { answer: JSON.parse(text) };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Run a game server's onRequest for one request, with a time limit, and say what the game gets. A
|
|
103
|
+
* game server without onRequest answers no_request_handler. When it throws, answers something that
|
|
104
|
+
* isn't plain JSON or takes too long, the game gets game_server_error, and failure says why for the
|
|
105
|
+
* game server's errors.
|
|
106
|
+
*/
|
|
107
|
+
export async function answerRequest(handler, player, request, server) {
|
|
108
|
+
if (typeof handler.onRequest !== "function")
|
|
109
|
+
return { code: "no_request_handler" };
|
|
110
|
+
let timer;
|
|
111
|
+
try {
|
|
112
|
+
const answer = await Promise.race([
|
|
113
|
+
Promise.resolve().then(() => handler.onRequest(player, request, server)),
|
|
114
|
+
new Promise((_, reject) => {
|
|
115
|
+
timer = setTimeout(() => reject(new Error(`onRequest didn't answer within ${REQUEST_LIMITS.answerTimeoutSec} seconds`)), REQUEST_LIMITS.answerTimeoutSec * 1000);
|
|
116
|
+
}),
|
|
117
|
+
]);
|
|
118
|
+
const read = readRequestAnswer(answer);
|
|
119
|
+
return "mistake" in read ? { code: "game_server_error", failure: read.mistake } : read;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return { code: "game_server_error", failure: String((error instanceof Error && error.stack) || error) };
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** The room limits the checks read; ROOM_LIMITS has all of them. */
|
|
2
|
+
export interface RoomCallLimits {
|
|
3
|
+
maximumKickMessageCharacterCount: number;
|
|
4
|
+
maximumLatestKeyCharacterCount: number;
|
|
5
|
+
maximumSetupDataSizeBytes: number;
|
|
6
|
+
maximumJoinDataSizeBytes: number;
|
|
7
|
+
maximumNameCharacterCount: number;
|
|
8
|
+
maximumLabelNameCharacterCount: number;
|
|
9
|
+
maximumLabelValueCharacterCount: number;
|
|
10
|
+
maximumLabelCount: number;
|
|
11
|
+
}
|
|
12
|
+
/** The error a mistake in a room call throws: GameServerError, or the sandbox's copy of it. */
|
|
13
|
+
export type RoomCallErrorClass = new (code: "bad_room_call" | "room_call_unavailable", field: string, message: string, fix: string) => Error;
|
|
14
|
+
/** A send's delivery as the room acts on it: latest-only, with its latestKey, or reliable. */
|
|
15
|
+
export type CheckedDelivery = {
|
|
16
|
+
isLatestOnly: false;
|
|
17
|
+
} | {
|
|
18
|
+
isLatestOnly: true;
|
|
19
|
+
latestKey: string;
|
|
20
|
+
};
|
|
21
|
+
export interface CheckedSend {
|
|
22
|
+
delivery: CheckedDelivery;
|
|
23
|
+
fromPlayerId: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface CheckedBroadcast extends CheckedSend {
|
|
26
|
+
audience: "everyone" | "players" | "spectators";
|
|
27
|
+
exceptPlayerId: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
export interface CheckedReserve {
|
|
30
|
+
playerIds: string[];
|
|
31
|
+
setupData: unknown;
|
|
32
|
+
isListed: boolean;
|
|
33
|
+
friendJoinsEnabled: boolean;
|
|
34
|
+
isKept: boolean;
|
|
35
|
+
name: string | null;
|
|
36
|
+
labels: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
export interface CheckedMove {
|
|
39
|
+
playerIds: string[];
|
|
40
|
+
joinData: unknown;
|
|
41
|
+
}
|
|
42
|
+
export interface RoomCallChecks {
|
|
43
|
+
checkBroadcast(message: unknown, broadcastOptions: unknown): CheckedBroadcast;
|
|
44
|
+
checkSend(playerId: unknown, message: unknown, sendOptions: unknown): CheckedSend & {
|
|
45
|
+
playerId: string;
|
|
46
|
+
};
|
|
47
|
+
checkBroadcastBytes(bytes: unknown, broadcastOptions: unknown): CheckedBroadcast & {
|
|
48
|
+
bytes: Uint8Array;
|
|
49
|
+
};
|
|
50
|
+
checkSendBytes(playerId: unknown, bytes: unknown, sendOptions: unknown): CheckedSend & {
|
|
51
|
+
playerId: string;
|
|
52
|
+
bytes: Uint8Array;
|
|
53
|
+
};
|
|
54
|
+
checkKick(playerId: unknown, kickOptions: unknown): {
|
|
55
|
+
playerId: string;
|
|
56
|
+
message: string;
|
|
57
|
+
isKeptOut: boolean;
|
|
58
|
+
};
|
|
59
|
+
checkReserve(reserveOptions: unknown, playerIdsInRoom: readonly string[]): CheckedReserve;
|
|
60
|
+
checkMoveTo(toRoomCode: unknown, moveOptions: unknown, playerIdsInRoom: readonly string[]): CheckedMove & {
|
|
61
|
+
toRoomCode: string;
|
|
62
|
+
};
|
|
63
|
+
checkSetInfo(nameAndLabels: unknown): {
|
|
64
|
+
name?: string | null;
|
|
65
|
+
labels?: Record<string, string>;
|
|
66
|
+
};
|
|
67
|
+
checkEnd(message: unknown): string | undefined;
|
|
68
|
+
/** Text with hidden characters dropped, spaces collapsed, cut to the length. */
|
|
69
|
+
tidy(given: unknown, maximumCharacterCount: number): string;
|
|
70
|
+
/** A name and labels within the limits, with hidden characters dropped. Fields left out stay out. */
|
|
71
|
+
tidyRoomInfo(nameAndLabels: {
|
|
72
|
+
name?: unknown;
|
|
73
|
+
labels?: unknown;
|
|
74
|
+
}): {
|
|
75
|
+
name?: string | null;
|
|
76
|
+
labels?: Record<string, string>;
|
|
77
|
+
};
|
|
78
|
+
/** Plain JSON within maximumSizeBytes, copied, or isValid false. Nothing (undefined or null) is null. */
|
|
79
|
+
checkRoomData(given: unknown, maximumSizeBytes: number): {
|
|
80
|
+
isValid: true;
|
|
81
|
+
checkedData: unknown;
|
|
82
|
+
} | {
|
|
83
|
+
isValid: false;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** The checks for a runtime: pass its GameServerError class and the room limits. */
|
|
87
|
+
export declare function roomCallChecks(ErrorClass: RoomCallErrorClass, limits: RoomCallLimits): RoomCallChecks;
|
|
88
|
+
/**
|
|
89
|
+
* The same parts as an ES module's source, for the game server's sandbox: export default is { checksFor }.
|
|
90
|
+
* A bundler that names functions (esbuild's keepNames) calls __name inside them, so the module gives it.
|
|
91
|
+
*/
|
|
92
|
+
export declare function roomCallChecksModuleSource(): string;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// What a game's room calls (room.broadcast, room.kick, room.reserve …) are checked for. Zaisma's rooms run a
|
|
2
|
+
// game server in a sandbox, where a call only records what the game asked; the same checks run there, at the
|
|
3
|
+
// call, so a mistake throws in the game's own handler and its developer sees it in the game's logs, exactly
|
|
4
|
+
// as on their own server, where the room checks the call itself.
|
|
5
|
+
/** The parts, as one function with nothing from outside it, so its source runs the same in the sandbox. */
|
|
6
|
+
function roomCallChecksParts() {
|
|
7
|
+
// Characters that hide text or flip its direction.
|
|
8
|
+
const HIDDEN = /[\u0000-\u001f\u007f-\u009f----]/g;
|
|
9
|
+
const ROOM_CODE = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{6}$/;
|
|
10
|
+
const BROADCAST_FIELDS = ["exceptPlayerId", "audience", "delivery", "latestKey", "fromPlayerId"];
|
|
11
|
+
const SEND_FIELDS = ["delivery", "latestKey", "fromPlayerId"];
|
|
12
|
+
function checksFor(ErrorClass, limits) {
|
|
13
|
+
const mistake = (call, message, fix) => new ErrorClass("bad_room_call", call, message, fix);
|
|
14
|
+
const tidy = (given, maximumCharacterCount) => String(given ?? "")
|
|
15
|
+
.normalize("NFKC")
|
|
16
|
+
.replace(HIDDEN, "")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim()
|
|
19
|
+
.slice(0, maximumCharacterCount)
|
|
20
|
+
.trim();
|
|
21
|
+
/** A call's options with only the fields it takes: anything else throws, naming the ones it does. */
|
|
22
|
+
const closed = (given, fields, call) => {
|
|
23
|
+
if (given === undefined || given === null)
|
|
24
|
+
return {};
|
|
25
|
+
if (typeof given !== "object" || Array.isArray(given))
|
|
26
|
+
throw mistake(call, `${call}'s options must be an object.`, `Give { ${fields.join(", ")} } or leave them out.`);
|
|
27
|
+
for (const field of Object.keys(given)) {
|
|
28
|
+
if (!fields.includes(field))
|
|
29
|
+
throw mistake(call, `${call} doesn't take ${field}.`, `It takes: ${fields.join(", ")}.`);
|
|
30
|
+
}
|
|
31
|
+
return given;
|
|
32
|
+
};
|
|
33
|
+
const readText = (given, name, call) => {
|
|
34
|
+
if (typeof given !== "string" || !given)
|
|
35
|
+
throw mistake(call, `${call}'s ${name} must be a player's ID, like player.id.`, `Give the ${name} as text.`);
|
|
36
|
+
return given;
|
|
37
|
+
};
|
|
38
|
+
/** A send's delivery and latestKey. latest_only needs its latestKey. */
|
|
39
|
+
const readDelivery = (given, call) => {
|
|
40
|
+
const { delivery, latestKey } = given;
|
|
41
|
+
if (delivery !== undefined && delivery !== "reliable" && delivery !== "latest_only")
|
|
42
|
+
throw mistake(call, `${call}'s delivery is ${JSON.stringify(delivery) ?? "not text"}.`, 'Give "reliable" (the default) or "latest_only".');
|
|
43
|
+
if (latestKey !== undefined && (typeof latestKey !== "string" || latestKey.length > limits.maximumLatestKeyCharacterCount)) {
|
|
44
|
+
throw mistake(call, `${call}'s latestKey must be text up to ${limits.maximumLatestKeyCharacterCount} characters.`, 'Give a short name for the slot this update replaces, like "positions".');
|
|
45
|
+
}
|
|
46
|
+
if (delivery !== "latest_only")
|
|
47
|
+
return { isLatestOnly: false };
|
|
48
|
+
if (latestKey === undefined)
|
|
49
|
+
throw mistake(call, `${call}'s delivery is "latest_only" with no latestKey.`, 'latest_only needs a latestKey: the slot this update replaces, like "position".');
|
|
50
|
+
return { isLatestOnly: true, latestKey: latestKey };
|
|
51
|
+
};
|
|
52
|
+
/** Who a send says it's from (SendOptions.fromPlayerId), or null for the game server. */
|
|
53
|
+
const readFromPlayerId = (given, call) => {
|
|
54
|
+
if (given.fromPlayerId === undefined)
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof given.fromPlayerId !== "string" || !given.fromPlayerId)
|
|
57
|
+
throw mistake(call, `${call}'s fromPlayerId must be a player's ID, like player.id.`, "Give the ID of the player the message is from, or leave it out.");
|
|
58
|
+
return given.fromPlayerId;
|
|
59
|
+
};
|
|
60
|
+
const readAudience = (given, call) => {
|
|
61
|
+
const audience = given.audience;
|
|
62
|
+
if (audience === undefined || audience === "everyone")
|
|
63
|
+
return "everyone";
|
|
64
|
+
if (audience === "players" || audience === "spectators")
|
|
65
|
+
return audience;
|
|
66
|
+
throw mistake(call, `${call}'s audience is ${typeof audience === "string" ? JSON.stringify(audience.slice(0, 40)) : typeof audience}.`, 'Give "everyone", "players" or "spectators", or leave it out for everyone.');
|
|
67
|
+
};
|
|
68
|
+
const readExcept = (given, call) => {
|
|
69
|
+
if (given.exceptPlayerId === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
return readText(given.exceptPlayerId, "exceptPlayerId", call);
|
|
72
|
+
};
|
|
73
|
+
/** Bytes a game handed the room, as a Uint8Array: anything else is a mistake in the game's call. */
|
|
74
|
+
const readBytes = (given, call) => {
|
|
75
|
+
if (given instanceof Uint8Array)
|
|
76
|
+
return given;
|
|
77
|
+
if (given instanceof ArrayBuffer)
|
|
78
|
+
return new Uint8Array(given);
|
|
79
|
+
if (ArrayBuffer.isView(given))
|
|
80
|
+
return new Uint8Array(given.buffer, given.byteOffset, given.byteLength);
|
|
81
|
+
throw mistake(call, `${call} takes bytes.`, "Give a Uint8Array, another typed array or an ArrayBuffer.");
|
|
82
|
+
};
|
|
83
|
+
const checkRoomData = (given, maximumSizeBytes) => {
|
|
84
|
+
if (given === undefined || given === null)
|
|
85
|
+
return { isValid: true, checkedData: null };
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = JSON.stringify(given);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { isValid: false };
|
|
92
|
+
}
|
|
93
|
+
if (text === undefined || new TextEncoder().encode(text).length > maximumSizeBytes)
|
|
94
|
+
return { isValid: false };
|
|
95
|
+
return { isValid: true, checkedData: JSON.parse(text) };
|
|
96
|
+
};
|
|
97
|
+
const tidyRoomInfo = (nameAndLabels) => {
|
|
98
|
+
const out = {};
|
|
99
|
+
if (nameAndLabels.name !== undefined)
|
|
100
|
+
out.name = nameAndLabels.name === null ? null : tidy(nameAndLabels.name, limits.maximumNameCharacterCount) || null;
|
|
101
|
+
if (nameAndLabels.labels !== undefined) {
|
|
102
|
+
const labels = {};
|
|
103
|
+
if (nameAndLabels.labels && typeof nameAndLabels.labels === "object") {
|
|
104
|
+
for (const [labelName, labelValue] of Object.entries(nameAndLabels.labels)) {
|
|
105
|
+
const key = tidy(labelName, limits.maximumLabelNameCharacterCount);
|
|
106
|
+
const shown = typeof labelValue === "string" || typeof labelValue === "number" ? tidy(labelValue, limits.maximumLabelValueCharacterCount) : "";
|
|
107
|
+
if (key && shown && Object.keys(labels).length < limits.maximumLabelCount)
|
|
108
|
+
labels[key] = shown;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
out.labels = labels;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
};
|
|
115
|
+
const checkBroadcast = (message, broadcastOptions) => {
|
|
116
|
+
const given = closed(broadcastOptions, BROADCAST_FIELDS, "room.broadcast");
|
|
117
|
+
return { audience: readAudience(given, "room.broadcast"), exceptPlayerId: readExcept(given, "room.broadcast"), delivery: readDelivery(given, "room.broadcast"), fromPlayerId: readFromPlayerId(given, "room.broadcast") };
|
|
118
|
+
};
|
|
119
|
+
const checkSend = (playerId, message, sendOptions) => {
|
|
120
|
+
const given = closed(sendOptions, SEND_FIELDS, "room.send");
|
|
121
|
+
return { playerId: readText(playerId, "playerId", "room.send"), delivery: readDelivery(given, "room.send"), fromPlayerId: readFromPlayerId(given, "room.send") };
|
|
122
|
+
};
|
|
123
|
+
const checkBroadcastBytes = (bytes, broadcastOptions) => {
|
|
124
|
+
const given = closed(broadcastOptions, BROADCAST_FIELDS, "room.broadcastBytes");
|
|
125
|
+
return {
|
|
126
|
+
bytes: readBytes(bytes, "room.broadcastBytes"),
|
|
127
|
+
audience: readAudience(given, "room.broadcastBytes"),
|
|
128
|
+
exceptPlayerId: readExcept(given, "room.broadcastBytes"),
|
|
129
|
+
delivery: readDelivery(given, "room.broadcastBytes"),
|
|
130
|
+
fromPlayerId: readFromPlayerId(given, "room.broadcastBytes"),
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
const checkSendBytes = (playerId, bytes, sendOptions) => {
|
|
134
|
+
const given = closed(sendOptions, SEND_FIELDS, "room.sendBytes");
|
|
135
|
+
return { playerId: readText(playerId, "playerId", "room.sendBytes"), bytes: readBytes(bytes, "room.sendBytes"), delivery: readDelivery(given, "room.sendBytes"), fromPlayerId: readFromPlayerId(given, "room.sendBytes") };
|
|
136
|
+
};
|
|
137
|
+
const checkKick = (playerId, kickOptions) => {
|
|
138
|
+
const given = closed(kickOptions, ["message", "keepOut"], "room.kick");
|
|
139
|
+
return { playerId: readText(playerId, "playerId", "room.kick"), message: tidy(given.message, limits.maximumKickMessageCharacterCount), isKeptOut: given.keepOut === true };
|
|
140
|
+
};
|
|
141
|
+
const checkReserve = (reserveOptions, playerIdsInRoom) => {
|
|
142
|
+
const given = closed(reserveOptions, ["playerIds", "setupData", "isListed", "friendJoinsEnabled", "isKept", "name", "labels"], "room.reserve");
|
|
143
|
+
const playerIds = [...new Set(Array.isArray(given.playerIds) ? given.playerIds : [])].filter((id) => typeof id === "string" && playerIdsInRoom.includes(id));
|
|
144
|
+
if (!playerIds.length)
|
|
145
|
+
throw mistake("room.reserve", "room.reserve needs playerIds of players who are in this room.", "Give playerIds from room.players.");
|
|
146
|
+
const checked = checkRoomData(given.setupData, limits.maximumSetupDataSizeBytes);
|
|
147
|
+
if (!checked.isValid)
|
|
148
|
+
throw mistake("room.reserve", `room.reserve's setupData isn't plain JSON up to ${limits.maximumSetupDataSizeBytes / 1024} KB.`, "Give numbers, text, true or false, null, lists and plain objects, and keep them small.");
|
|
149
|
+
const nameAndLabels = tidyRoomInfo({ name: given.name ?? null, labels: given.labels ?? {} });
|
|
150
|
+
return {
|
|
151
|
+
playerIds,
|
|
152
|
+
setupData: checked.checkedData,
|
|
153
|
+
isListed: given.isListed === true,
|
|
154
|
+
friendJoinsEnabled: given.friendJoinsEnabled !== false,
|
|
155
|
+
isKept: given.isKept === true,
|
|
156
|
+
name: nameAndLabels.name ?? null,
|
|
157
|
+
labels: nameAndLabels.labels ?? {},
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
const checkMoveTo = (toRoomCode, moveOptions, playerIdsInRoom) => {
|
|
161
|
+
if (typeof toRoomCode !== "string" || !ROOM_CODE.test(toRoomCode))
|
|
162
|
+
throw mistake("room.moveTo", "room.moveTo needs a room code.", "Give the code of a room of this game, like the one room.reserve answered.");
|
|
163
|
+
const given = closed(moveOptions, ["playerIds", "joinData"], "room.moveTo");
|
|
164
|
+
const playerIds = [...new Set(Array.isArray(given.playerIds) ? given.playerIds : [])].filter((id) => typeof id === "string" && playerIdsInRoom.includes(id));
|
|
165
|
+
if (!playerIds.length)
|
|
166
|
+
throw mistake("room.moveTo", "room.moveTo needs playerIds of players who are in this room.", "Give playerIds from room.players.");
|
|
167
|
+
const checked = checkRoomData(given.joinData, limits.maximumJoinDataSizeBytes);
|
|
168
|
+
if (!checked.isValid)
|
|
169
|
+
throw mistake("room.moveTo", `room.moveTo's joinData isn't plain JSON up to ${limits.maximumJoinDataSizeBytes / 1024} KB.`, "Give numbers, text, true or false, null, lists and plain objects, and keep them small.");
|
|
170
|
+
return { toRoomCode, playerIds, joinData: checked.checkedData };
|
|
171
|
+
};
|
|
172
|
+
const checkSetInfo = (nameAndLabels) => tidyRoomInfo(closed(nameAndLabels, ["name", "labels"], "room.setInfo"));
|
|
173
|
+
const checkEnd = (message) => {
|
|
174
|
+
if (message === undefined || message === null)
|
|
175
|
+
return undefined;
|
|
176
|
+
if (typeof message !== "string")
|
|
177
|
+
throw mistake("room.end", "room.end's message must be text.", "Give the text players see, or leave it out.");
|
|
178
|
+
return message.slice(0, limits.maximumKickMessageCharacterCount);
|
|
179
|
+
};
|
|
180
|
+
return { checkBroadcast, checkSend, checkBroadcastBytes, checkSendBytes, checkKick, checkReserve, checkMoveTo, checkSetInfo, checkEnd, tidy, tidyRoomInfo, checkRoomData };
|
|
181
|
+
}
|
|
182
|
+
return { checksFor };
|
|
183
|
+
}
|
|
184
|
+
/** The checks for a runtime: pass its GameServerError class and the room limits. */
|
|
185
|
+
export function roomCallChecks(ErrorClass, limits) {
|
|
186
|
+
return roomCallChecksParts().checksFor(ErrorClass, limits);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The same parts as an ES module's source, for the game server's sandbox: export default is { checksFor }.
|
|
190
|
+
* A bundler that names functions (esbuild's keepNames) calls __name inside them, so the module gives it.
|
|
191
|
+
*/
|
|
192
|
+
export function roomCallChecksModuleSource() {
|
|
193
|
+
return `var __name = (target) => target;\nexport default (${roomCallChecksParts.toString()})();\n`;
|
|
194
|
+
}
|