@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/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
- if (!position)
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
- const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length ? `(${insertedColumns.map((column) => quoteName(column.name)).join(",")})` : ""} VALUES(`;
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
- const rows = position.last_rowid_number === null
78
- ? select(`SELECT ${valuesSql} FROM ${quoteName(table.name)} ORDER BY ${primaryKey.map((column) => quoteName(column.name)).join(", ")} LIMIT ${PAGE_ROW_COUNT} OFFSET ?`, [position.dumped_row_count])
79
- : select(`SELECT rowid AS paging_rowid, ${valuesSql} FROM ${quoteName(table.name)} WHERE rowid > ? ORDER BY rowid LIMIT ${PAGE_ROW_COUNT}`, [position.last_rowid_number]);
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 (position.last_rowid_number === null)
88
- position.dumped_row_count++;
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, dumped_row_count: dumpedRowCount } = (position ?? {});
135
- if (typeof tableName !== "string" || !(lastRowidNumber === null || Number.isInteger(lastRowidNumber)) || !Number.isInteger(dumpedRowCount))
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, dumped_row_count: dumpedRowCount };
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 = dumpSql.slice(scannedStatement.startIndex, scannedStatement.endIndex);
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) => new DatabaseError("bad_sql", `Statement ${statementNumber} of the dump (${preview(text)}): ${message}`, IMPORT_FIX);
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
- return { statements, statementCount: scanned.length, skippedStatementCount };
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); never set on your own server.
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)