@zaisma/game-server 1.8.0 → 1.10.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/local-play.js CHANGED
@@ -145,6 +145,8 @@ function defaultState() {
145
145
  // Each player's guest, while they play as one: { id, name }.
146
146
  guests: {},
147
147
  friends: [],
148
+ // Friend requests waiting between bench accounts: { from, to, createdAtMs }.
149
+ friendRequests: [],
148
150
  blocks: [],
149
151
  ownedItems: {},
150
152
  items: [],
@@ -629,6 +631,8 @@ export async function startLocalPlay(givenOptions) {
629
631
  if (rest === "" && method === "DELETE") {
630
632
  state.accounts = state.accounts.filter((each) => each.id !== account.id);
631
633
  state.friends = state.friends.filter((pair) => !pair.includes(account.id));
634
+ state.friendRequests = (state.friendRequests ?? []).filter((each) => each.from !== account.id && each.to !== account.id);
635
+ state.acceptedFriendRequests = (state.acceptedFriendRequests ?? []).filter((each) => each.from !== account.id && each.to !== account.id);
632
636
  state.blocks = state.blocks.filter((block) => block.blocker !== account.id && block.blocked !== account.id);
633
637
  state.purchases = state.purchases.filter((purchase) => purchase.accountId !== account.id);
634
638
  delete state.ownedItems[account.id];
@@ -951,6 +955,67 @@ export async function startLocalPlay(givenOptions) {
951
955
  }
952
956
  return reply(200, { player: { player_id: target.playerId, name: target.username }, is_friend: friendIdsOf(account.id).includes(target.id), is_blocked: state.blocks.some((block) => block.blocker === account.id && block.blocked === target.id) });
953
957
  }
958
+ // Friend requests between bench accounts, as Zaisma's game page sends and answers them (apps/site/src/friend-requests.ts).
959
+ if (path === "/friends/requests" || path.startsWith("/friends/requests/")) {
960
+ if (!account) return reply(401, { error: { code: "not_signed_in", message: "Sign in to add friends." } });
961
+ const requests = (state.friendRequests ??= []);
962
+ const isBlockedPair = (other) => state.blocks.some((block) => isPair([block.blocker, block.blocked], account.id, other.id));
963
+ const isFriend = (other) => friendIdsOf(account.id).includes(other.id);
964
+ const waitingFrom = (from, to) => requests.find((each) => each.from === from && each.to === to);
965
+ const makeFriends = (other) => {
966
+ // The sender hears of the accept on their next look, for a day, as on Zaisma.
967
+ (state.acceptedFriendRequests ??= []).push({ from: other.id, to: account.id, answeredAtMs: Date.now() });
968
+ state.friendRequests = requests.filter((each) => !isPair([each.from, each.to], account.id, other.id));
969
+ if (!isFriend(other)) state.friends.push([account.id, other.id]);
970
+ saveState();
971
+ log(`${account.username} and ${other.username} are friends now.`);
972
+ };
973
+ if (request.method === "GET" && path === "/friends/requests") {
974
+ const accepted = (state.acceptedFriendRequests ?? []).filter((each) => each.from === account.id && each.answeredAtMs > Date.now() - 86400000 && isFriend({ id: each.to })).map((each) => ({ to: accountOf(each.to), answeredAtMs: each.answeredAtMs })).filter((each) => each.to);
975
+ return reply(200, {
976
+ requests: requests.filter((each) => each.to === account.id).map((each) => ({ from: accountOf(each.from), createdAtMs: each.createdAtMs })).filter((each) => each.from).map((each) => ({ player_id: each.from.playerId, name: each.from.username, created_at_ms: each.createdAtMs, game_slug: gameSlug })).reverse(),
977
+ accepted: accepted.map((each) => ({ player_id: each.to.playerId, name: each.to.username, answered_at_ms: each.answeredAtMs })).reverse(),
978
+ });
979
+ }
980
+ const aboutMatch = /^\/friends\/requests\/about\/(player_[a-z0-9]{1,40})$/.exec(path);
981
+ if (request.method === "GET" && aboutMatch) {
982
+ const other = accountOfPlayer(aboutMatch[1]);
983
+ if (!other || other.id === account.id) return reply(200, { player: null, can_send: false, has_sent: false, is_friend: false });
984
+ const hasSent = Boolean(waitingFrom(account.id, other.id));
985
+ const canSend = !isFriend(other) && !hasSent && !isBlockedPair(other) && other.ageBand === account.ageBand && (Boolean(waitingFrom(other.id, account.id)) || glass.takesFriendRequests(other.ageBand, other.socialMode));
986
+ return reply(200, { player: { player_id: other.playerId, name: other.username }, can_send: canSend, has_sent: hasSent, is_friend: isFriend(other) });
987
+ }
988
+ if (isPost && path === "/friends/requests") {
989
+ const body = await readJson(request);
990
+ const other = typeof body.player_id === "string" ? accountOfPlayer(body.player_id) : null;
991
+ if (!other || other.id === account.id) return reply(409, { error: { code: "not_an_account", message: "Only players signed in to Zaisma take friend requests." } });
992
+ if (isFriend(other)) return reply(409, { error: { code: "already_friends", message: "You're friends already." } });
993
+ if (waitingFrom(other.id, account.id)) {
994
+ makeFriends(other);
995
+ return reply(200, { status: "friends" });
996
+ }
997
+ if (waitingFrom(account.id, other.id)) return reply(409, { error: { code: "already_sent", message: "Your request is waiting for them." } });
998
+ if (isBlockedPair(other) || other.ageBand !== account.ageBand || !glass.takesFriendRequests(other.ageBand, other.socialMode)) return reply(409, { error: { code: "not_taking_requests", message: "They aren't taking friend requests from you." } });
999
+ requests.push({ from: account.id, to: other.id, createdAtMs: Date.now() });
1000
+ saveState();
1001
+ log(`${account.username} sent ${other.username} a friend request.`);
1002
+ return reply(200, { status: "sent" });
1003
+ }
1004
+ const answerMatch = /^\/friends\/requests\/(player_[a-z0-9]{1,40})\/(accept|turn-down)$/.exec(path);
1005
+ if (isPost && answerMatch) {
1006
+ await readText(request);
1007
+ const other = accountOfPlayer(answerMatch[1]);
1008
+ if (!other || !waitingFrom(other.id, account.id)) return reply(404, { error: { code: "request_not_found", message: "That request isn't waiting any more." } });
1009
+ if (answerMatch[2] === "accept") makeFriends(other);
1010
+ else {
1011
+ state.friendRequests = requests.filter((each) => !(each.from === other.id && each.to === account.id));
1012
+ saveState();
1013
+ log(`${account.username} turned down ${other.username}'s friend request.`);
1014
+ }
1015
+ return reply(200, { answer: answerMatch[2] === "accept" ? "accepted" : "turned_down" });
1016
+ }
1017
+ return reply(404, { error: { code: "not_found", message: "Nothing here." } });
1018
+ }
954
1019
  // A report goes to Zaisma's moderators, or the game's developer: here the terminal says what it would send.
955
1020
  if (isPost && path === `${game}/report`) {
956
1021
  const body = await readJson(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.8.0",
3
+ "version": "1.10.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/sdk/saves.js CHANGED
@@ -33,7 +33,7 @@
33
33
  // first, doubling up to 1,024 while a batch's text stays under 1 MB (games keep whole files as records).
34
34
  var FIRST_BATCH_RECORD_COUNT = 16;
35
35
  var MAXIMUM_BATCH_RECORD_COUNT = 1024;
36
- var SMALL_BATCH_CHARACTER_COUNT = 1024 * 1024;
36
+ var SMALL_BATCH_BYTE_COUNT = 1024 * 1024;
37
37
 
38
38
  // Tests check the save format through this (test/node/saves.test.ts).
39
39
  if (__ZAISMA_SAVES__.test) return __ZAISMA_SAVES__.test({ encode: encode, decode: decode, saveText: saveText, putSave: putSave, readDatabases: readDatabases, databasesReader: databasesReader });
@@ -67,6 +67,17 @@
67
67
  return textOf(canonical(local), idbText);
68
68
  }
69
69
  // The same text as canonicalSave on the server: all three keys, always in this order.
70
+ /** Text's size in UTF-8 bytes, as the server counts a save against the limit, without encoding it. */
71
+ function byteLength(text) {
72
+ var byteCount = text.length;
73
+ for (var i = 0; i < text.length; i++) {
74
+ var code = text.charCodeAt(i);
75
+ if (code < 0x80) continue;
76
+ // Two bytes below U+0800, and each half of a surrogate pair (four bytes the pair); three for the rest.
77
+ byteCount += code < 0x800 || (code >= 0xd800 && code < 0xe000) ? 1 : 2;
78
+ }
79
+ return byteCount;
80
+ }
70
81
  function textOf(localText, idbText) {
71
82
  return '{"save_format_version_number":1,"local_storage_items":' + localText + ',"indexeddb_databases":' + (idbText || "[]") + "}";
72
83
  }
@@ -319,21 +330,23 @@
319
330
 
320
331
  /**
321
332
  * readNumberbText while the game plays: each store's text is kept, and a read only reads again the stores
322
- * the game changed (changed: { isAll, databases: [name], stores: ["<database>\n<store>"] }), a batch of
323
- * records at a time, stopping once the save passes maximumCharacterCount characters. The text is exactly
333
+ * the game changed (changed: { isAll, isCountCheck, databases: [name], stores: ["<database>\n<store>"] }), a batch
334
+ * of records at a time, stopping once the save passes maximumByteCount bytes (UTF-8, as the server counts).
335
+ * isCountCheck also reads again any database whose version, stores or record counts changed, which catches most
336
+ * writes the script can't watch (the game's own workers) without reading what didn't change. The text is exactly
324
337
  * readNumberbText's, put together from the kept parts. Answers { text } or { isOverLimit: true }.
325
338
  */
326
339
  function databasesReader() {
327
340
  // Database name -> { version, stores: { name: { text, hasRecords } } }
328
341
  var kept = {};
329
- return function read(changed, maximumCharacterCount) {
342
+ return function read(changed, maximumByteCount) {
330
343
  var changedStores = {};
331
344
  changed.stores.forEach(function (storeKey) {
332
345
  changedStores[storeKey] = true;
333
346
  });
334
347
  return names().then(function (list) {
335
348
  var keptNow = {};
336
- var characterCount = 0;
349
+ var byteCount = 0;
337
350
  var index = 0;
338
351
  function next() {
339
352
  if (index === list.length) {
@@ -355,26 +368,73 @@
355
368
  var isAnyStore = isWhole || Object.keys(changedStores).some(function (storeKey) {
356
369
  return storeKey.indexOf(name + "\n") === 0;
357
370
  });
358
- if (!isAnyStore) {
371
+ var keep = function () {
359
372
  keptNow[name] = before;
360
- characterCount += before.characterCount;
361
- return characterCount > maximumCharacterCount ? { isOverLimit: true } : next();
362
- }
363
- return readChanged(name, before, isWhole, changedStores, maximumCharacterCount - characterCount).then(function (database) {
364
- if (database && database.isOverLimit) return { isOverLimit: true };
365
- if (database) {
366
- keptNow[name] = database;
367
- characterCount += database.characterCount;
368
- }
369
- return next();
373
+ byteCount += before.byteCount;
374
+ return byteCount > maximumByteCount ? { isOverLimit: true } : next();
375
+ };
376
+ var readAgain = function (isWholeRead) {
377
+ return readChanged(name, before, isWholeRead, changedStores, maximumByteCount - byteCount).then(function (database) {
378
+ if (database && database.isOverLimit) return { isOverLimit: true };
379
+ if (database) {
380
+ keptNow[name] = database;
381
+ byteCount += database.byteCount;
382
+ }
383
+ return next();
384
+ });
385
+ };
386
+ if (isAnyStore) return readAgain(isWhole);
387
+ if (!changed.isCountCheck) return keep();
388
+ return countsChanged(name, before).then(function (isChanged) {
389
+ return isChanged ? readAgain(true) : keep();
370
390
  });
371
391
  }
372
392
  return next();
373
393
  });
374
394
  };
375
395
  }
396
+ /** Whether a database kept from an earlier read looks changed: its version, stores or a store's record count. Only counts are read, never records. */
397
+ function countsChanged(name, before) {
398
+ var created = false;
399
+ return open(name, 0, function () {
400
+ created = true;
401
+ }).then(function (database) {
402
+ var stores = storeNames(database);
403
+ if (created || database.version !== before.version || stores.join("\n") !== before.storeNames.join("\n")) {
404
+ database.close();
405
+ if (created) indexedDB.deleteDatabase(name);
406
+ return true;
407
+ }
408
+ if (!stores.length) {
409
+ database.close();
410
+ return false;
411
+ }
412
+ return new Promise(function (ok) {
413
+ var transaction = database.transaction(stores, "readonly");
414
+ var recordCounts = {};
415
+ stores.forEach(function (storeName) {
416
+ var counting = transaction.objectStore(storeName).count();
417
+ counting.onsuccess = function () {
418
+ recordCounts[storeName] = counting.result;
419
+ };
420
+ });
421
+ transaction.oncomplete = function () {
422
+ database.close();
423
+ ok(
424
+ stores.some(function (storeName) {
425
+ return recordCounts[storeName] !== before.stores[storeName].recordCount;
426
+ }),
427
+ );
428
+ };
429
+ transaction.onerror = transaction.onabort = function () {
430
+ database.close();
431
+ ok(true);
432
+ };
433
+ });
434
+ });
435
+ }
376
436
  /** One database for databasesReader: its stores read again where changed, the rest as kept. null when it's gone or has no stores. */
377
- function readChanged(name, before, isWhole, changedStores, maximumCharacterCount) {
437
+ function readChanged(name, before, isWhole, changedStores, maximumByteCount) {
378
438
  var created = false;
379
439
  return open(name, 0, function () {
380
440
  created = true;
@@ -390,12 +450,12 @@
390
450
  var reading = stores.filter(function (storeName) {
391
451
  return isWhole || !before.stores[storeName] || changedStores[name + "\n" + storeName];
392
452
  });
393
- var out = { version: database.version, storeNames: stores, stores: {}, characterCount: 0, hasRecords: false };
453
+ var out = { version: database.version, storeNames: stores, stores: {}, byteCount: 0, hasRecords: false };
394
454
  stores.forEach(function (storeName) {
395
455
  if (reading.indexOf(storeName) === -1) out.stores[storeName] = before.stores[storeName];
396
456
  });
397
457
  stores.forEach(function (storeName) {
398
- if (out.stores[storeName]) out.characterCount += out.stores[storeName].text.length;
458
+ if (out.stores[storeName]) out.byteCount += out.stores[storeName].byteCount;
399
459
  });
400
460
  if (!reading.length) {
401
461
  database.close();
@@ -421,20 +481,21 @@
421
481
  var keys = store.getAllKeys(range, batchRecordCount);
422
482
  var values = store.getAll(range, batchRecordCount);
423
483
  values.onsuccess = function () {
424
- var previousCharacterCount = out.characterCount;
484
+ var previousByteCount = out.byteCount;
425
485
  var blobCharacterCount = pending.blobCharacterCount || 0;
426
486
  values.result.forEach(function (record, i) {
427
487
  var encoded = encode([keys.result[i], record], pending, []);
428
488
  copy.records.push(encoded);
429
- out.characterCount += JSON.stringify(encoded).length + 1;
489
+ out.byteCount += byteLength(JSON.stringify(encoded)) + 1;
430
490
  });
431
- out.characterCount += (pending.blobCharacterCount || 0) - blobCharacterCount;
432
- if (out.characterCount > maximumCharacterCount) {
491
+ // A file's base64 text is all ASCII: a character a byte.
492
+ out.byteCount += (pending.blobCharacterCount || 0) - blobCharacterCount;
493
+ if (out.byteCount > maximumByteCount) {
433
494
  isOverLimit = true;
434
495
  transaction.abort();
435
496
  return;
436
497
  }
437
- var isSmall = out.characterCount - previousCharacterCount < SMALL_BATCH_CHARACTER_COUNT;
498
+ var isSmall = out.byteCount - previousByteCount < SMALL_BATCH_BYTE_COUNT;
438
499
  if (values.result.length === batchRecordCount) nextBatch(keys.result[keys.result.length - 1], isSmall ? Math.min(batchRecordCount * 2, MAXIMUM_BATCH_RECORD_COUNT) : batchRecordCount);
439
500
  else nextStore();
440
501
  };
@@ -448,11 +509,12 @@
448
509
  var recordTexts = stored.records.map(function (record) {
449
510
  return JSON.stringify(record);
450
511
  });
451
- out.stores[stored.shape.name] = { text: shapeText.slice(0, -1) + ',"records":[' + recordTexts.join(",") + "]}", hasRecords: stored.records.length > 0 };
512
+ var storeText = shapeText.slice(0, -1) + ',"records":[' + recordTexts.join(",") + "]}";
513
+ out.stores[stored.shape.name] = { text: storeText, byteCount: byteLength(storeText), recordCount: stored.records.length, hasRecords: stored.records.length > 0 };
452
514
  });
453
- out.characterCount = 0;
515
+ out.byteCount = 0;
454
516
  stores.forEach(function (storeName) {
455
- out.characterCount += out.stores[storeName].text.length;
517
+ out.byteCount += out.stores[storeName].byteCount;
456
518
  });
457
519
  ok(finish(out));
458
520
  }, fail);
@@ -477,7 +539,7 @@
477
539
  var read = databasesReader();
478
540
  addEventListener("message", function (event) {
479
541
  var asked = event.data || {};
480
- read(asked.changed, asked.maximumCharacterCount).then(
542
+ read(asked.changed, asked.maximumByteCount).then(
481
543
  function (answer) {
482
544
  answer.id = asked.id;
483
545
  postMessage(answer);
@@ -596,7 +658,7 @@
596
658
  // The page always hands over a v1 save.
597
659
  var local = save.local_storage_items;
598
660
  var databases = decode(save.indexeddb_databases);
599
- // IndexedDB first: it's the part that can fail, and then localStorage is still as it was.
661
+ // IndexedDB first, so a database that can't be set up leaves localStorage as it was.
600
662
  var writing =
601
663
  window.indexedDB && indexedDB.databases
602
664
  ? writeDatabases(databases, onBlocked)
@@ -604,10 +666,20 @@
604
666
  ? Promise.reject(new Error("No IndexedDB here"))
605
667
  : Promise.resolve();
606
668
  return writing.then(function () {
607
- localStorage.clear();
608
- Object.keys(local).forEach(function (key) {
609
- localStorage.setItem(key, String(local[key]));
610
- });
669
+ // A write that throws (the browser's storage is full) puts this device's localStorage back as it was.
670
+ var before = snapshot();
671
+ try {
672
+ localStorage.clear();
673
+ Object.keys(local).forEach(function (key) {
674
+ localStorage.setItem(key, String(local[key]));
675
+ });
676
+ } catch (error) {
677
+ localStorage.clear();
678
+ Object.keys(before).forEach(function (key) {
679
+ localStorage.setItem(key, before[key]);
680
+ });
681
+ throw error;
682
+ }
611
683
  });
612
684
  }
613
685
 
@@ -681,7 +753,8 @@
681
753
 
682
754
  // What the game changed since the last check, so a check reads only that again: a store, a whole
683
755
  // database (set up anew or deleted) or localStorage. Writes the game's own workers make, and
684
- // localStorage set as a property, aren't seen, so every 10th check (5 minutes) reads everything.
756
+ // localStorage set as a property, aren't seen: every 10th check (5 minutes) reads localStorage and any
757
+ // database whose version or record counts changed, and leaving the page reads every database whole.
685
758
  var changedStores = {};
686
759
  var changedDatabases = {};
687
760
  var isLocalChanged = true;
@@ -735,7 +808,7 @@
735
808
  var hereReader = null;
736
809
  var waiting = null;
737
810
  var readNumber = 0;
738
- function readDatabasesText(changed, maximumCharacterCount) {
811
+ function readDatabasesText(changed, maximumByteCount) {
739
812
  if (!isWorkerTried) {
740
813
  isWorkerTried = true;
741
814
  try {
@@ -755,7 +828,7 @@
755
828
  }
756
829
  if (!worker) {
757
830
  hereReader = hereReader || databasesReader();
758
- return hereReader(changed, maximumCharacterCount);
831
+ return hereReader(changed, maximumByteCount);
759
832
  }
760
833
  var id = ++readNumber;
761
834
  return new Promise(function (ok) {
@@ -766,14 +839,14 @@
766
839
  ok(answer);
767
840
  },
768
841
  };
769
- worker.postMessage({ id: id, changed: changed, maximumCharacterCount: maximumCharacterCount });
842
+ worker.postMessage({ id: id, changed: changed, maximumByteCount: maximumByteCount });
770
843
  }).then(function (answer) {
771
844
  if (!answer.isFailed) return answer;
772
845
  // The worker can't read here (or stopped): read on the page from now on, from the start.
773
846
  if (worker) worker.terminate();
774
847
  worker = null;
775
848
  hereReader = databasesReader();
776
- return hereReader({ isAll: true, databases: [], stores: [] }, maximumCharacterCount);
849
+ return hereReader({ isAll: true, databases: [], stores: [] }, maximumByteCount);
777
850
  });
778
851
  }
779
852
 
@@ -781,35 +854,44 @@
781
854
  var last = null;
782
855
  var timer = null;
783
856
  var localText = null;
857
+ var localByteCount = 0;
784
858
  var idbText = null;
785
859
  var checkCount = 0;
786
860
  var reading = false;
787
861
  var again = false;
788
862
  // localStorage this small is read at every check anyway, which also catches it set as a property.
789
863
  var SMALL_LOCAL_CHARACTER_COUNT = 64 * 1024;
790
- /** isLeaving: the player is leaving the tab, so localStorage is read whatever was seen. */
791
- function check(isLeaving) {
864
+ /**
865
+ * isLeaving: the player is leaving the tab, so localStorage is read whatever was seen. isWhole: the page is going
866
+ * away, so every database is read whole, catching any write the script couldn't see.
867
+ */
868
+ function check(isLeaving, isWhole) {
792
869
  if (!syncing) return;
793
870
  if (reading) {
794
871
  again = true;
795
872
  return;
796
873
  }
797
- var isAll = isAllChanged || idbText === null || ++checkCount % 10 === 0;
798
- var changed = { isAll: isAll, databases: Object.keys(changedDatabases), stores: Object.keys(changedStores) };
799
- var readsIdb = isAll || changed.databases.length > 0 || changed.stores.length > 0;
800
- var readsLocal = isAll || isLocalChanged || localText === null || isLeaving === true || localText.length < SMALL_LOCAL_CHARACTER_COUNT;
874
+ var isAll = isAllChanged || idbText === null || isWhole === true;
875
+ // Every 10th check (5 minutes), also what the script can't watch: localStorage, and databases whose version or record counts changed.
876
+ var isCountCheck = !isAll && ++checkCount % 10 === 0;
877
+ var changed = { isAll: isAll, isCountCheck: isCountCheck, databases: Object.keys(changedDatabases), stores: Object.keys(changedStores) };
878
+ var readsIdb = isAll || isCountCheck || changed.databases.length > 0 || changed.stores.length > 0;
879
+ var readsLocal = isAll || isCountCheck || isLocalChanged || localText === null || isLeaving === true || localText.length < SMALL_LOCAL_CHARACTER_COUNT;
801
880
  if (!readsIdb && !readsLocal) return;
802
881
  changedStores = {};
803
882
  changedDatabases = {};
804
883
  isAllChanged = false;
805
884
  isLocalChanged = false;
806
885
  reading = true;
807
- if (readsLocal) localText = canonical(snapshot());
808
- var maximumCharacterCount = saveLimitBytes - localText.length;
809
- (readsIdb ? readDatabasesText(changed, maximumCharacterCount) : Promise.resolve({ text: idbText }))
886
+ if (readsLocal) {
887
+ localText = canonical(snapshot());
888
+ localByteCount = byteLength(localText);
889
+ }
890
+ var maximumByteCount = saveLimitBytes - localByteCount;
891
+ (readsIdb ? readDatabasesText(changed, maximumByteCount) : Promise.resolve({ text: idbText }))
810
892
  .then(function (answer) {
811
893
  if (answer.isFailed) throw new Error("The save couldn't be read");
812
- if (answer.isOverLimit || localText.length > saveLimitBytes) {
894
+ if (answer.isOverLimit || localByteCount > saveLimitBytes) {
813
895
  // Read again in full next time: what's kept stopped partway.
814
896
  idbText = null;
815
897
  post({ step: "over_limit" });
@@ -856,17 +938,15 @@
856
938
  fail(new Error("The game holds its database open"));
857
939
  }).then(ok, fail);
858
940
  })
859
- .then(function () {
860
- // What's in storage now is the save the page sent: the next check has nothing new to send.
861
- return readNumberbText();
862
- })
863
941
  .then(
864
- function (freshIdbText) {
865
- idbText = freshIdbText;
942
+ function () {
943
+ // What's in storage now is the save the page sent, already encoded: nothing is read again on the game's thread.
944
+ idbText = save.indexeddb_databases.length ? JSON.stringify(save.indexeddb_databases) : "";
866
945
  localText = canonical(snapshot());
867
- // The worker's kept parts are from before: the next check reads everything (and finds nothing new).
946
+ localByteCount = byteLength(localText);
947
+ // The worker's kept parts are from before: the next check reads everything there (and finds nothing new).
868
948
  isAllChanged = true;
869
- last = textOf(localText, freshIdbText);
949
+ last = textOf(localText, idbText);
870
950
  post({ step: "applied", ok: true });
871
951
  },
872
952
  function () {
@@ -878,7 +958,7 @@
878
958
  if (document.visibilityState === "hidden") check(true);
879
959
  });
880
960
  addEventListener("pagehide", function () {
881
- check(true);
961
+ check(true, true);
882
962
  });
883
963
  post({ step: "hello", phase: "game", versionId: versionId });
884
964
  })();
package/sdk/zaisma.d.ts CHANGED
@@ -240,8 +240,35 @@ declare namespace Zaisma {
240
240
  * await Zaisma.friends.setRoom(null); // left the match
241
241
  */
242
242
  setRoom(room: OwnServerRoom | null): Promise<void>;
243
+ /**
244
+ * Offer the player to send another player a friend request, from your own scoreboard, lobby or menu.
245
+ * Zaisma asks the player first on its page ("Send Mia a friend request?", with the name Zaisma knows
246
+ * them by), so nothing is sent without them, and the other player gets it over their game's corner and
247
+ * on zaisma.com/friends. Players can also send one from Players in Zaisma's bar, so a button of your
248
+ * own is optional. Resolves with what happened: "sent"; "cancelled" when the player said no or another
249
+ * question was up; "already_friends"; or "not_taking_requests" when the other player doesn't take them
250
+ * from this one (their settings, their age group, a block, or a guest), which never says which. Your game
251
+ * never hears of requests themselves, only that two players are friends (room.areFriends on your game server, and the
252
+ * passes' tags, from their next join). Fails with error.code "account_needed" for a guest (ask them to
253
+ * sign in with {@link player.signIn}), "bad_player_id" for something that isn't a player's id, and
254
+ * "not_on_zaisma" when the game isn't played on Zaisma.
255
+ *
256
+ * @example
257
+ * // A button beside each player on your end-of-match screen.
258
+ * const { status } = await Zaisma.friends.sendRequest(player.id);
259
+ * if (status === "sent") button.textContent = "Request sent";
260
+ */
261
+ sendRequest(playerId: string): Promise<FriendRequestAnswer>;
243
262
  }
244
263
 
264
+ /** What became of a friend request the game offered ({@link Friends.sendRequest}). */
265
+ interface FriendRequestAnswer {
266
+ status: FriendRequestStatus;
267
+ }
268
+
269
+ /** "sent", "cancelled" (the player said no), "already_friends", or "not_taking_requests" (never why). */
270
+ type FriendRequestStatus = "sent" | "cancelled" | "already_friends" | "not_taking_requests";
271
+
245
272
  /** The player's friends playing this game ({@link Friends.list}). */
246
273
  interface FriendList {
247
274
  /** By name. Empty when none of them are playing it. */
@@ -1005,7 +1032,8 @@ declare namespace Zaisma {
1005
1032
  readonly canChat: boolean;
1006
1033
  /**
1007
1034
  * When Zaisma will end this match, an ISO time, or null: set once the game's hosting pauses for the
1008
- * month, an hour after the pause (the "ending" event). Never set in a room on your own server.
1035
+ * month, an hour after the pause (the "ending" event); null again if Zaisma calls the end off (the
1036
+ * "endingCancelled" event). Never set in a room on your own server.
1009
1037
  */
1010
1038
  readonly endsAt: string | null;
1011
1039
  /**
@@ -1222,6 +1250,11 @@ declare namespace Zaisma {
1222
1250
  * "ended" follows then.
1223
1251
  */
1224
1252
  ending: (ending: Ending) => void;
1253
+ /**
1254
+ * Zaisma called off the end the "ending" event announced, because the game's hosting started again:
1255
+ * room.endsAt is null, and the match goes on. Optional, as events added after v1 are, so v1's types still fit.
1256
+ */
1257
+ endingCancelled?: () => void;
1225
1258
  /** The game server or the host removed the player from the room. The connection closes. */
1226
1259
  kicked: (kicked: Kicked) => void;
1227
1260
  /** The room's listing, Join switch, name or labels changed, or how many watch: room.isListed, room.isOpen, room.friendJoinsEnabled, room.name, room.labels and room.spectatorCount have the new ones. */
package/sdk/zaisma.js CHANGED
@@ -29,7 +29,7 @@
29
29
  var SMOOTHING_OPTIONS = ["delayMs"];
30
30
  var DELIVERIES = ["reliable", "latest_only"];
31
31
  var ROOM_INFO_FIELDS = ["name", "labels"];
32
- var ROOM_EVENTS = ["message", "bytes", "state", "players", "connectionStatus", "ended", "ending", "kicked", "settings", "move", "moved", "idle", "away", "back", "muted", "error", "versionUpdate", "chat"];
32
+ var ROOM_EVENTS = ["message", "bytes", "state", "players", "connectionStatus", "ended", "ending", "endingCancelled", "kicked", "settings", "move", "moved", "idle", "away", "back", "muted", "error", "versionUpdate", "chat"];
33
33
  /** Chat (room.chat): the longest message, the lines a room keeps in memory for reports, and how many of them a report carries. */
34
34
  var MAXIMUM_CHAT_CHARACTER_COUNT = 2000;
35
35
  var KEPT_CHAT_LINE_COUNT = 50;
@@ -617,6 +617,9 @@
617
617
  } else if (message.type === "blocks") {
618
618
  room._blocks = new Set(Array.isArray(message.playerIds) ? message.playerIds : []);
619
619
  room._mutedChanged();
620
+ } else if (message.type === "friend_request") {
621
+ // Someone here sent this player a friend request, or accepted theirs: the Zaisma page looks now (never who).
622
+ if (onZaismaPage()) window.parent.postMessage({ type: "zaisma:friend-requests" }, config.site);
620
623
  } else if (message.type === "message") {
621
624
  room._emit("message", message.data, typeof message.fromPlayerId === "string" ? message.fromPlayerId : null);
622
625
  } else if (message.type === "chat" || message.type === "chat_sent" || message.type === "chat_stopped" || (message.type === "error" && typeof message.chatNumber === "number")) {
@@ -640,6 +643,9 @@
640
643
  } else if (message.type === "ending") {
641
644
  room.endsAt = message.endsAt;
642
645
  room._emit("ending", { endsAt: message.endsAt });
646
+ } else if (message.type === "ending_cancelled") {
647
+ room.endsAt = null;
648
+ room._emit("endingCancelled");
643
649
  } else if (message.type === "version_update") {
644
650
  var versionUpdate = { versionId: message.versionId, appliesAt: message.appliesAt, isReloading: message.isReloading === true };
645
651
  if (message.step === "now") room._isReloadingForVersion = versionUpdate.isReloading;
@@ -2454,6 +2460,51 @@
2454
2460
  }
2455
2461
 
2456
2462
  var rooms = { list: listRooms, quickJoin: quickJoin, linkedRoomCode: roomCodeFromPage(), linkedServerUrl: roomCodeFromPage() ? serverUrlFromPage() : null, isLinkedAsSpectator: watchFromPage() };
2457
- var friends = { list: listFriends, setRoom: setOwnRoom };
2463
+ /*
2464
+ * Friend requests (zaisma.d.ts: Friends.sendRequest): the Zaisma page asks the player before anything is
2465
+ * sent, with the name Zaisma knows the other player by, so no game sends one on a player's behalf. Once one
2466
+ * is sent, from here or the page's Players, the rooms the other player is in tell their game to have their
2467
+ * page look now; Zaisma's record is the request itself.
2468
+ */
2469
+ var friendRequestAsk = null;
2470
+ var FRIEND_REQUEST_STATUSES = ["sent", "cancelled", "already_friends", "not_taking_requests"];
2471
+ function sendFriendRequest(playerId) {
2472
+ if (!onZaismaPage() || !config.rooms) return Promise.reject(notOnZaisma("Friend requests come from Zaisma, so they work only in games played on Zaisma."));
2473
+ if (typeof playerId !== "string" || !/^player_[a-z0-9]{1,40}$/.test(playerId)) return Promise.reject(codedError("bad_player_id", "Zaisma.friends.sendRequest takes a player's id from room.players or Zaisma.friends.list."));
2474
+ return getPass().then(function (mine) {
2475
+ if (mine.kind !== "account") throw codedError("account_needed", "Friend requests are for players signed in to Zaisma: ask the player to sign in first (Zaisma.player.signIn).");
2476
+ // One question at a time: another request while the page is asking is a no.
2477
+ if (friendRequestAsk) return { status: "cancelled" };
2478
+ friendRequestAsk = new Promise(function (resolve) {
2479
+ var isAsking = false;
2480
+ var timer = setTimeout(function () { if (!isAsking) settle("cancelled"); }, 3000);
2481
+ function settle(status) {
2482
+ removeEventListener("message", onMessage);
2483
+ clearTimeout(timer);
2484
+ friendRequestAsk = null;
2485
+ resolve({ status: status });
2486
+ }
2487
+ function onMessage(event) {
2488
+ var told = event.data;
2489
+ if (event.origin !== config.site || event.source !== window.parent || !told || told.type !== "zaisma:friend-request" || told.playerId !== playerId) return;
2490
+ if (told.status === "asking") isAsking = true;
2491
+ else if (FRIEND_REQUEST_STATUSES.indexOf(told.status) !== -1) settle(told.status);
2492
+ }
2493
+ addEventListener("message", onMessage);
2494
+ window.parent.postMessage({ type: "zaisma:friend-request", playerId: playerId }, config.site);
2495
+ });
2496
+ return friendRequestAsk;
2497
+ });
2498
+ }
2499
+ // A request sent (by the game's own button or the page's Players), or one accepted: the rooms the other player is in nudge their game.
2500
+ window.addEventListener("message", function (event) {
2501
+ var told = event.data;
2502
+ if (!config.site || event.origin !== config.site || event.source !== window.parent || !told || told.type !== "zaisma:friend-request" || (told.status !== "sent" && told.status !== "accepted") || typeof told.playerId !== "string") return;
2503
+ joined.forEach(function (room) {
2504
+ if (!room._done && !room.isSpectator && room.players.some(function (each) { return each.id === told.playerId; })) room._say({ type: "friend_request", playerId: told.playerId });
2505
+ });
2506
+ });
2507
+
2508
+ var friends = { list: listFriends, setRoom: setOwnRoom, sendRequest: sendFriendRequest };
2458
2509
  window.Zaisma = { sdkVersionNumber: 1, gameSlug: config.game || null, player: player, joinRoom: joinRoom, rooms: rooms, server: server, invite: invite, checkChat: checkChat, openPlayersPanel: openPlayersPanel, links: links, items: items, friends: friends, saves: saves, versions: versions };
2459
2510
  })();