@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/bench/glass.js +594 -65
- package/bin/local-play.js +65 -0
- package/package.json +1 -1
- package/sdk/saves.js +137 -57
- package/sdk/zaisma.d.ts +34 -1
- package/sdk/zaisma.js +53 -2
- package/src/room.d.ts +27 -2
- package/src/room.js +30 -0
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
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
|
|
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
|
|
323
|
-
* records at a time, stopping once the save passes
|
|
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,
|
|
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
|
|
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
|
-
|
|
371
|
+
var keep = function () {
|
|
359
372
|
keptNow[name] = before;
|
|
360
|
-
|
|
361
|
-
return
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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,
|
|
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: {},
|
|
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.
|
|
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
|
|
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.
|
|
489
|
+
out.byteCount += byteLength(JSON.stringify(encoded)) + 1;
|
|
430
490
|
});
|
|
431
|
-
|
|
432
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
515
|
+
out.byteCount = 0;
|
|
454
516
|
stores.forEach(function (storeName) {
|
|
455
|
-
out.
|
|
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.
|
|
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
|
|
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.
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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: [] },
|
|
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
|
-
/**
|
|
791
|
-
|
|
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 ||
|
|
798
|
-
|
|
799
|
-
var
|
|
800
|
-
var
|
|
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)
|
|
808
|
-
|
|
809
|
-
|
|
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 ||
|
|
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 (
|
|
865
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
})();
|