@zaisma/game-server 1.25.0 → 1.27.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/bin/node-parts.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The parts of the Node runner (node.js) that stand on their own: where it gets the pass keys and
2
2
  // profiles, its local SQLite files, and the checks on its options. Nothing here is part of the
3
3
  // public package: tests reach it by path.
4
+ import { randomUUID } from "node:crypto";
4
5
  import { existsSync } from "node:fs";
5
6
  import { DATABASE_LIMITS, GameServerError, answerDatabase, checkBatch, checkPlayerId, checkStatement, checkedRow, eraseRefused, eraseStatements, readDatabaseAnswer, tablesFromColumns, tooManyRows, TABLE_COLUMNS_SQL } from "../src/internal.js";
6
7
 
@@ -249,9 +250,43 @@ export function localRoomStates(file) {
249
250
  console.warn("Saved room states from an older runner were dropped; rooms start fresh.");
250
251
  }
251
252
  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)");
253
+ // A kept room's world back to an earlier moment: copies of its state, and each restore with the state from just before it, for its undo.
254
+ database.exec("CREATE TABLE IF NOT EXISTS room_state_copies (room_code TEXT NOT NULL, saved_at_ms INTEGER NOT NULL, state_json TEXT NOT NULL, saved_room_json TEXT NOT NULL, PRIMARY KEY (room_code, saved_at_ms))");
255
+ database.exec("CREATE TABLE IF NOT EXISTS kept_room_restores (id TEXT PRIMARY KEY, room_code TEXT NOT NULL, restore_to_at_ms INTEGER NOT NULL, restored_at_ms INTEGER NOT NULL, undoes_kept_room_restore_id TEXT, undo_state_json TEXT NOT NULL, undo_saved_room_json TEXT NOT NULL, undo_saved_at_ms INTEGER NOT NULL, is_told INTEGER NOT NULL DEFAULT 0)");
252
256
  return database;
253
257
  };
254
258
  const isThere = () => database !== null || (file !== ":memory:" && existsSync(file));
