@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zaisma contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
1
|
# @zaisma/game-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Write a game server once; run it in Zaisma's rooms or on your own Node server. Rooms, players, state sync, chat, the game's database and its requests behave the same in both.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// server.ts
|
|
7
|
+
import { defineGameServer } from "@zaisma/game-server";
|
|
8
|
+
|
|
9
|
+
export default defineGameServer({
|
|
10
|
+
ticksPerSec: 20,
|
|
11
|
+
setup: () => ({ scores: {} }),
|
|
12
|
+
onJoin: (state, who, room) => ({ ...state, scores: { ...state.scores, [who.id]: 0 } }),
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
On your own server (Node 22.18 or later):
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
npx @zaisma/game-server@1 ./server.ts --game <your-game-slug> --port 8080
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
or from code:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { startServer } from "@zaisma/game-server/node";
|
|
26
|
+
import gameServer from "./server.ts";
|
|
27
|
+
|
|
28
|
+
const running = startServer({ gameServer, gameSlug: "your-game-slug", port: 8080 });
|
|
29
|
+
// Later: await running.close(); every room saves first.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A mistake in your game server's code becomes a coded error in the game's logs, never a silent success, and the Node runner behaves the same as Zaisma's rooms. Versions follow semver: within 1.x the package only gains things.
|
|
33
|
+
|
|
34
|
+
- The game server, handler by handler: the "Game servers" section of the [README](https://github.com/zaisma/zaisma#readme) and [`docs/DEVELOPER-API.md`](https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md).
|
|
35
|
+
- Every name and option follows [`docs/NAMING.md`](https://github.com/zaisma/zaisma/blob/main/docs/NAMING.md).
|
|
36
|
+
|
|
37
|
+
MIT licensed.
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// The parts of the Node runner (node.js) that stand on their own: where it gets the pass keys and
|
|
2
|
+
// profiles, its local SQLite files, and the checks on its options. Nothing here is part of the
|
|
3
|
+
// public package: tests reach it by path.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { DATABASE_LIMITS, GameServerError, answerDatabase, checkBatch, checkPlayerId, checkStatement, checkedRow, eraseRefused, eraseStatements, readDatabaseAnswer, tablesFromColumns, tooManyRows, TABLE_COLUMNS_SQL } from "../src/internal.js";
|
|
6
|
+
|
|
7
|
+
/** How long the pass keys and the profiles of accounts are kept before they're fetched again. */
|
|
8
|
+
const KEYS_KEPT_MS = 3600_000;
|
|
9
|
+
const PROFILE_KEPT_MS = 300_000;
|
|
10
|
+
/** The most profiles kept at once. */
|
|
11
|
+
const MAXIMUM_PROFILE_COUNT = 10_000;
|
|
12
|
+
/** The format the runner's saved room states are written in. A file with another is dropped, and rooms start fresh. */
|
|
13
|
+
const ROOM_STATES_FORMAT_VERSION_NUMBER = 1;
|
|
14
|
+
|
|
15
|
+
/** Fetch the public keys a JWKS URL publishes, caching them for an hour (fetched again at once when asked to refresh). */
|
|
16
|
+
function keysFrom(jwksUrl) {
|
|
17
|
+
let cache = null;
|
|
18
|
+
return async (isRefreshing = false) => {
|
|
19
|
+
if (cache && !isRefreshing && Date.now() - cache.fetchedAtMs < KEYS_KEPT_MS) return cache.keys;
|
|
20
|
+
const response = await fetch(jwksUrl);
|
|
21
|
+
if (!response.ok) throw new Error(`Couldn't load Zaisma's keys from ${jwksUrl}`);
|
|
22
|
+
cache = { fetchedAtMs: Date.now(), keys: (await response.json()).keys };
|
|
23
|
+
return cache.keys;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The pass keys a JWKS URL publishes (Zaisma's: <issuerUrl>/.well-known/jwks.json), as startServer's loadPassKeys takes them. */
|
|
28
|
+
export function passKeysFrom(jwksUrl) {
|
|
29
|
+
return keysFrom(jwksUrl);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The receipt keys a JWKS URL publishes (Zaisma's: <issuerUrl>/.well-known/receipt-jwks.json), as startServer's loadReceiptKeys takes them. */
|
|
33
|
+
export function receiptKeysFrom(jwksUrl) {
|
|
34
|
+
return keysFrom(jwksUrl);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Look up accounts' public profiles at Zaisma (<issuerUrl>/v1/players/<id>), caching each for 5 minutes.
|
|
39
|
+
* An account's pass proves only who the player is; its name comes from here. Null when no account
|
|
40
|
+
* has that ID (deleted since its pass was made).
|
|
41
|
+
*/
|
|
42
|
+
export function profilesFrom(issuerUrl) {
|
|
43
|
+
const cache = new Map();
|
|
44
|
+
return async (playerId) => {
|
|
45
|
+
const cached = cache.get(playerId);
|
|
46
|
+
if (cached && Date.now() - cached.fetchedAtMs < PROFILE_KEPT_MS) return cached.profile;
|
|
47
|
+
const response = await fetch(`${issuerUrl}/v1/players/${encodeURIComponent(playerId)}`);
|
|
48
|
+
if (response.status !== 200 && response.status !== 404) throw new Error(`Couldn't look up player ${playerId} at ${issuerUrl}`);
|
|
49
|
+
const profile = response.status === 200 ? (await response.json()).player : null;
|
|
50
|
+
if (cache.size > MAXIMUM_PROFILE_COUNT) cache.clear();
|
|
51
|
+
cache.set(playerId, { fetchedAtMs: Date.now(), profile });
|
|
52
|
+
return profile;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A row from node:sqlite, read as BigInts: a whole number in range is a number again, and one past 2^53 stays too big for checkedRow to refuse. */
|
|
57
|
+
function numbersFrom(row) {
|
|
58
|
+
const plain = {};
|
|
59
|
+
for (const [column, columnValue] of Object.entries(row)) plain[column] = typeof columnValue === "bigint" ? Number(columnValue) : columnValue;
|
|
60
|
+
return plain;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The game's database (room.database) in a local SQLite file, opened on first use: the same calls
|
|
65
|
+
* as in Zaisma rooms, checked the same way, each call in one transaction. Zaisma's free limits don't
|
|
66
|
+
* apply here; the file is yours. Copy it to move the data, or open it with any SQLite tool.
|
|
67
|
+
*/
|
|
68
|
+
export function localDatabase(file) {
|
|
69
|
+
let opened = null;
|
|
70
|
+
const open = async () => {
|
|
71
|
+
// Loaded only when a game uses its database, so games without one never see Node's SQLite warning.
|
|
72
|
+
opened ??= import("node:sqlite").then(({ DatabaseSync }) => new DatabaseSync(file));
|
|
73
|
+
return opened;
|
|
74
|
+
};
|
|
75
|
+
const run = (database, statements) => {
|
|
76
|
+
database.exec("BEGIN");
|
|
77
|
+
try {
|
|
78
|
+
const results = statements.map(({ sql, values }) => {
|
|
79
|
+
const rows = [];
|
|
80
|
+
const statement = database.prepare(sql);
|
|
81
|
+
statement.setReadBigInts(true);
|
|
82
|
+
for (const row of statement.iterate(...values)) {
|
|
83
|
+
if (rows.length >= DATABASE_LIMITS.maximumRowCount) throw tooManyRows();
|
|
84
|
+
rows.push(checkedRow(numbersFrom(row)));
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
});
|
|
88
|
+
database.exec("COMMIT");
|
|
89
|
+
return results;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
try {
|
|
92
|
+
database.exec("ROLLBACK");
|
|
93
|
+
} catch {
|
|
94
|
+
// SQLite already ended the transaction.
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
query: async (sql, values) => {
|
|
101
|
+
const database = await open();
|
|
102
|
+
return readDatabaseAnswer(answerDatabase(() => run(database, [checkStatement(sql, values)])[0]));
|
|
103
|
+
},
|
|
104
|
+
batch: async (statements) => {
|
|
105
|
+
const database = await open();
|
|
106
|
+
return readDatabaseAnswer(answerDatabase(() => run(database, checkBatch(statements))));
|
|
107
|
+
},
|
|
108
|
+
// The same rule as in Zaisma rooms (eraseStatements), in one transaction. A file that isn't there yet has nothing to erase.
|
|
109
|
+
erasePlayer: async (playerId) => {
|
|
110
|
+
const checkedPlayerId = readDatabaseAnswer(answerDatabase(() => checkPlayerId(playerId)));
|
|
111
|
+
if (!opened && !existsSync(file)) return [];
|
|
112
|
+
const database = await open();
|
|
113
|
+
return readDatabaseAnswer(
|
|
114
|
+
answerDatabase(() => {
|
|
115
|
+
const statements = eraseStatements(tablesFromColumns(database.prepare(TABLE_COLUMNS_SQL).all()), checkedPlayerId);
|
|
116
|
+
database.exec("BEGIN");
|
|
117
|
+
try {
|
|
118
|
+
const erasedTables = statements.map((statement) => {
|
|
119
|
+
try {
|
|
120
|
+
return { tableName: statement.tableName, erasedRowCount: Number(database.prepare(statement.sql).run(...statement.values).changes) };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw eraseRefused(statement.tableName, error);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
database.exec("COMMIT");
|
|
126
|
+
return erasedTables;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
try {
|
|
129
|
+
database.exec("ROLLBACK");
|
|
130
|
+
} catch {
|
|
131
|
+
// SQLite already ended the transaction.
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A player as a saved record: snake_case, like the rest of the room_states file. */
|
|
142
|
+
function playerRecord(player) {
|
|
143
|
+
return { id: player.id, name: player.name, kind: player.kind, ...(player.ownedItemNames ? { owned_item_names: player.ownedItemNames } : {}) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function playerFromRecord(record) {
|
|
147
|
+
return { id: record.id, name: record.name, kind: record.kind, ...(record.owned_item_names ? { ownedItemNames: record.owned_item_names } : {}) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A map of records, each changed by change; an absent map stays absent. */
|
|
151
|
+
function mapped(given, change) {
|
|
152
|
+
return given ? Object.fromEntries(Object.entries(given).map(([key, entry]) => [key, change(entry)])) : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Drop the keys whose value is undefined, so JSON.stringify and a reader see the same record. */
|
|
156
|
+
function without(record) {
|
|
157
|
+
return Object.fromEntries(Object.entries(record).filter(([, recordValue]) => recordValue !== undefined));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What a room needs to wake from a restart, as the record the room_states file keeps in saved_room_json:
|
|
162
|
+
* its settings, who it remembers, its seats and waits, and the players in its match (match_players).
|
|
163
|
+
* Every key is snake_case, apart from the ones that are the game's own data (labels, setup_data, join_data, kept_out's IDs).
|
|
164
|
+
* Whether the room is kept is the is_kept column, not part of this.
|
|
165
|
+
*/
|
|
166
|
+
export function savedRoomRecord(savedRoom, matchPlayers) {
|
|
167
|
+
return without({
|
|
168
|
+
is_listed: savedRoom.isListed,
|
|
169
|
+
is_open: savedRoom.isOpen,
|
|
170
|
+
friend_joins_enabled: savedRoom.friendJoinsEnabled,
|
|
171
|
+
name: savedRoom.name,
|
|
172
|
+
labels: savedRoom.labels,
|
|
173
|
+
known_player_ids: savedRoom.knownPlayerIds,
|
|
174
|
+
setup_data: savedRoom.setupData,
|
|
175
|
+
kept_out: savedRoom.keptOut,
|
|
176
|
+
expected: mapped(savedRoom.expected, (expected) => ({ join_data: expected.joinData, from_room_code: expected.fromRoomCode, until_ms: expected.untilMs })),
|
|
177
|
+
held: mapped(savedRoom.held, (held) => ({ player: playerRecord(held.player), reason: held.reason, until_ms: held.untilMs })),
|
|
178
|
+
kept: savedRoom.kept,
|
|
179
|
+
reconnecting: mapped(savedRoom.reconnecting, (reconnecting) => ({ player: playerRecord(reconnecting.player), until_ms: reconnecting.untilMs })),
|
|
180
|
+
lobby_room_code: savedRoom.lobbyRoomCode,
|
|
181
|
+
ends_at_ms: savedRoom.endsAt ? Date.parse(savedRoom.endsAt) : undefined,
|
|
182
|
+
account_blocks: savedRoom.accountBlocks,
|
|
183
|
+
session_blocks: savedRoom.sessionBlocks,
|
|
184
|
+
friendships: savedRoom.friendships,
|
|
185
|
+
match_players: matchPlayers.map(playerRecord),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The room and the players in its match that savedRoomRecord wrote, as the room takes them; isKept is the is_kept column. */
|
|
190
|
+
export function savedRoomFrom(record, isKept) {
|
|
191
|
+
const savedRoom = without({
|
|
192
|
+
isListed: record.is_listed,
|
|
193
|
+
isOpen: record.is_open,
|
|
194
|
+
friendJoinsEnabled: record.friend_joins_enabled,
|
|
195
|
+
name: record.name,
|
|
196
|
+
labels: record.labels,
|
|
197
|
+
knownPlayerIds: record.known_player_ids,
|
|
198
|
+
setupData: record.setup_data,
|
|
199
|
+
keptOut: record.kept_out,
|
|
200
|
+
expected: mapped(record.expected, (expected) => ({ joinData: expected.join_data, fromRoomCode: expected.from_room_code, untilMs: expected.until_ms })),
|
|
201
|
+
held: mapped(record.held, (held) => ({ player: playerFromRecord(held.player), reason: held.reason, untilMs: held.until_ms })),
|
|
202
|
+
kept: record.kept,
|
|
203
|
+
reconnecting: mapped(record.reconnecting, (reconnecting) => ({ player: playerFromRecord(reconnecting.player), untilMs: reconnecting.until_ms })),
|
|
204
|
+
lobbyRoomCode: record.lobby_room_code,
|
|
205
|
+
endsAt: record.ends_at_ms === undefined ? undefined : new Date(record.ends_at_ms).toISOString(),
|
|
206
|
+
accountBlocks: record.account_blocks,
|
|
207
|
+
sessionBlocks: record.session_blocks,
|
|
208
|
+
friendships: record.friendships,
|
|
209
|
+
isKept: isKept ? true : undefined,
|
|
210
|
+
});
|
|
211
|
+
return { savedRoom, matchPlayers: (record.match_players ?? []).map(playerFromRecord) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The rooms' states in a local SQLite file (roomStateFile), made the first time a room saves: a
|
|
216
|
+
* real-time room's every stateSaveIntervalSec and a turn-based room's after each change, so a
|
|
217
|
+
* restarted server brings its matches back (onRestored, for rooms that tick). Apart from the game's
|
|
218
|
+
* database, which is the game's own data: these are the runner's. One row a room, with its state as JSON
|
|
219
|
+
* and the rest of the room as a savedRoomRecord. A file written in another format_version is dropped
|
|
220
|
+
* when it's opened: its rooms start fresh.
|
|
221
|
+
*/
|
|
222
|
+
export function localRoomStates(file) {
|
|
223
|
+
let database = null;
|
|
224
|
+
const open = () => {
|
|
225
|
+
if (database) return database;
|
|
226
|
+
// Loaded only once a room saves, so a server that never does never sees Node's SQLite warning.
|
|
227
|
+
const { DatabaseSync } = process.getBuiltinModule("node:sqlite");
|
|
228
|
+
database = new DatabaseSync(file);
|
|
229
|
+
const hasTable = database.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'room_states'").get() !== undefined;
|
|
230
|
+
const hasVersion = hasTable && database.prepare("SELECT 1 FROM pragma_table_info('room_states') WHERE name = 'format_version'").get() !== undefined;
|
|
231
|
+
if (hasTable && (!hasVersion || database.prepare("SELECT 1 FROM room_states WHERE format_version != ? LIMIT 1").get(ROOM_STATES_FORMAT_VERSION_NUMBER) !== undefined)) {
|
|
232
|
+
database.exec("DROP TABLE room_states");
|
|
233
|
+
console.warn("Saved room states from an older runner were dropped; rooms start fresh.");
|
|
234
|
+
}
|
|
235
|
+
database.exec("CREATE TABLE IF NOT EXISTS room_states (room_code TEXT PRIMARY KEY, format_version INTEGER NOT NULL, state_json TEXT NOT NULL, saved_room_json TEXT NOT NULL, saved_at_ms INTEGER NOT NULL, is_saved_empty INTEGER NOT NULL, is_kept INTEGER NOT NULL)");
|
|
236
|
+
return database;
|
|
237
|
+
};
|
|
238
|
+
const isThere = () => database !== null || (file !== ":memory:" && existsSync(file));
|
|
239
|
+
return {
|
|
240
|
+
/** A room's saved state, the players in its match and the room, when it was saved and whether it was empty then; null if it has none. */
|
|
241
|
+
read(roomCode) {
|
|
242
|
+
if (!isThere()) return null;
|
|
243
|
+
const row = open().prepare("SELECT state_json, saved_room_json, saved_at_ms, is_saved_empty, is_kept FROM room_states WHERE room_code = ?").get(roomCode);
|
|
244
|
+
if (!row) return null;
|
|
245
|
+
const { savedRoom, matchPlayers } = savedRoomFrom(JSON.parse(row.saved_room_json), row.is_kept === 1);
|
|
246
|
+
return { stateJson: row.state_json, matchPlayers, savedRoom, savedAtMs: Number(row.saved_at_ms), isSavedEmpty: row.is_saved_empty === 1 };
|
|
247
|
+
},
|
|
248
|
+
write(roomCode, { stateJson, matchPlayers, savedRoom, savedAtMs, isSavedEmpty = false }) {
|
|
249
|
+
open()
|
|
250
|
+
.prepare("INSERT INTO room_states (room_code, format_version, state_json, saved_room_json, saved_at_ms, is_saved_empty, is_kept) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(room_code) DO UPDATE SET format_version = excluded.format_version, state_json = excluded.state_json, saved_room_json = excluded.saved_room_json, saved_at_ms = excluded.saved_at_ms, is_saved_empty = excluded.is_saved_empty, is_kept = excluded.is_kept")
|
|
251
|
+
.run(roomCode, ROOM_STATES_FORMAT_VERSION_NUMBER, stateJson, JSON.stringify(savedRoomRecord(savedRoom, matchPlayers)), savedAtMs, isSavedEmpty ? 1 : 0, savedRoom.isKept === true ? 1 : 0);
|
|
252
|
+
},
|
|
253
|
+
/** A room's state unchanged since its last save, which now counts as saved as it emptied, with the room as it is (kept or not). */
|
|
254
|
+
markEmpty(roomCode, savedRoom, matchPlayers) {
|
|
255
|
+
if (isThere()) open().prepare("UPDATE room_states SET saved_room_json = ?, is_kept = ?, is_saved_empty = 1 WHERE room_code = ?").run(JSON.stringify(savedRoomRecord(savedRoom, matchPlayers)), savedRoom.isKept === true ? 1 : 0, roomCode);
|
|
256
|
+
},
|
|
257
|
+
remove(roomCode) {
|
|
258
|
+
if (isThere()) open().prepare("DELETE FROM room_states WHERE room_code = ?").run(roomCode);
|
|
259
|
+
},
|
|
260
|
+
/** Delete the states saved before then, except those of rooms still running and kept rooms (room.setKept). */
|
|
261
|
+
removeOlder(beforeMs, runningRoomCodes) {
|
|
262
|
+
if (!isThere()) return;
|
|
263
|
+
const running = [...runningRoomCodes];
|
|
264
|
+
open()
|
|
265
|
+
.prepare(`DELETE FROM room_states WHERE saved_at_ms < ? AND is_kept = 0${running.length ? ` AND room_code NOT IN (${running.map(() => "?").join(", ")})` : ""}`)
|
|
266
|
+
.run(beforeMs, ...running);
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** room.secrets and server.secrets on your own server: the environment variables, as they were at the start. */
|
|
272
|
+
export function secretsFromEnvironment() {
|
|
273
|
+
return Object.freeze(Object.fromEntries(Object.entries(process.env).filter(([, environmentValue]) => typeof environmentValue === "string")));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile"];
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check what startServer was given: unknown options and options of the wrong kind are a GameServerError,
|
|
280
|
+
* before anything listens. Returns the options with their defaults filled in.
|
|
281
|
+
*/
|
|
282
|
+
export function checkedStartServerOptions(given, issuerUrlDefault) {
|
|
283
|
+
const fail = (code, field, message, fix) => new GameServerError(code, field, message, fix);
|
|
284
|
+
if (given === null || typeof given !== "object" || Array.isArray(given)) throw fail("bad_option", "options", "startServer takes one object of options.", `Call startServer({ gameServer, gameSlug }), with any of: ${OPTION_NAMES.join(", ")}.`);
|
|
285
|
+
for (const field of Object.keys(given)) {
|
|
286
|
+
if (!OPTION_NAMES.includes(field)) throw fail("unknown_option", field, `${field} isn't a startServer option.`, `startServer takes: ${OPTION_NAMES.join(", ")}.`);
|
|
287
|
+
}
|
|
288
|
+
const bad = (field, kind, fix) => fail("bad_option", field, `${field} isn't ${kind}.`, fix);
|
|
289
|
+
const isText = (checked) => typeof checked === "string" && checked.length > 0;
|
|
290
|
+
const isFunction = (checked) => typeof checked === "function";
|
|
291
|
+
const options = { ...given };
|
|
292
|
+
for (const [field, fix] of [["gameServer", "Give your game server object: the default export of your server.ts, or relay."], ["gameSlug", 'Give your game\'s slug as text, like "my-game".']]) {
|
|
293
|
+
if (options[field] === undefined) throw fail("bad_option", field, `${field} is missing.`, fix);
|
|
294
|
+
}
|
|
295
|
+
if (options.gameServer === null || typeof options.gameServer !== "object") throw bad("gameServer", "a game server object", "Give the default export of your server.ts (a defineGameServer object), or relay.");
|
|
296
|
+
if (typeof options.gameSlug !== "string" || !/^[a-z0-9-]{1,40}$/.test(options.gameSlug)) throw bad("gameSlug", "a game slug", 'Give your game\'s slug: 1 to 40 lowercase letters, digits or hyphens, like "my-game".');
|
|
297
|
+
options.port ??= 8080;
|
|
298
|
+
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) throw bad("port", "a port number", "Give a whole number from 0 to 65535 (0 picks a free one), like 8080.");
|
|
299
|
+
options.origins ??= [`https://${options.gameSlug}.zaismausercontent.com`];
|
|
300
|
+
if (!Array.isArray(options.origins) || !options.origins.every(isText)) throw bad("origins", "a list of page origins", 'Give a list of origins, like ["https://my-game.zaismausercontent.com"]; an empty list allows any page, for local testing.');
|
|
301
|
+
options.issuerUrl ??= issuerUrlDefault;
|
|
302
|
+
if (!isText(options.issuerUrl) || !URL.canParse(options.issuerUrl)) throw bad("issuerUrl", "a URL", `Give the address of the server that signs your players' passes, like "${issuerUrlDefault}".`);
|
|
303
|
+
for (const [field, fix] of [["loadPassKeys", "Give async (isRefreshing) => the pass keys, as a list."], ["loadReceiptKeys", "Give async (isRefreshing) => the receipt keys, as a list."], ["loadProfile", "Give async (playerId) => { name } or null."]]) {
|
|
304
|
+
if (options[field] !== undefined && !isFunction(options[field])) throw bad(field, "a function", fix);
|
|
305
|
+
}
|
|
306
|
+
if (options.database !== undefined && (options.database === null || typeof options.database !== "object" || !["query", "batch", "erasePlayer"].every((call) => isFunction(options.database[call])))) throw bad("database", "a game database", "Give an object with query, batch and erasePlayer, like the one localDatabase makes.");
|
|
307
|
+
if (options.secrets !== undefined && (options.secrets === null || typeof options.secrets !== "object" || Array.isArray(options.secrets))) throw bad("secrets", "an object of secrets", "Give an object of text values by name, like { STRIPE_KEY: \"…\" }.");
|
|
308
|
+
if (options.webhookSigningSecret !== undefined && typeof options.webhookSigningSecret !== "string") throw bad("webhookSigningSecret", "text", "Give the signing_secret of the webhook whose events come to /v1/webhooks, as text.");
|
|
309
|
+
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.");
|
|
310
|
+
return options;
|
|
311
|
+
}
|
package/bin/node.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import type { GameDatabase, GameServer, PublicKey } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
/** What startServer takes. Only gameServer and gameSlug are required; the rest have the defaults named here. */
|
|
5
|
+
export interface StartServerOptions {
|
|
6
|
+
/** Your game server: the default export of your server.ts (a defineGameServer object), or relay. */
|
|
7
|
+
gameServer: GameServer;
|
|
8
|
+
/** Your game's Zaisma slug: 1 to 40 lowercase letters, digits or hyphens. */
|
|
9
|
+
gameSlug: string;
|
|
10
|
+
/** The port to listen on. Default 8080; 0 picks a free one. */
|
|
11
|
+
port?: number;
|
|
12
|
+
/** The page origins that may connect. Default `https://<gameSlug>.zaismausercontent.com`; an empty list allows any page, for local testing. */
|
|
13
|
+
origins?: string[];
|
|
14
|
+
/** Who signs your players' passes. Default https://rooms.zaisma.com (PASS_ISSUER_URL). */
|
|
15
|
+
issuerUrl?: string;
|
|
16
|
+
/** The keys that check passes. Default: the ones `<issuerUrl>/.well-known/jwks.json` publishes, kept an hour. Called with true to fetch them again. */
|
|
17
|
+
loadPassKeys?: (isRefreshing?: boolean) => Promise<PublicKey[]>;
|
|
18
|
+
/** The keys that check receipts (room.verifyReceipt). Default: the ones `<issuerUrl>/.well-known/receipt-jwks.json` publishes. */
|
|
19
|
+
loadReceiptKeys?: (isRefreshing?: boolean) => Promise<PublicKey[]>;
|
|
20
|
+
/** An account's public profile, or null when no account has that ID. Default: looked up at `<issuerUrl>/v1/players/<id>`. */
|
|
21
|
+
loadProfile?: (playerId: string) => Promise<{ name: string } | null>;
|
|
22
|
+
/** The game's database (room.database). Default: a local SQLite file, zaisma-database.sqlite, made the first time the game uses it. */
|
|
23
|
+
database?: GameDatabase;
|
|
24
|
+
/** room.secrets and server.secrets. Default: this process's environment variables. */
|
|
25
|
+
secrets?: Readonly<Record<string, string>>;
|
|
26
|
+
/** The signing secret of the Zaisma webhook whose events come to /v1/webhooks. Default: the ZAISMA_WEBHOOK_SIGNING_SECRET environment variable. */
|
|
27
|
+
webhookSigningSecret?: string;
|
|
28
|
+
/** Where rooms save their states, so a restart brings matches back. Default zaisma-room-states.sqlite; null keeps none. */
|
|
29
|
+
roomStateFile?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunningServer {
|
|
33
|
+
/** Saves every room, tells players to reconnect, and finishes once the server has stopped. */
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
/** The HTTP server, for its address (httpServer.address()). */
|
|
36
|
+
httpServer: Server;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run a game server on your own Node server (Node 22.18 or later). Throws a GameServerError
|
|
41
|
+
* (unknown_option, bad_option) for options it doesn't take, before anything listens.
|
|
42
|
+
*/
|
|
43
|
+
export function startServer(options: StartServerOptions): RunningServer;
|