@zaisma/game-server 0.0.1 → 1.0.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 +311 -0
- package/bin/node.d.ts +43 -0
- package/bin/node.js +615 -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 +1462 -0
- package/src/room.js +2192 -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 +112 -0
- package/src/webhooks.js +102 -0
|
@@ -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
|
+
}
|