259
+ /** Keep a copy of a kept room's state: the newest of each minute for a day, of each hour back to 30 days. */
260
+ const keepCopy = (roomCode, stateJson, savedRoomJson, savedAtMs) => {
261
+ const minuteMs = savedAtMs - (savedAtMs % 60_000);
262
+ const opened = open();
263
+ const isNewMinute = opened.prepare("SELECT 1 FROM room_state_copies WHERE room_code = ? AND saved_at_ms >= ? LIMIT 1").get(roomCode, minuteMs) === undefined;
264
+ if (!isNewMinute) opened.prepare("DELETE FROM room_state_copies WHERE room_code = ? AND saved_at_ms >= ?").run(roomCode, minuteMs);
265
+ opened.prepare("INSERT OR REPLACE INTO room_state_copies (room_code, saved_at_ms, state_json, saved_room_json) VALUES (?, ?, ?, ?)").run(roomCode, savedAtMs, stateJson, savedRoomJson);
266
+ if (!isNewMinute) return;
267
+ const dayAgoMs = savedAtMs - WORLD_COPY_MINUTES_DURATION_MS;
268
+ opened.prepare("DELETE FROM room_state_copies WHERE room_code = ?1 AND saved_at_ms < ?2 AND saved_at_ms NOT IN (SELECT MAX(saved_at_ms) FROM room_state_copies WHERE room_code = ?1 AND saved_at_ms < ?2 GROUP BY saved_at_ms / 3600000)").run(roomCode, dayAgoMs);
269
+ opened.prepare("DELETE FROM room_state_copies WHERE room_code = ? AND saved_at_ms < ?").run(roomCode, savedAtMs - WORLD_COPIES_DURATION_MS);
270
+ };
271
+ const restoreJson = (row) => ({
272
+ id: row.id,
273
+ roomCode: row.room_code,
274
+ restoreToAt: new Date(Number(row.restore_to_at_ms)).toISOString(),
275
+ restoredAt: new Date(Number(row.restored_at_ms)).toISOString(),
276
+ undoesKeptRoomRestoreId: row.undoes_kept_room_restore_id,
277
+ undoExpiresAt: new Date(Number(row.restored_at_ms) + WORLD_COPIES_DURATION_MS).toISOString(),
278
+ });
279
+ /** Puts a kept room's row back to a state, keeping the state it held, so the restore can be undone. */
280
+ const restoreTo = (roomCode, to, restoreToAtMs, undoneId, nowMs) => {
281
+ const opened = open();
282
+ const current = opened.prepare("SELECT state_json, saved_room_json, saved_at_ms FROM room_states WHERE room_code = ? AND is_kept = 1").get(roomCode);
283
+ if (!current) return { refusal: "no_kept_room" };
284
+ const id = `kept_room_restore_${randomUUID().replaceAll("-", "")}`;
285
+ const record = JSON.parse(to.saved_room_json);
286
+ opened.prepare("INSERT INTO kept_room_restores (id, room_code, restore_to_at_ms, restored_at_ms, undoes_kept_room_restore_id, undo_state_json, undo_saved_room_json, undo_saved_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(id, roomCode, restoreToAtMs, nowMs, undoneId, current.state_json, current.saved_room_json, current.saved_at_ms);
287
+ opened.prepare("UPDATE room_states SET state_json = ?, saved_room_json = ?, saved_at_ms = ?, is_saved_empty = 0 WHERE room_code = ?").run(to.state_json, JSON.stringify(record), to.saved_at_ms, roomCode);
288
+ return { keptRoomRestore: restoreJson(opened.prepare("SELECT * FROM kept_room_restores WHERE id = ?").get(id)) };
289
+ };
255
290
  return {
256
291
  /** 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. */
257
292
  read(roomCode) {
@@ -262,6 +297,7 @@ export function localRoomStates(file) {
262
297
  return { stateJson: row.state_json, matchPlayers, savedRoom, savedAtMs: Number(row.saved_at_ms), isSavedEmpty: row.is_saved_empty === 1 };
263
298
  },
264
299
  write(roomCode, { stateJson, matchPlayers, savedRoom, savedAtMs, isSavedEmpty = false }) {
300
+ if (savedRoom.isKept === true) keepCopy(roomCode, stateJson, JSON.stringify(savedRoomRecord(savedRoom, matchPlayers)), savedAtMs);
265
301
  open()
266
302
  .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")
267
303
  .run(roomCode, ROOM_STATES_FORMAT_VERSION_NUMBER, stateJson, JSON.stringify(savedRoomRecord(savedRoom, matchPlayers)), savedAtMs, isSavedEmpty ? 1 : 0, savedRoom.isKept === true ? 1 : 0);
@@ -271,7 +307,44 @@ export function localRoomStates(file) {
271
307
  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);
272
308
  },
273
309
  remove(roomCode) {
274
- if (isThere()) open().prepare("DELETE FROM room_states WHERE room_code = ?").run(roomCode);
310
+ if (!isThere()) return;
311
+ open().prepare("DELETE FROM room_states WHERE room_code = ?").run(roomCode);
312
+ open().prepare("DELETE FROM room_state_copies WHERE room_code = ?").run(roomCode);
313
+ open().prepare("DELETE FROM kept_room_restores WHERE room_code = ?").run(roomCode);
314
+ },
315
+ /** The earliest moment a kept room can go back to and its restores, newest first, or null when it isn't kept. */
316
+ keptRoomRestores(roomCode, nowMs = Date.now()) {
317
+ if (!isThere()) return null;
318
+ const opened = open();
319
+ if (opened.prepare("SELECT 1 FROM room_states WHERE room_code = ? AND is_kept = 1").get(roomCode) === undefined) return null;
320
+ const earliest = opened.prepare("SELECT MIN(saved_at_ms) AS saved_at_ms FROM room_state_copies WHERE room_code = ? AND saved_at_ms >= ?").get(roomCode, nowMs - WORLD_COPIES_DURATION_MS);
321
+ const restores = opened.prepare("SELECT * FROM kept_room_restores WHERE room_code = ? AND restored_at_ms >= ? ORDER BY restored_at_ms DESC LIMIT 50").all(roomCode, nowMs - WORLD_COPIES_DURATION_MS);
322
+ return { earliestRestoreToAt: new Date(Number(earliest?.saved_at_ms ?? nowMs)).toISOString(), restores: restores.map(restoreJson) };
323
+ },
324
+ /** A kept room back at its newest copy at or before a moment. */
325
+ restoreKeptRoom(roomCode, restoreToAtMs, nowMs = Date.now()) {
326
+ if (!isThere()) return { refusal: "no_kept_room" };
327
+ const listed = this.keptRoomRestores(roomCode, nowMs);
328
+ if (!listed) return { refusal: "no_kept_room" };
329
+ const to = open().prepare("SELECT state_json, saved_room_json, saved_at_ms FROM room_state_copies WHERE room_code = ? AND saved_at_ms <= ? ORDER BY saved_at_ms DESC LIMIT 1").get(roomCode, restoreToAtMs);
330
+ if (!to || restoreToAtMs > nowMs || restoreToAtMs < nowMs - WORLD_COPIES_DURATION_MS) return { refusal: "bad_time", earliestRestoreToAt: listed.earliestRestoreToAt };
331
+ return restoreTo(roomCode, to, restoreToAtMs, null, nowMs);
332
+ },
333
+ /** A kept room back at the state it held just before an earlier restore. */
334
+ undoKeptRoomRestore(roomCode, keptRoomRestoreId, nowMs = Date.now()) {
335
+ if (!isThere()) return { refusal: "no_kept_room" };
336
+ const undone = open().prepare("SELECT * FROM kept_room_restores WHERE id = ? AND room_code = ? AND restored_at_ms >= ?").get(keptRoomRestoreId, roomCode, nowMs - WORLD_COPIES_DURATION_MS);
337
+ if (!undone) return { refusal: "no_kept_room_restore" };
338
+ return restoreTo(roomCode, { state_json: undone.undo_state_json, saved_room_json: undone.undo_saved_room_json, saved_at_ms: undone.undo_saved_at_ms }, Number(undone.restored_at_ms), keptRoomRestoreId, nowMs);
339
+ },
340
+ /** A restore this room hasn't told its game server yet, once. */
341
+ takeToldRestore(roomCode) {
342
+ if (!isThere()) return null;
343
+ const opened = open();
344
+ const row = opened.prepare("SELECT id, restore_to_at_ms, restored_at_ms, undoes_kept_room_restore_id FROM kept_room_restores WHERE room_code = ? AND is_told = 0 ORDER BY restored_at_ms DESC LIMIT 1").get(roomCode);
345
+ if (!row) return null;
346
+ opened.prepare("UPDATE kept_room_restores SET is_told = 1 WHERE room_code = ?").run(roomCode);
347
+ return { id: row.id, restoreToAtMs: Number(row.restore_to_at_ms), restoredAtMs: Number(row.restored_at_ms), undoesKeptRoomRestoreId: row.undoes_kept_room_restore_id, erasedPlayerIds: [] };
275
348
  },
276
349
  /** Delete the states saved before then, except those of rooms still running and kept rooms (room.setKept). */
277
350
  removeOlder(beforeMs, runningRoomCodes) {
@@ -289,6 +362,10 @@ export function secretsFromEnvironment() {
289
362
  return Object.freeze(Object.fromEntries(Object.entries(process.env).filter(([, environmentValue]) => typeof environmentValue === "string")));
290
363
  }
291
364
 
365
+ /** How long a kept room's state copies last, and a restore's undo; the first of those keeps a copy a minute, the rest one an hour. */
366
+ const WORLD_COPIES_DURATION_MS = 30 * 86_400_000;
367
+ const WORLD_COPY_MINUTES_DURATION_MS = 86_400_000;
368
+
292
369
  const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile", "zaismaKey", "developerApiUrl", "webtransportPort", "certificatePath", "certificateKeyPath", "identifyPlayer", "outsideGuestsEnabled"];
293
370
 
294
371
  /**
package/bin/node.js CHANGED
@@ -304,6 +304,7 @@ export function startServer(options) {
304
304
  let room = rooms.get(code);
305
305
  if (room) return room;
306
306
  const saved = start ? null : readSaved(code);
307
+ const toldRestore = saved ? (roomStates?.takeToldRestore(code) ?? null) : null;
307
308
  const sockets = new Map();
308
309
  const core = new RoomCore(
309
310
  definition,
@@ -346,7 +347,7 @@ export function startServer(options) {
346
347
  },
347
348
  // As in Zaisma's rooms: a room that ticks, or a turn-based one that had players in its match, comes back with its state and
348
349
  // onRestored, its match's players away until they reconnect; a turn-based room nobody was in comes back with its state and its room settings alone.
349
- saved ? { state: saved.state, room: saved.savedRoom, ...(definition.ticksPerSec || saved.matchPlayers?.length ? { restoreInfo: restoreInfoOf(saved.savedAtMs, Date.now(), null, saved.isSavedEmpty), lostPlayers: saved.matchPlayers } : {}) } : { start },
350
+ saved ? { state: saved.state, room: saved.savedRoom, ...(definition.ticksPerSec || saved.matchPlayers?.length || toldRestore ? { restoreInfo: restoreInfoOf(saved.savedAtMs, Date.now(), null, saved.isSavedEmpty, toldRestore), lostPlayers: saved.matchPlayers } : {}) } : { start },
350
351
  );
351
352
  if (saved) core.savedAtMs = saved.savedAtMs;
352
353
  // passes: each player's checked pass, newest, for the blocks and friendships between them (passesHaveBlockBetween, passesAreFriends);
@@ -408,9 +409,31 @@ export function startServer(options) {
408
409
  closeIfEmpty(code, room);
409
410
  return { isDone: true };
410
411
  },
412
+ restoreKeptRoom: async (code, restoreToAtMs) => (roomStates ? startOverRestored(code, () => roomStates.restoreKeptRoom(code, restoreToAtMs)) : { refusal: "no_history" }),
413
+ undoKeptRoomRestore: async (code, keptRoomRestoreId) => (roomStates ? startOverRestored(code, () => roomStates.undoKeptRoomRestore(code, keptRoomRestoreId)) : { refusal: "no_history" }),
414
+ listKeptRoomRestores: async (code) => roomStates?.keptRoomRestores(code) ?? null,
411
415
  };
412
416
  }
413
417
 
418
+ /**
419
+ * A kept room back at an earlier state: what it holds now is saved first, so the undo brings all of it back, then its row changes and the running
420
+ * room goes. Its players' connections drop, their games reconnect, and the room comes back from its row, with onRestored, as after a restart.
421
+ */
422
+ function startOverRestored(code, restore) {
423
+ const room = rooms.get(code);
424
+ if (room && !room.core.ended) {
425
+ room.savedStateKey = null;
426
+ saveRoom(code, room);
427
+ }
428
+ const answer = restore();
429
+ if (answer.keptRoomRestore && room) {
430
+ stopTimers(room);
431
+ rooms.delete(code);
432
+ for (const ws of room.sockets.values()) ws.terminate();
433
+ }
434
+ return answer;
435
+ }
436
+
414
437
  /** A room here by its code that hasn't ended, or, when isWaking, a kept one asleep in the saved states, woken. */
415
438
  function runningRoom(code, isWaking) {
416
439
  const running = rooms.get(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, ROOM_MESSAGE_LIMITS, ROOM_MESSAGE_SENDER_KINDS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, type Audience, type AwayInfo, type AwayMode, type AwayReason, type BroadcastOptions, type Delivery, type GameServer, type GameServerErrorCode, type HandlerResult, type IdleCheck, type JoinAnswer, type ChatAnswer, type ChatInfo, type JoinInfo, type KickOptions, type LiveRoom, type LeaveInfo, type LeaveReason, type MoveOptions, type MoveResult, type Player, type Purchase, type Refusal, type ReserveOptions, type RestoreInfo, type Room, type RoomErrorCode, type RoomInfo, type RoomMessage, type RoomMessageSenderKind, type RoomPlayer, type RoomSelection, type SendOptions, type VersionUpdate, } from "./room.ts";
1
+ export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, ROOM_MESSAGE_LIMITS, ROOM_MESSAGE_SENDER_KINDS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, type Audience, type AwayInfo, type AwayMode, type AwayReason, type BroadcastOptions, type Delivery, type GameServer, type GameServerErrorCode, type HandlerResult, type IdleCheck, type JoinAnswer, type ChatAnswer, type ChatInfo, type JoinInfo, type KickOptions, type LiveRoom, type LeaveInfo, type LeaveReason, type MoveOptions, type MoveResult, type Player, type Purchase, type Refusal, type ReserveOptions, type KeptRoomRestoreInfo, type RestoreInfo, type Room, type RoomErrorCode, type RoomInfo, type RoomMessage, type RoomMessageSenderKind, type RoomPlayer, type RoomSelection, type SendOptions, type VersionUpdate, } from "./room.ts";
2
2
  export { REQUEST_LIMITS, type RequestErrorCode, type Server } from "./requests.ts";
3
3
  export { SCHEDULE_LIMITS, type Schedule, type ScheduledRun } from "./schedules.ts";
4
4
  export { WEBHOOK_LIMITS, verifyWebhook, type WebhookError, type WebhookErrorCode, type WebhookEvent, type WebhookEventKind, type WebhookHeaders } from "./webhooks.ts";
package/src/requests.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import type { GameDatabase } from "./database.ts";
13
13
  import type { ReceiptClaims } from "./pass.ts";
14
14
  import type { KickOptions, LiveRoom, Player, RoomPlayer, RoomSelection } from "./room.ts";
15
+ import type { KeptRoomRestore, KeptRoomRestoreList } from "./room-calls.ts";
15
16
  /**
16
17
  * What onRequest, onSchedule and onWebhook reach besides what they're given: the game's own database, the
17
18
  * same one its rooms use, its secrets, receipt checks, and the game's live rooms. Room messages sent from here
@@ -51,6 +52,18 @@ export interface Server {
51
52
  endRoom(roomCode: string, message?: string): Promise<{
52
53
  isDone: boolean;
53
54
  }>;
55
+ /**
56
+ * Put a kept room's world back as it was at a moment in the last 30 days (an ISO time). Who may do this is your game's own rule, so
57
+ * check it first. Players in the room drop for a second or two and reconnect, and its onRestored runs with restoreInfo.keptRoomRestore;
58
+ * tell them your own way. Called from inside that room, the room starts over, so nothing after the call runs on it.
59
+ */
60
+ restoreKeptRoom(roomCode: string, restoreOptions: {
61
+ restoreToAt: string;
62
+ }): Promise<KeptRoomRestore>;
63
+ /** Put a kept room's world back as it was just before an earlier restore, for 30 days after it. */
64
+ undoKeptRoomRestore(roomCode: string, keptRoomRestoreId: string): Promise<KeptRoomRestore>;
65
+ /** What a restore screen draws from: the earliest moment the world can go back to, and its restores, newest first. */
66
+ listKeptRoomRestores(roomCode: string): Promise<KeptRoomRestoreList>;
54
67
  }
55
68
  export declare const REQUEST_LIMITS: {
56
69
  /** A request, as JSON. */
@@ -12,7 +12,35 @@ export interface RoomCallLimits {
12
12
  maximumRoomMessageSizeBytes: number;
13
13
  }
14
14
  /** The error a mistake in a room call throws: GameServerError, or the sandbox's copy of it. */
15
- export type RoomCallErrorClass = new (code: "bad_room_call" | "room_call_unavailable", field: string, message: string, fix: string) => Error;
15
+ export type RoomCallErrorClass = new (code: "bad_room_call" | "room_call_unavailable" | "room_call_limit", field: string, message: string, fix: string) => Error;
16
+ /** A kept room's restore, as server.restoreKeptRoom, undoKeptRoomRestore and listKeptRoomRestores answer it, with ISO times. */
17
+ export interface KeptRoomRestore {
18
+ id: string;
19
+ roomCode: string;
20
+ restoreToAt: string;
21
+ restoredAt: string;
22
+ undoesKeptRoomRestoreId: string | null;
23
+ undoExpiresAt: string;
24
+ }
25
+ /** What server.listKeptRoomRestores answers: the earliest moment the world can go back to, and its restores, newest first. */
26
+ export interface KeptRoomRestoreList {
27
+ earliestRestoreToAt: string;
28
+ restores: KeptRoomRestore[];
29
+ }
30
+ /** How a room reach answers a restore: the restore, or why not, which the call throws as an error. */
31
+ export type KeptRoomRestoreAnswer = {
32
+ keptRoomRestore: KeptRoomRestore;
33
+ refusal?: undefined;
34
+ earliestRestoreToAt?: undefined;
35
+ } | {
36
+ keptRoomRestore?: undefined;
37
+ refusal: "no_kept_room" | "no_kept_room_restore" | "no_history" | "limited" | "failed";
38
+ earliestRestoreToAt?: undefined;
39
+ } | {
40
+ keptRoomRestore?: undefined;
41
+ refusal: "bad_time";
42
+ earliestRestoreToAt: string;
43
+ };
16
44
  /** A send's delivery as the room acts on it: latest-only, with its latestKey, or reliable. */
17
45
  export type CheckedDelivery = {
18
46
  isLatestOnly: false;
@@ -67,6 +95,12 @@ export interface RoomCallChecks {
67
95
  labels?: Record<string, string>;
68
96
  };
69
97
  checkEnd(message: unknown): string | undefined;
98
+ /** server.restoreKeptRoom's options as the moment to go back to, in epoch milliseconds. */
99
+ checkRestoreToAt(restoreOptions: unknown, call: string): number;
100
+ checkKeptRoomRestoreId(keptRoomRestoreId: unknown, call: string): string;
101
+ /** A room reach's answer as the restore, or thrown as the error the game server's code catches. */
102
+ keptRoomRestoreOf(answer: KeptRoomRestoreAnswer, call: string): KeptRoomRestore;
103
+ keptRoomRestoreListOf(listed: KeptRoomRestoreList | null, call: string): KeptRoomRestoreList;
70
104
  /** room.sendToRoom: another room's code (never this room's) and the message as plain JSON within the size, copied. */
71
105
  checkSendToRoom(toRoomCode: unknown, message: unknown, ownRoomCode: string | null, call: string): {
72
106
  toRoomCode: string;
package/src/room-calls.js CHANGED
@@ -177,6 +177,40 @@ function roomCallChecksParts() {
177
177
  throw mistake("room.end", "room.end's message must be text.", "Give the text players see, or leave it out.");
178
178
  return message.slice(0, limits.maximumKickMessageCharacterCount);
179
179
  };
180
+ const checkRestoreToAt = (restoreOptions, call) => {
181
+ const fix = 'Give { restoreToAt } as an ISO time from the last 30 days, like "2026-10-18T14:05:00Z"; server.listKeptRoomRestores says the earliest.';
182
+ const { restoreToAt } = closed(restoreOptions, ["restoreToAt"], call);
183
+ const atMs = typeof restoreToAt === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(restoreToAt) ? Date.parse(restoreToAt) : NaN;
184
+ if (!Number.isFinite(atMs))
185
+ throw mistake(call, call + " needs restoreToAt as an ISO time.", fix);
186
+ return atMs;
187
+ };
188
+ const checkKeptRoomRestoreId = (keptRoomRestoreId, call) => {
189
+ if (typeof keptRoomRestoreId !== "string" || !keptRoomRestoreId)
190
+ throw mistake(call, call + " needs a restore's ID.", "Give an id from server.listKeptRoomRestores.");
191
+ return keptRoomRestoreId;
192
+ };
193
+ const noKeptRoom = (call) => mistake(call, call + " needs a kept room of this game that isn't deleted.", "Give the code of a kept room: its game server calls room.setKept(true).");
194
+ const keptRoomRestoreOf = (answer, call) => {
195
+ if (answer.keptRoomRestore)
196
+ return answer.keptRoomRestore;
197
+ if (answer.refusal === "bad_time")
198
+ throw mistake(call, call + "'s restoreToAt isn't a moment this world can go back to.", "Give a time from " + answer.earliestRestoreToAt + " to now.");
199
+ if (answer.refusal === "no_kept_room")
200
+ throw noKeptRoom(call);
201
+ if (answer.refusal === "no_kept_room_restore")
202
+ throw mistake(call, call + " needs a restore of this world from the last 30 days.", "Give an id from server.listKeptRoomRestores.");
203
+ if (answer.refusal === "limited")
204
+ throw new ErrorClass("room_call_limit", call, "A world can go back up to 5 times a minute.", "Wait a minute, then try again.");
205
+ if (answer.refusal === "no_history")
206
+ throw new ErrorClass("room_call_unavailable", call, call + " isn't available here: these rooms keep no history.", "On Zaisma, and on the Node runner with --room-state-file, it works.");
207
+ throw new ErrorClass("room_call_unavailable", call, "The world didn't answer, so nothing changed.", "Try again in a moment.");
208
+ };
209
+ const keptRoomRestoreListOf = (listed, call) => {
210
+ if (!listed)
211
+ throw noKeptRoom(call);
212
+ return listed;
213
+ };
180
214
  const readRoomMessage = (message, call) => {
181
215
  if (message === undefined || message === null)
182
216
  throw mistake(call, `${call} needs a message.`, "Give the message as plain JSON, like { kind: \"blood_moon\" }.");
@@ -207,7 +241,7 @@ function roomCallChecksParts() {
207
241
  return { toRoomCode, message: readRoomMessage(message, call) };
208
242
  };
209
243
  const checkSendToEveryRoom = (message, roomSelection, call) => ({ message: readRoomMessage(message, call), ...checkRoomSelection(roomSelection, call) });
210
- return { checkBroadcast, checkSend, checkBroadcastBytes, checkSendBytes, checkKick, checkReserve, checkMoveTo, checkSetInfo, checkEnd, checkSendToRoom, checkSendToEveryRoom, checkRoomSelection, tidy, tidyRoomInfo, checkRoomData };
244
+ return { checkBroadcast, checkSend, checkBroadcastBytes, checkSendBytes, checkKick, checkReserve, checkMoveTo, checkSetInfo, checkEnd, checkRestoreToAt, checkKeptRoomRestoreId, keptRoomRestoreOf, keptRoomRestoreListOf, checkSendToRoom, checkSendToEveryRoom, checkRoomSelection, tidy, tidyRoomInfo, checkRoomData };
211
245
  }
212
246
  return { checksFor };
213
247
  }
package/src/room.d.ts CHANGED
@@ -688,6 +688,8 @@ export interface RoomReach {
688
688
  }>;
689
689
  }
690
690
  /** What the game server reaches outside rooms besides RoomReach (server.getRoom, kickFromRoom, endRoom), its calls already checked. */
691
+ export type { KeptRoomRestore, KeptRoomRestoreAnswer, KeptRoomRestoreList } from "./room-calls.ts";
692
+ import type { KeptRoomRestoreAnswer, KeptRoomRestoreList } from "./room-calls.ts";
691
693
  export interface ServerRoomReach extends RoomReach {
692
694
  getRoom(roomCode: string): Promise<{
693
695
  room: LiveRoom;
@@ -699,6 +701,9 @@ export interface ServerRoomReach extends RoomReach {
699
701
  endRoom(roomCode: string, message: string | undefined): Promise<{
700
702
  isDone: boolean;
701
703
  }>;
704
+ restoreKeptRoom(roomCode: string, restoreToAtMs: number): Promise<KeptRoomRestoreAnswer>;
705
+ undoKeptRoomRestore(roomCode: string, keptRoomRestoreId: string): Promise<KeptRoomRestoreAnswer>;
706
+ listKeptRoomRestores(roomCode: string): Promise<KeptRoomRestoreList | null>;
702
707
  }
703
708
  /**
704
709
  * What onRestored hears: when the state it came back with was saved (an ISO time), how many seconds of
@@ -709,13 +714,34 @@ export interface RestoreInfo {
709
714
  savedAt: string;
710
715
  lostDurationSec: number;
711
716
  savedVersionId: string | null;
717
+ /** The developer's restore that put this kept room's world back at an earlier moment, or null for any other restart. Tell your players your own way. */
718
+ keptRoomRestore: KeptRoomRestoreInfo | null;
719
+ }
720
+ /**
721
+ * What a kept room's onRestored hears when its world went back to an earlier moment (server.restoreKeptRoom, the API): the
722
+ * restore's ID, the moment, when it happened and the restore it undid, if any, with ISO times. erasedPlayerIds are players
723
+ * erased since the moment: the world's state came back with them in it, so drop them from it here.
724
+ */
725
+ export interface KeptRoomRestoreInfo {
726
+ /** The restore's ID, for server.undoKeptRoomRestore; null for a deleted world brought back from its last copy, which has no undo. */
727
+ id: string | null;
728
+ restoreToAt: string;
729
+ restoredAt: string;
730
+ undoesKeptRoomRestoreId: string | null;
731
+ erasedPlayerIds: string[];
712
732
  }
713
733
  /**
714
734
  * A restored room's restoreInfo, from when its state was saved and the version that saved it. A state
715
735
  * saved as the room emptied (isSavedEmpty) lost nothing, however long ago: a kept world waking after a week
716
736
  * reads savedAt for the time that passed.
717
737
  */
718
- export declare function restoreInfoOf(savedAtMs: number, nowMs?: number, savedVersionId?: string | null, isSavedEmpty?: boolean): RestoreInfo;
738
+ export declare function restoreInfoOf(savedAtMs: number, nowMs?: number, savedVersionId?: string | null, isSavedEmpty?: boolean, toldRestore?: {
739
+ id: string | null;
740
+ restoreToAtMs: number;
741
+ restoredAtMs: number;
742
+ undoesKeptRoomRestoreId: string | null;
743
+ erasedPlayerIds: string[];
744
+ } | null): RestoreInfo;
719
745
  /**
720
746
  * What canJoin answers: true lets the player in; false turns them away; { refusalMessage } turns them
721
747
  * away with that message. Answering nothing turns them away too, and is logged as a mistake.
package/src/room.js CHANGED
@@ -40,8 +40,19 @@ export const ROOM_MESSAGE_LIMITS = {
40
40
  * saved as the room emptied (isSavedEmpty) lost nothing, however long ago: a kept world waking after a week
41
41
  * reads savedAt for the time that passed.
42
42
  */
43
- export function restoreInfoOf(savedAtMs, nowMs = Date.now(), savedVersionId = null, isSavedEmpty = false) {
44
- return { savedAt: new Date(savedAtMs).toISOString(), lostDurationSec: isSavedEmpty ? 0 : Math.max(0, Math.round((nowMs - savedAtMs) / 1000)), savedVersionId };
43
+ export function restoreInfoOf(savedAtMs, nowMs = Date.now(), savedVersionId = null, isSavedEmpty = false, toldRestore = null) {
44
+ return {
45
+ savedAt: new Date(savedAtMs).toISOString(),
46
+ lostDurationSec: isSavedEmpty ? 0 : Math.max(0, Math.round((nowMs - savedAtMs) / 1000)),
47
+ savedVersionId,
48
+ keptRoomRestore: toldRestore && {
49
+ id: toldRestore.id,
50
+ restoreToAt: new Date(toldRestore.restoreToAtMs).toISOString(),
51
+ restoredAt: new Date(toldRestore.restoredAtMs).toISOString(),
52
+ undoesKeptRoomRestoreId: toldRestore.undoesKeptRoomRestoreId,
53
+ erasedPlayerIds: toldRestore.erasedPlayerIds,
54
+ },
55
+ };
45
56
  }
46
57
  /** What the sender hears when the game server's onChat stopped their message. */
47
58
  export const STOPPED_BY_GAME_MESSAGE = "Your message wasn't sent.";
@@ -388,6 +399,21 @@ export function newServer(serverParts, roomReach) {
388
399
  const reach = reachFor("server.endRoom");
389
400
  return reach.endRoom(roomCodeOf(roomCode, "server.endRoom"), checks.checkEnd(message));
390
401
  },
402
+ restoreKeptRoom: async (roomCode, restoreOptions) => {
403
+ const call = "server.restoreKeptRoom";
404
+ const reach = reachFor(call);
405
+ return checks.keptRoomRestoreOf(await reach.restoreKeptRoom(roomCodeOf(roomCode, call), checks.checkRestoreToAt(restoreOptions, call)), call);
406
+ },
407
+ undoKeptRoomRestore: async (roomCode, keptRoomRestoreId) => {
408
+ const call = "server.undoKeptRoomRestore";
409
+ const reach = reachFor(call);
410
+ return checks.keptRoomRestoreOf(await reach.undoKeptRoomRestore(roomCodeOf(roomCode, call), checks.checkKeptRoomRestoreId(keptRoomRestoreId, call)), call);
411
+ },
412
+ listKeptRoomRestores: async (roomCode) => {
413
+ const call = "server.listKeptRoomRestores";
414
+ const listed = await reachFor(call).listKeptRoomRestores(roomCodeOf(roomCode, call));
415
+ return checks.keptRoomRestoreListOf(listed, call);
416
+ },
391
417
  };
392
418
  }
393
419
  /** room.secrets and server.secrets where a game has none. */