@zaisma/game-server 1.7.0 → 1.9.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/glass.js +276 -60
- package/bin/node-parts.js +2 -1
- package/package.json +1 -1
- package/sdk/saves.js +137 -57
- package/sdk/zaisma.d.ts +7 -1
- package/sdk/zaisma.js +4 -1
- package/src/database.d.ts +21 -8
- package/src/database.js +57 -15
- package/src/dump.d.ts +4 -1
- package/src/dump.js +166 -18
- package/src/room.d.ts +16 -2
- package/src/room.js +14 -0
package/src/dump.d.ts
CHANGED
|
@@ -54,7 +54,10 @@ export interface ImportPlan {
|
|
|
54
54
|
* statements, and leaves out what sqlite3 writes around them (the transaction, PRAGMAs, ANALYZE and its
|
|
55
55
|
* sqlite_stat1 rows). The values in an INSERT become values for ?s, since Zaisma's database takes
|
|
56
56
|
* statements up to 100 KB and a row of a dump can be bigger; unistr('…'), which sqlite3 writes for text with
|
|
57
|
-
* line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up.
|
|
57
|
+
* line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up. The tables are made
|
|
58
|
+
* first, so a row can refer to a table made after it. A search table in sqlite3's own dump (its
|
|
59
|
+
* `INSERT INTO sqlite_schema` under writable_schema, and its shadow tables) becomes its CREATE VIRTUAL TABLE, filled again
|
|
60
|
+
* from its _content rows with their rowids.
|
|
58
61
|
*/
|
|
59
62
|
export declare function importStatements(dumpSql: string): ImportPlan;
|
|
60
63
|
/** The start of a statement, for an error. */
|
package/src/dump.js
CHANGED
|
@@ -53,8 +53,12 @@ export function dumpPart(read, cursor, partSizeBytes = DUMP_LIMITS.exportPartSiz
|
|
|
53
53
|
};
|
|
54
54
|
let tableIndex = 0;
|
|
55
55
|
let position = null;
|
|
56
|
-
if (cursor === null)
|
|
56
|
+
if (cursor === null) {
|
|
57
57
|
add(DUMP_START);
|
|
58
|
+
// Every table is made before any row goes in, so a row can refer to a table made after it.
|
|
59
|
+
for (const table of tables)
|
|
60
|
+
add(`${table.sql};\n`);
|
|
61
|
+
}
|
|
58
62
|
else {
|
|
59
63
|
position = readCursor(cursor);
|
|
60
64
|
tableIndex = tables.findIndex((table) => table.name === position.table_name);
|
|
@@ -63,29 +67,39 @@ export function dumpPart(read, cursor, partSizeBytes = DUMP_LIMITS.exportPartSiz
|
|
|
63
67
|
}
|
|
64
68
|
for (; tableIndex < tables.length; tableIndex++) {
|
|
65
69
|
const table = tables[tableIndex];
|
|
66
|
-
|
|
67
|
-
add(`${table.sql};\n`);
|
|
68
|
-
position ??= { table_name: table.name, last_rowid_number: table.is_without_rowid ? null : 0, dumped_row_count: 0 };
|
|
70
|
+
position ??= { table_name: table.name, last_rowid_number: null, last_key: null };
|
|
69
71
|
const columns = select("SELECT name, hidden = 0 AS is_insertable, pk AS primary_key_number FROM pragma_table_xinfo(?) ORDER BY cid", [table.name]);
|
|
70
72
|
// Generated columns, and a virtual table's hidden ones, can't be inserted: the INSERTs name the rest.
|
|
71
73
|
const insertedColumns = columns.filter((column) => column.is_insertable === 1);
|
|
72
|
-
|
|
74
|
+
// A search table's rows are known by their rowids, so they go in with them.
|
|
75
|
+
const isVirtual = table.table_kind === "virtual";
|
|
76
|
+
const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length || isVirtual ? `(${[...(isVirtual ? ["rowid"] : []), ...insertedColumns.map((column) => quoteName(column.name))].join(",")})` : ""} VALUES(`;
|
|
73
77
|
// quote() writes each value as SQL, the way sqlite3's .dump does: 'text' with '' for a quote, X'…' for bytes.
|
|
74
78
|
const valuesSql = insertedColumns.map((column, i) => `quote(${quoteName(column.name)}) AS value_${i}`).join(", ");
|
|
79
|
+
// A table with a declared primary key is read in its key order (rowid last, where it has one), so a row the game deletes or
|
|
80
|
+
// replaces on the other side of the cursor can't move it; a table without one, in rowid order.
|
|
75
81
|
const primaryKey = columns.filter((column) => column.primary_key_number > 0).sort((first, second) => first.primary_key_number - second.primary_key_number);
|
|
82
|
+
const tableName = quoteName(table.name);
|
|
83
|
+
const keyExpressions = primaryKey.length ? [...primaryKey.map((column) => `${tableName}.${quoteName(column.name)}`), ...(table.is_without_rowid ? [] : [`${tableName}.rowid`])] : [];
|
|
76
84
|
while (true) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
let rows;
|
|
86
|
+
if (keyExpressions.length) {
|
|
87
|
+
const keySql = keyExpressions.map((expression, i) => `typeof(${expression}) AS key_type_${i}, CASE typeof(${expression}) WHEN 'text' THEN ${expression} WHEN 'blob' THEN hex(${expression}) WHEN 'null' THEN NULL ELSE quote(${expression}) END AS key_text_${i}`).join(", ");
|
|
88
|
+
const after = keyAfter(keyExpressions, position.last_key);
|
|
89
|
+
rows = select(`SELECT ${keySql}${valuesSql ? `, ${valuesSql}` : ""} FROM ${tableName}${after.sql ? ` WHERE ${after.sql}` : ""} ORDER BY ${keyExpressions.join(", ")} LIMIT ${PAGE_ROW_COUNT}`, after.values);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
rows = select(`SELECT rowid AS paging_rowid${valuesSql ? `, ${valuesSql}` : ""} FROM ${tableName} WHERE rowid > ? ORDER BY rowid LIMIT ${PAGE_ROW_COUNT}`, [position.last_rowid_number ?? 0]);
|
|
93
|
+
}
|
|
80
94
|
for (const row of rows) {
|
|
81
|
-
const insert = `${insertInto}${insertedColumns.map((_, i) => row[`value_${i}`]).join(",")});\n`;
|
|
95
|
+
const insert = `${insertInto}${[...(isVirtual ? [row.paging_rowid] : []), ...insertedColumns.map((_, i) => row[`value_${i}`])].join(",")});\n`;
|
|
82
96
|
// The part ends before a row that would take it past partSizeBytes; the next starts with that row.
|
|
83
97
|
if (hasRows && dumpSizeBytes + utf8SizeBytes(insert) > partSizeBytes)
|
|
84
98
|
return { dumpSql, nextCursor: writeCursor(position), readRowCount };
|
|
85
99
|
add(insert);
|
|
86
100
|
hasRows = true;
|
|
87
|
-
if (
|
|
88
|
-
position.
|
|
101
|
+
if (keyExpressions.length)
|
|
102
|
+
position.last_key = keyExpressions.map((_, i) => ({ key_type: row[`key_type_${i}`], key_text: row[`key_text_${i}`] }));
|
|
89
103
|
else
|
|
90
104
|
position.last_rowid_number = row.paging_rowid;
|
|
91
105
|
}
|
|
@@ -131,26 +145,135 @@ function readCursor(cursor) {
|
|
|
131
145
|
catch {
|
|
132
146
|
throw new DumpCursorError("The cursor isn't one an export answered.");
|
|
133
147
|
}
|
|
134
|
-
const { table_name: tableName, last_rowid_number: lastRowidNumber,
|
|
135
|
-
|
|
148
|
+
const { table_name: tableName, last_rowid_number: lastRowidNumber, last_key: lastKey } = (position ?? {});
|
|
149
|
+
const isKey = (key) => {
|
|
150
|
+
const { key_type: keyType, key_text: keyText } = (key ?? {});
|
|
151
|
+
return (keyType === "null" ? keyText === null : (keyType === "integer" || keyType === "real" || keyType === "text" || keyType === "blob") && typeof keyText === "string") && (keyType !== "integer" || /^-?\d+$/.test(keyText)) && (keyType !== "real" || /^-?[0-9.]+(e[+-]?\d+)?$/i.test(keyText)) && (keyType !== "blob" || /^([0-9A-Fa-f]{2})*$/.test(keyText));
|
|
152
|
+
};
|
|
153
|
+
if (typeof tableName !== "string" || !(lastRowidNumber === null || Number.isInteger(lastRowidNumber)) || !(lastKey === null || (Array.isArray(lastKey) && lastKey.length > 0 && lastKey.length <= 64 && lastKey.every(isKey))))
|
|
136
154
|
throw new DumpCursorError("The cursor isn't one an export answered.");
|
|
137
|
-
return { table_name: tableName, last_rowid_number: lastRowidNumber,
|
|
155
|
+
return { table_name: tableName, last_rowid_number: lastRowidNumber, last_key: lastKey };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The WHERE for the rows after a key, for ORDER BY the key's columns: one OR per column, each holding the ones before it equal
|
|
159
|
+
* and itself greater (NULLs sort first). The key's values are bound, numbers as text SQLite casts back, so none loses digits.
|
|
160
|
+
*/
|
|
161
|
+
function keyAfter(keyExpressions, lastKey) {
|
|
162
|
+
if (!lastKey)
|
|
163
|
+
return { sql: "", values: [] };
|
|
164
|
+
if (lastKey.length !== keyExpressions.length)
|
|
165
|
+
throw new DumpCursorError("The cursor isn't one an export answered.");
|
|
166
|
+
const values = [];
|
|
167
|
+
const operand = (key) => {
|
|
168
|
+
values.push(key.key_type === "blob" ? Uint8Array.from({ length: key.key_text.length / 2 }, (_, byteIndex) => parseInt(key.key_text.slice(byteIndex * 2, byteIndex * 2 + 2), 16)) : key.key_text);
|
|
169
|
+
return key.key_type === "integer" ? "CAST(? AS INTEGER)" : key.key_type === "real" ? "CAST(? AS REAL)" : "?";
|
|
170
|
+
};
|
|
171
|
+
const terms = keyExpressions.map((expression, i) => {
|
|
172
|
+
const conditions = keyExpressions.slice(0, i).map((earlier, earlierIndex) => (lastKey[earlierIndex].key_type === "null" ? `${earlier} IS NULL` : `${earlier} IS ${operand(lastKey[earlierIndex])}`));
|
|
173
|
+
conditions.push(lastKey[i].key_type === "null" ? `${expression} IS NOT NULL` : `${expression} > ${operand(lastKey[i])}`);
|
|
174
|
+
return `(${conditions.join(" AND ")})`;
|
|
175
|
+
});
|
|
176
|
+
return { sql: terms.join(" OR "), values };
|
|
138
177
|
}
|
|
139
178
|
const IMPORT_FIX = "Import takes a dump's CREATE and INSERT statements, as sqlite3's .dump or export_database writes them; it only adds. Run other changes with query_database or batch_database.";
|
|
179
|
+
/** The name a statement starts with after `CREATE TABLE [IF NOT EXISTS]` or `INSERT INTO`, however it's quoted. */
|
|
180
|
+
function tableNameAfter(text, introduction) {
|
|
181
|
+
const match = new RegExp(`^${introduction.source}\\s*(?:"((?:[^"]|"")*)"|'((?:[^']|'')*)'|\`([^\`]*)\`|\\[([^\\]]*)\\]|(\\w+))`, "i").exec(text);
|
|
182
|
+
if (!match)
|
|
183
|
+
return null;
|
|
184
|
+
const [, doubleQuoted, singleQuoted, backQuoted, bracketed, bare] = match;
|
|
185
|
+
return doubleQuoted?.replaceAll('""', '"') ?? singleQuoted?.replaceAll("''", "'") ?? backQuoted ?? bracketed ?? bare ?? null;
|
|
186
|
+
}
|
|
187
|
+
/** The shadow tables an FTS5 table makes for itself: sqlite3's .dump writes them as ordinary tables, and a search table makes them again. */
|
|
188
|
+
const FTS5_SHADOW_SUFFIXES = ["data", "idx", "content", "docsize", "config"];
|
|
189
|
+
const SEARCH_TABLE_FIX = "Import takes a search table made with fts5. For others, import the database file instead: npx @zaisma/cli database import <slug> game.sqlite.";
|
|
190
|
+
/** The columns of an fts5(…) table from its CREATE VIRTUAL TABLE: the arguments that aren't options (name=value). */
|
|
191
|
+
function searchColumnNames(createSql) {
|
|
192
|
+
const openIndex = createSql.indexOf("(", createSql.search(/fts5/i));
|
|
193
|
+
const closeIndex = createSql.lastIndexOf(")");
|
|
194
|
+
if (openIndex === -1 || closeIndex <= openIndex)
|
|
195
|
+
return [];
|
|
196
|
+
const argumentTexts = [];
|
|
197
|
+
let current = "";
|
|
198
|
+
let openParenthesisCount = 0;
|
|
199
|
+
let quote = null;
|
|
200
|
+
for (const character of createSql.slice(openIndex + 1, closeIndex)) {
|
|
201
|
+
if (quote) {
|
|
202
|
+
current += character;
|
|
203
|
+
if (character === quote)
|
|
204
|
+
quote = null;
|
|
205
|
+
}
|
|
206
|
+
else if (character === "'" || character === '"' || character === "`") {
|
|
207
|
+
quote = character;
|
|
208
|
+
current += character;
|
|
209
|
+
}
|
|
210
|
+
else if (character === "(") {
|
|
211
|
+
openParenthesisCount++;
|
|
212
|
+
current += character;
|
|
213
|
+
}
|
|
214
|
+
else if (character === ")") {
|
|
215
|
+
openParenthesisCount--;
|
|
216
|
+
current += character;
|
|
217
|
+
}
|
|
218
|
+
else if (character === "," && openParenthesisCount === 0) {
|
|
219
|
+
argumentTexts.push(current);
|
|
220
|
+
current = "";
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
current += character;
|
|
224
|
+
}
|
|
225
|
+
argumentTexts.push(current);
|
|
226
|
+
const names = [];
|
|
227
|
+
for (const argument of argumentTexts) {
|
|
228
|
+
const trimmed = argument.trim();
|
|
229
|
+
if (!trimmed || /^[\w.]+\s*=/.test(trimmed))
|
|
230
|
+
continue;
|
|
231
|
+
const first = /^(?:"((?:[^"]|"")*)"|'([^']*)'|`([^`]*)`|\[([^\]]*)\]|(\w+))/.exec(trimmed);
|
|
232
|
+
const name = first?.[1]?.replaceAll('""', '"') ?? first?.[2] ?? first?.[3] ?? first?.[4] ?? first?.[5];
|
|
233
|
+
if (name)
|
|
234
|
+
names.push(name);
|
|
235
|
+
}
|
|
236
|
+
return names;
|
|
237
|
+
}
|
|
140
238
|
/**
|
|
141
239
|
* Reads a part of a dump into the statements to run. Import only adds: it takes CREATE and INSERT
|
|
142
240
|
* statements, and leaves out what sqlite3 writes around them (the transaction, PRAGMAs, ANALYZE and its
|
|
143
241
|
* sqlite_stat1 rows). The values in an INSERT become values for ?s, since Zaisma's database takes
|
|
144
242
|
* statements up to 100 KB and a row of a dump can be bigger; unistr('…'), which sqlite3 writes for text with
|
|
145
|
-
* line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up.
|
|
243
|
+
* line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up. The tables are made
|
|
244
|
+
* first, so a row can refer to a table made after it. A search table in sqlite3's own dump (its
|
|
245
|
+
* `INSERT INTO sqlite_schema` under writable_schema, and its shadow tables) becomes its CREATE VIRTUAL TABLE, filled again
|
|
246
|
+
* from its _content rows with their rowids.
|
|
146
247
|
*/
|
|
147
248
|
export function importStatements(dumpSql) {
|
|
148
249
|
const statements = [];
|
|
149
250
|
const scanned = scanStatements(dumpSql);
|
|
150
251
|
let skippedStatementCount = 0;
|
|
252
|
+
const textOf = (scannedStatement) => dumpSql.slice(scannedStatement.startIndex, scannedStatement.endIndex);
|
|
253
|
+
const refuseAt = (statementNumber, text, message, fix = IMPORT_FIX) => new DatabaseError("bad_sql", `Statement ${statementNumber} of the dump (${preview(text)}): ${message}`, fix);
|
|
254
|
+
// sqlite3's .dump creates a virtual table by writing its row in the schema table: those come first, so the shadow tables are known.
|
|
255
|
+
const searchTables = new Map();
|
|
256
|
+
const shadowNames = new Map();
|
|
257
|
+
scanned.forEach((scannedStatement, i) => {
|
|
258
|
+
const [firstWord = "", secondWord = "", thirdWord = ""] = scannedStatement.leadingWords;
|
|
259
|
+
if (!(firstWord === "INSERT" && secondWord === "INTO" && (thirdWord === "SQLITE_SCHEMA" || thirdWord === "SQLITE_MASTER")))
|
|
260
|
+
return;
|
|
261
|
+
const text = textOf(scannedStatement);
|
|
262
|
+
const match = /^INSERT\s+INTO\s+sqlite_(?:schema|master)\s*\([^)]*\)\s*VALUES\s*\(\s*'table'\s*,\s*'((?:[^']|'')*)'\s*,\s*'(?:[^']|'')*'\s*,\s*\d+\s*,\s*'((?:[^']|'')*)'\s*\)$/i.exec(text);
|
|
263
|
+
const createSql = match?.[2]?.replaceAll("''", "'");
|
|
264
|
+
if (!match || !createSql || !/^CREATE\s+VIRTUAL\s+TABLE\s/i.test(createSql))
|
|
265
|
+
throw refuseAt(i + 1, text, "it writes to the schema table, and only a virtual table's can be read from there.");
|
|
266
|
+
if (!/\bUSING\s+fts5\b/i.test(createSql))
|
|
267
|
+
throw refuseAt(i + 1, text, "this virtual table isn't an fts5 search table.", SEARCH_TABLE_FIX);
|
|
268
|
+
const name = match[1].replaceAll("''", "'");
|
|
269
|
+
const table = { name, createSql, columnNames: searchColumnNames(createSql) };
|
|
270
|
+
searchTables.set(i, table);
|
|
271
|
+
for (const suffix of FTS5_SHADOW_SUFFIXES)
|
|
272
|
+
shadowNames.set(`${name}_${suffix}`.toLowerCase(), { ...table, suffix });
|
|
273
|
+
});
|
|
151
274
|
scanned.forEach((scannedStatement, i) => {
|
|
152
275
|
const statementNumber = i + 1;
|
|
153
|
-
const text =
|
|
276
|
+
const text = textOf(scannedStatement);
|
|
154
277
|
const [firstWord = "", secondWord = "", thirdWord = ""] = scannedStatement.leadingWords;
|
|
155
278
|
const words = scannedStatement.leadingWords.join(" ");
|
|
156
279
|
const isSkipped = ["BEGIN", "COMMIT", "END", "ROLLBACK", "ANALYZE"].includes(firstWord) ||
|
|
@@ -162,7 +285,30 @@ export function importStatements(dumpSql) {
|
|
|
162
285
|
skippedStatementCount++;
|
|
163
286
|
return;
|
|
164
287
|
}
|
|
165
|
-
const refuse = (message) =>
|
|
288
|
+
const refuse = (message) => refuseAt(statementNumber, text, message);
|
|
289
|
+
if (firstWord === "INSERT" && secondWord === "INTO" && (thirdWord === "SQLITE_SCHEMA" || thirdWord === "SQLITE_MASTER")) {
|
|
290
|
+
statements.push({ sql: searchTables.get(i).createSql, values: [], statementNumber });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (shadowNames.size) {
|
|
294
|
+
const shadow = shadowNames.get(tableNameAfter(text, firstWord === "CREATE" ? /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?/ : /INSERT\s+INTO/)?.toLowerCase() ?? "");
|
|
295
|
+
if (shadow && (firstWord === "CREATE" || firstWord === "INSERT")) {
|
|
296
|
+
if (firstWord === "CREATE" || shadow.suffix !== "content") {
|
|
297
|
+
skippedStatementCount++;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// The rows of a search table are in its _content, by rowid; they go back in through the table itself.
|
|
301
|
+
const bound = bindLiterals(text, refuse);
|
|
302
|
+
const insertInto = `INSERT INTO ${quoteName(shadow.name)}(${["rowid", ...shadow.columnNames.map(quoteName)].join(",")}) VALUES(`;
|
|
303
|
+
const replaced = bound.sql.replace(/^INSERT\s+INTO\s+(?:"(?:[^"]|"")*"|\w+)\s+VALUES\s*\(/i, insertInto);
|
|
304
|
+
if (replaced === bound.sql)
|
|
305
|
+
throw refuse("a search table's content rows are INSERT INTO \"name_content\" VALUES(…).");
|
|
306
|
+
if (bound.values.length > DATABASE_LIMITS.maximumValueCount)
|
|
307
|
+
throw new DatabaseError("bad_values", `Statement ${statementNumber} of the dump (${preview(text)}) has ${bound.values.length} text and blob values.`, `Give each row its own INSERT, as sqlite3's .dump does: the database takes up to ${DATABASE_LIMITS.maximumValueCount} values a statement.`);
|
|
308
|
+
statements.push({ sql: replaced, values: bound.values, statementNumber });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
166
312
|
if (firstWord === "CREATE") {
|
|
167
313
|
statements.push({ sql: text, values: [], statementNumber });
|
|
168
314
|
return;
|
|
@@ -188,7 +334,9 @@ export function importStatements(dumpSql) {
|
|
|
188
334
|
throw new DatabaseError("bad_values", `Statement ${statementNumber} of the dump (${preview(text)}) has ${bound.values.length} text and blob values.`, `Give each row its own INSERT, as sqlite3's .dump does: the database takes up to ${DATABASE_LIMITS.maximumValueCount} values a statement.`);
|
|
189
335
|
statements.push({ sql: bound.sql, values: bound.values, statementNumber });
|
|
190
336
|
});
|
|
191
|
-
|
|
337
|
+
// Tables first, in the order given, then the rest in theirs: a row may refer to a table made after it.
|
|
338
|
+
const isTable = (statement) => /^CREATE\s+(?:VIRTUAL\s+)?TABLE\s/i.test(statement.sql);
|
|
339
|
+
return { statements: [...statements.filter(isTable), ...statements.filter((statement) => !isTable(statement))], statementCount: scanned.length, skippedStatementCount };
|
|
192
340
|
}
|
|
193
341
|
/** The start of a statement, for an error. */
|
|
194
342
|
export function preview(text) {
|
package/src/room.d.ts
CHANGED
|
@@ -98,7 +98,8 @@ export interface Room {
|
|
|
98
98
|
/**
|
|
99
99
|
* When Zaisma will end this match, an ISO time, or null. Set once the game's hosting on Zaisma rooms
|
|
100
100
|
* pauses for the month (its free allowance, or its developer's budget or credit, is used), an hour after
|
|
101
|
-
* the pause, so your game can save and tell its players (onEnding runs then);
|
|
101
|
+
* the pause, so your game can save and tell its players (onEnding runs then); back to null when Zaisma calls the
|
|
102
|
+
* end off because the game's hosting started again (onEndingCancelled runs then). Never set on your own server.
|
|
102
103
|
*/
|
|
103
104
|
readonly endsAt: string | null;
|
|
104
105
|
/** Shown in room lists (the game page and Zaisma.rooms.list). A hidden room still works by code and link. */
|
|
@@ -541,6 +542,12 @@ export interface GameServer<S = any> {
|
|
|
541
542
|
* the "ending" event. Never runs on your own server.
|
|
542
543
|
*/
|
|
543
544
|
onEnding?(state: S, room: Room): HandlerResult<S>;
|
|
545
|
+
/**
|
|
546
|
+
* Zaisma called off the end onEnding announced, because the game's hosting started again (credit came in, its
|
|
547
|
+
* budget was raised or the month reset): room.endsAt is null again, the match goes on and takes new players.
|
|
548
|
+
* Runs once; each player's game also gets the "endingCancelled" event. Never runs on your own server.
|
|
549
|
+
*/
|
|
550
|
+
onEndingCancelled?(state: S, room: Room): HandlerResult<S>;
|
|
544
551
|
/**
|
|
545
552
|
* The room restarted (Zaisma restarts its rooms on every update, and your own server may restart) and
|
|
546
553
|
* came back with the state it last saved: restoreInfo.savedAt says when, and restoreInfo.lostDurationSec
|
|
@@ -634,7 +641,7 @@ export declare function readJoinAnswer(answer: unknown): {
|
|
|
634
641
|
mistake: GameServerError;
|
|
635
642
|
};
|
|
636
643
|
/** Everything a game server object may have, in the order the docs give them. Anything else is refused. */
|
|
637
|
-
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "adultChatFilterEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onChat", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
644
|
+
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "adultChatFilterEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onChat", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onEndingCancelled", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
638
645
|
export declare const ROOM_LIMITS: {
|
|
639
646
|
/** Players in one room: what one room carries at 60 ticks a second (test/load/room-load.test.ts). */
|
|
640
647
|
readonly maximumPlayerCount: 100;
|
|
@@ -843,6 +850,8 @@ export type ServerMessage = {
|
|
|
843
850
|
} | {
|
|
844
851
|
type: "ending";
|
|
845
852
|
endsAt: string;
|
|
853
|
+
} | {
|
|
854
|
+
type: "ending_cancelled";
|
|
846
855
|
} | {
|
|
847
856
|
type: "version_update";
|
|
848
857
|
step: "coming" | "now";
|
|
@@ -1664,6 +1673,11 @@ export declare class RoomCore<S = any> {
|
|
|
1664
1673
|
* Only Zaisma rooms call it, when the game's hosting pauses.
|
|
1665
1674
|
*/
|
|
1666
1675
|
startEnding(endsAt: string): void;
|
|
1676
|
+
/**
|
|
1677
|
+
* Zaisma calls off the end startEnding announced, since the game's hosting started again: endsAt is null, every
|
|
1678
|
+
* player hears it and onEndingCancelled runs, once. Only Zaisma rooms call it.
|
|
1679
|
+
*/
|
|
1680
|
+
cancelEnding(): void;
|
|
1667
1681
|
/** End the match: everyone hears message and why (endReason), and their connections close. */
|
|
1668
1682
|
end(message: string, endReason: EndReason): void;
|
|
1669
1683
|
/** The players as every player's game lists them, with why each isn't playing. */
|
package/src/room.js
CHANGED
|
@@ -94,6 +94,7 @@ export const GAME_SERVER_OPTIONS = [
|
|
|
94
94
|
"onRefund",
|
|
95
95
|
"onTick",
|
|
96
96
|
"onEnding",
|
|
97
|
+
"onEndingCancelled",
|
|
97
98
|
"onRestored",
|
|
98
99
|
"stateView",
|
|
99
100
|
"onVersionUpdate",
|
|
@@ -2210,6 +2211,19 @@ export class RoomCore {
|
|
|
2210
2211
|
if (this.server.onEnding)
|
|
2211
2212
|
this.run(() => this.server.onEnding(this.state, this.api), "onEnding");
|
|
2212
2213
|
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Zaisma calls off the end startEnding announced, since the game's hosting started again: endsAt is null, every
|
|
2216
|
+
* player hears it and onEndingCancelled runs, once. Only Zaisma rooms call it.
|
|
2217
|
+
*/
|
|
2218
|
+
cancelEnding() {
|
|
2219
|
+
if (this.ended || this.endsAt === null)
|
|
2220
|
+
return;
|
|
2221
|
+
this.endsAt = null;
|
|
2222
|
+
this.dirty = true;
|
|
2223
|
+
this.sendEach(this.everyone(), JSON.stringify({ type: "ending_cancelled" }));
|
|
2224
|
+
if (this.server.onEndingCancelled)
|
|
2225
|
+
this.run(() => this.server.onEndingCancelled(this.state, this.api), "onEndingCancelled");
|
|
2226
|
+
}
|
|
2213
2227
|
/** End the match: everyone hears message and why (endReason), and their connections close. */
|
|
2214
2228
|
end(message, endReason) {
|
|
2215
2229
|
if (this.ended)
|