@zaisma/game-server 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bench/glass.js +191 -52
- package/package.json +1 -1
- package/sdk/saves.js +137 -57
- package/sdk/zaisma.d.ts +7 -1
- package/sdk/zaisma.js +4 -1
- package/src/room.d.ts +16 -2
- package/src/room.js +14 -0
package/bench/glass.js
CHANGED
|
@@ -8146,6 +8146,7 @@ var GAME_SERVER_OPTIONS = [
|
|
|
8146
8146
|
"onRefund",
|
|
8147
8147
|
"onTick",
|
|
8148
8148
|
"onEnding",
|
|
8149
|
+
"onEndingCancelled",
|
|
8149
8150
|
"onRestored",
|
|
8150
8151
|
"stateView",
|
|
8151
8152
|
"onVersionUpdate",
|
|
@@ -23873,24 +23874,63 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23873
23874
|
let applying = null; // a developer's change while the game domain puts it in place under the running game
|
|
23874
23875
|
let isRestoringCopy = false; // the game is going back to an earlier version (Zaisma.saves.restoreCopy)
|
|
23875
23876
|
let versionId = null; // the game version the game domain says it was served from (its last hello or save), a label on each save
|
|
23877
|
+
let clockOffsetMs = 0; // Zaisma's clock minus this device's, from the last answer's Date header
|
|
23878
|
+
let retryTimer = null; // a failed save waiting to be sent again
|
|
23879
|
+
let retryDelayMs = 0; // how long the last wait was: 30 seconds, doubling to 5 minutes
|
|
23880
|
+
let isAwaitingSignIn = false; // saving paused after a 401 or another account signing in, until the player is signed in
|
|
23876
23881
|
// A save with nothing in it, exactly as canonicalSave in packages/core/src/saves.ts writes it.
|
|
23877
23882
|
const EMPTY_SAVE_TEXT = ${JSON.stringify(EMPTY_SAVE_TEXT)};
|
|
23878
23883
|
|
|
23879
23884
|
// A save as one text, the same as canonicalSave on the server: all three keys in this order, localStorage keys sorted.
|
|
23880
23885
|
const canonical = (save) => {
|
|
23886
|
+
if (!save || !save.local_storage_items) return null;
|
|
23881
23887
|
const items = {};
|
|
23882
23888
|
Object.keys(save.local_storage_items).sort().forEach((k) => Object.defineProperty(items, k, { value: save.local_storage_items[k], enumerable: true, writable: true, configurable: true }));
|
|
23883
23889
|
return JSON.stringify({ save_format_version_number: 1, local_storage_items: items, indexeddb_databases: save.indexeddb_databases });
|
|
23884
23890
|
};
|
|
23885
23891
|
const sha = async (text) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
23892
|
+
// Which save of an account this device last had: { version_number, sha256, slug, marked_at_ms }, per account tag.
|
|
23886
23893
|
const marker = (tag, value) => {
|
|
23887
23894
|
try {
|
|
23888
23895
|
if (value === undefined) return JSON.parse(localStorage.getItem("zaisma:save:" + tag) || "null");
|
|
23889
|
-
localStorage.setItem("zaisma:save:" + tag, JSON.stringify(value));
|
|
23896
|
+
localStorage.setItem("zaisma:save:" + tag, JSON.stringify({ ...value, slug: SAVES.slug, marked_at_ms: serverNow() }));
|
|
23890
23897
|
} catch { return null; }
|
|
23891
23898
|
};
|
|
23899
|
+
// Every account's marker for this game in this browser, with its tag (markers from before slug was kept are left out).
|
|
23900
|
+
const markersHere = () => {
|
|
23901
|
+
const found = [];
|
|
23902
|
+
try {
|
|
23903
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
23904
|
+
const key = localStorage.key(i);
|
|
23905
|
+
if (!key || !key.startsWith("zaisma:save:")) continue;
|
|
23906
|
+
const value = JSON.parse(localStorage.getItem(key) || "null");
|
|
23907
|
+
if (value && value.slug === SAVES.slug) found.push({ tag: key.slice("zaisma:save:".length), ...value });
|
|
23908
|
+
}
|
|
23909
|
+
} catch {}
|
|
23910
|
+
return found;
|
|
23911
|
+
};
|
|
23912
|
+
// Another account's progress, moved aside on a shared computer until that account signs in here again: { text, played_at_ms }.
|
|
23913
|
+
const asideKey = (tag) => "zaisma:unsynced:" + tag;
|
|
23914
|
+
const putAside = (tag, value) => {
|
|
23915
|
+
try { localStorage.setItem(asideKey(tag), JSON.stringify(value)); return true; } catch { return false; }
|
|
23916
|
+
};
|
|
23917
|
+
const takeAside = (tag) => {
|
|
23918
|
+
try {
|
|
23919
|
+
const value = JSON.parse(localStorage.getItem(asideKey(tag)) || "null");
|
|
23920
|
+
localStorage.removeItem(asideKey(tag));
|
|
23921
|
+
return value && typeof value.text === "string" ? value : null;
|
|
23922
|
+
} catch { return null; }
|
|
23923
|
+
};
|
|
23924
|
+
// Zaisma's clock: this device's, put right by the offset each answer's Date header shows.
|
|
23925
|
+
const serverNow = () => Date.now() + clockOffsetMs;
|
|
23926
|
+
// Every call to the account: learns Zaisma's clock from the answer.
|
|
23927
|
+
const ask = (path, init = {}) => fetch(path, { credentials: "same-origin", ...init }).then((res) => {
|
|
23928
|
+
const answeredAtMs = Date.parse((res.headers && res.headers.get && res.headers.get("date")) || "");
|
|
23929
|
+
if (answeredAtMs) clockOffsetMs = answeredAtMs - Date.now();
|
|
23930
|
+
return res;
|
|
23931
|
+
});
|
|
23892
23932
|
const ago = (savedAt) => {
|
|
23893
|
-
const m = Math.round((
|
|
23933
|
+
const m = Math.round((serverNow() - Date.parse(savedAt)) / 60000);
|
|
23894
23934
|
return m < 1 ? "just now" : m < 60 ? m + " min ago" : m < 1440 ? Math.round(m / 60) + " h ago" : Math.round(m / 1440) + " days ago";
|
|
23895
23935
|
};
|
|
23896
23936
|
// Where progress goes, in the Keep your progress card.
|
|
@@ -23909,11 +23949,10 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23909
23949
|
}
|
|
23910
23950
|
// The account's progress wins: keep this device's as an earlier version on the account, dated when this device
|
|
23911
23951
|
// last played it (played_at; null when that isn't known). Answers the copy's ID, or null.
|
|
23912
|
-
const keepDevice = (text) => {
|
|
23913
|
-
if (!text || text === EMPTY_SAVE_TEXT) return Promise.resolve(null);
|
|
23914
|
-
const
|
|
23915
|
-
|
|
23916
|
-
return fetch(SAVES.api, { method: "PUT", body: JSON.stringify(body), credentials: "same-origin" })
|
|
23952
|
+
const keepDevice = (text, playedAtMs = (played() || {}).played_at_ms) => {
|
|
23953
|
+
if (!text || text === EMPTY_SAVE_TEXT || !cloud || cloud instanceof Promise) return Promise.resolve(null);
|
|
23954
|
+
const body = { tag: cloud.tag, keep: true, save_json: text, version_id: versionId, played_at: playedAtMs ? new Date(playedAtMs).toISOString() : null };
|
|
23955
|
+
return ask(SAVES.api, { method: "PUT", body: JSON.stringify(body) })
|
|
23917
23956
|
.then((r) => (r.ok ? r.json() : null))
|
|
23918
23957
|
.then((out) => (out && out.save_copy_id) || null)
|
|
23919
23958
|
.catch(() => null);
|
|
@@ -23925,25 +23964,22 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23925
23964
|
localStorage.setItem(playedKey, JSON.stringify(value));
|
|
23926
23965
|
} catch { return null; }
|
|
23927
23966
|
};
|
|
23928
|
-
// The game sent its save: if it changed, this device played just now.
|
|
23967
|
+
// The game sent its save: if it changed, this device played just now (on Zaisma's clock).
|
|
23929
23968
|
async function notePlayed(text) {
|
|
23930
23969
|
const hash = await sha(text);
|
|
23931
23970
|
const was = played();
|
|
23932
23971
|
if (!was) played({ sha256: hash, played_at_ms: null });
|
|
23933
|
-
else if (was.sha256 !== hash) played({ sha256: hash, played_at_ms:
|
|
23972
|
+
else if (was.sha256 !== hash) played({ sha256: hash, played_at_ms: serverNow() });
|
|
23934
23973
|
}
|
|
23935
23974
|
// Whether this device's progress was played after the account's was saved. Not known: the account's wins.
|
|
23936
|
-
const deviceIsNewer = (savedAt) =>
|
|
23937
|
-
const p = played();
|
|
23938
|
-
return Boolean(p && p.played_at_ms && savedAt && p.played_at_ms > Date.parse(savedAt));
|
|
23939
|
-
};
|
|
23975
|
+
const deviceIsNewer = (savedAt, playedAtMs = (played() || {}).played_at_ms) => Boolean(playedAtMs && savedAt && playedAtMs > Date.parse(savedAt));
|
|
23940
23976
|
const tell = (msg) => frame && frame.contentWindow && frame.contentWindow.postMessage({ type: "zaisma:saves", ...msg }, SAVES.frameOrigin);
|
|
23941
23977
|
// To the game itself (the SDK), not the game domain's saves script.
|
|
23942
23978
|
const toGame = (msg) => frame && frame.contentWindow && frame.contentWindow.postMessage(msg, SAVES.frameOrigin);
|
|
23943
23979
|
const copyFields = (copy) => ({ id: copy.id, savedAt: copy.saved_at, copyReason: copy.copy_reason, sizeBytes: copy.size_bytes, isCurrentSave: copy.is_current_save, versionId: copy.version_id ?? null });
|
|
23944
23980
|
async function copyById(saveCopyId) {
|
|
23945
23981
|
if (!saveCopyId) return null;
|
|
23946
|
-
const res = await
|
|
23982
|
+
const res = await ask(SAVES.api + "/copies").catch(() => null);
|
|
23947
23983
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
23948
23984
|
const copy = out && Array.isArray(out.save_copies) ? out.save_copies.find((each) => each.id === saveCopyId) : null;
|
|
23949
23985
|
return copy ? copyFields(copy) : null;
|
|
@@ -23964,7 +24000,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23964
24000
|
// One retry for a passing hiccup; a signed-out player or a game without saves just plays.
|
|
23965
24001
|
async function load(retry = true) {
|
|
23966
24002
|
try {
|
|
23967
|
-
const res = await
|
|
24003
|
+
const res = await ask(SAVES.api);
|
|
23968
24004
|
if (res.ok) return await res.json();
|
|
23969
24005
|
if (res.status === 401 || res.status === 404 || !retry) return null;
|
|
23970
24006
|
} catch { if (!retry) return null; }
|
|
@@ -23984,6 +24020,9 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23984
24020
|
pendingConflict = null; // A conflict from this reload comes after it (conflicted).
|
|
23985
24021
|
if (applying) { clearTimeout(applying.timer); applying = null; }
|
|
23986
24022
|
queued = null; // From the game as it was; the reloaded game sends its own.
|
|
24023
|
+
clearTimeout(retryTimer);
|
|
24024
|
+
retryTimer = null;
|
|
24025
|
+
retryDelayMs = 0;
|
|
23987
24026
|
cloud = load();
|
|
23988
24027
|
openAt("_zaisma/restore#" + encodeURIComponent(SAVES.query));
|
|
23989
24028
|
clearTimeout(fallback);
|
|
@@ -24094,8 +24133,8 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24094
24133
|
const c = await load();
|
|
24095
24134
|
if (!c) return say("Couldn't reach your saved progress, so this visit won't save to your account.");
|
|
24096
24135
|
cloud = c;
|
|
24097
|
-
if (!c.version_number) {
|
|
24098
|
-
// A new account: whatever this device has becomes the account's save.
|
|
24136
|
+
if (!c.version_number || !c.save_data) {
|
|
24137
|
+
// A new account (or one whose save has no data): whatever this device has becomes the account's save.
|
|
24099
24138
|
paused = false;
|
|
24100
24139
|
synced = null;
|
|
24101
24140
|
welcome = true;
|
|
@@ -24108,6 +24147,10 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24108
24147
|
tell({ step: "sync", text: guestText });
|
|
24109
24148
|
say("Progress saves to your account.");
|
|
24110
24149
|
pop("Your progress now saves to your account.");
|
|
24150
|
+
} else if (!guestText || guestText === EMPTY_SAVE_TEXT) {
|
|
24151
|
+
// Nothing on this device: the game reopens with the account's progress.
|
|
24152
|
+
forceCloud = true;
|
|
24153
|
+
openThroughRestore();
|
|
24111
24154
|
} else if (deviceIsNewer(c.saved_at)) {
|
|
24112
24155
|
// Both have progress, and this device's was played more recently: it replaces the account's, which is kept
|
|
24113
24156
|
// as an earlier version (the game hears the conflict once that save is stored).
|
|
@@ -24126,9 +24169,37 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24126
24169
|
pop("You're signed in. Your account's progress in this game is newer, so the game reloaded with it.");
|
|
24127
24170
|
}
|
|
24128
24171
|
}
|
|
24129
|
-
|
|
24130
|
-
|
|
24131
|
-
|
|
24172
|
+
// Saving paused after a 401 or another account signing in: once the account answers again, the game reopens
|
|
24173
|
+
// through the restore page as whoever is signed in now, which checks whose progress is on this device.
|
|
24174
|
+
async function signedInAgain() {
|
|
24175
|
+
if (!isAwaitingSignIn || guest || !signedIn()) return;
|
|
24176
|
+
const res = await ask(SAVES.api + "?version").catch(() => null);
|
|
24177
|
+
if (!isAwaitingSignIn || !res || !res.ok) return;
|
|
24178
|
+
isAwaitingSignIn = false;
|
|
24179
|
+
openThroughRestore();
|
|
24180
|
+
}
|
|
24181
|
+
function awaitSignIn(isOtherAccount) {
|
|
24182
|
+
paused = true;
|
|
24183
|
+
queued = null;
|
|
24184
|
+
tell({ step: "pause" });
|
|
24185
|
+
isAwaitingSignIn = true;
|
|
24186
|
+
if (isOtherAccount) signedInAgain();
|
|
24187
|
+
else say("Sign in again to keep saving to your account.");
|
|
24188
|
+
}
|
|
24189
|
+
const heardSignIn = () => { joined(); signedInAgain(); };
|
|
24190
|
+
addEventListener("focus", heardSignIn);
|
|
24191
|
+
document.addEventListener("visibilitychange", heardSignIn);
|
|
24192
|
+
try { new BroadcastChannel("zaisma").onmessage = (e) => e.data === "signed-in" && heardSignIn(); } catch {}
|
|
24193
|
+
|
|
24194
|
+
// Whose this device's progress is, when it isn't this account's (tag): another account's synced save (isSynced),
|
|
24195
|
+
// or progress never synced, which belongs to the account that played here last. Null: this account's, or no one's.
|
|
24196
|
+
function progressOwner(localHash, tag) {
|
|
24197
|
+
const markers = markersHere();
|
|
24198
|
+
const synced = markers.find((each) => each.tag !== tag && each.sha256 === localHash);
|
|
24199
|
+
if (synced) return { tag: synced.tag, isSynced: true };
|
|
24200
|
+
const last = markers.reduce((newest, each) => (!newest || (each.marked_at_ms || 0) > (newest.marked_at_ms || 0) ? each : newest), null);
|
|
24201
|
+
return last && last.tag !== tag ? { tag: last.tag, isSynced: false } : null;
|
|
24202
|
+
}
|
|
24132
24203
|
|
|
24133
24204
|
async function restore(text, broken) {
|
|
24134
24205
|
clearTimeout(fallback);
|
|
@@ -24143,63 +24214,98 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24143
24214
|
say("Couldn't reach your saved progress, so this visit won't save to your account.");
|
|
24144
24215
|
return tell({ step: "restore", action: "keep" });
|
|
24145
24216
|
}
|
|
24146
|
-
|
|
24147
|
-
const
|
|
24217
|
+
// An answer without its data counts as no save on the account; the next save still starts from its version.
|
|
24218
|
+
const hasCloudSave = Boolean(c.version_number && c.save_data);
|
|
24219
|
+
let localHash = await sha(text);
|
|
24148
24220
|
// The first time this page sees this device's save: from now on a change to it counts as playing.
|
|
24149
24221
|
if (!played()) played({ sha256: localHash, played_at_ms: null });
|
|
24150
24222
|
// A marker from before version_number and sha256 matches neither, so the most recently played wins.
|
|
24151
24223
|
const mark = marker(c.tag);
|
|
24224
|
+
// A shared computer: progress here that's another account's stays that account's, and this account starts from its own.
|
|
24225
|
+
let deviceText = text;
|
|
24226
|
+
let isStorageOthers = false;
|
|
24227
|
+
if (text !== EMPTY_SAVE_TEXT && localHash !== c.sha256 && !(mark && mark.sha256 === localHash)) {
|
|
24228
|
+
const owner = progressOwner(localHash, c.tag);
|
|
24229
|
+
if (owner) {
|
|
24230
|
+
if (!owner.isSynced && !putAside(owner.tag, { text, played_at_ms: (played() || {}).played_at_ms || null })) {
|
|
24231
|
+
say("Another account's progress is on this device, so this visit won't save to your account.");
|
|
24232
|
+
return tell({ step: "restore", action: "keep" });
|
|
24233
|
+
}
|
|
24234
|
+
isStorageOthers = true;
|
|
24235
|
+
deviceText = EMPTY_SAVE_TEXT;
|
|
24236
|
+
}
|
|
24237
|
+
}
|
|
24238
|
+
// This account's own progress, moved aside while another account played here: it's this device's progress again.
|
|
24239
|
+
const aside = takeAside(c.tag);
|
|
24240
|
+
let devicePlayedAtMs = (played() || {}).played_at_ms;
|
|
24241
|
+
if (aside) {
|
|
24242
|
+
deviceText = aside.text;
|
|
24243
|
+
devicePlayedAtMs = aside.played_at_ms;
|
|
24244
|
+
localHash = await sha(aside.text);
|
|
24245
|
+
}
|
|
24246
|
+
const empty = deviceText === EMPTY_SAVE_TEXT;
|
|
24152
24247
|
let use;
|
|
24153
|
-
if (forceCloud &&
|
|
24248
|
+
if (forceCloud && hasCloudSave) {
|
|
24154
24249
|
use = "cloud";
|
|
24155
24250
|
// This device's progress since its last save (the account keeps one copy of each progress).
|
|
24156
24251
|
// Unchanged since it last synced, it was the account's own save, so there's nothing to keep; nor when it was kept before the reload.
|
|
24157
|
-
if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) &&
|
|
24252
|
+
if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) && deviceText !== keptBeforeReload) keepDevice(deviceText, devicePlayedAtMs);
|
|
24158
24253
|
}
|
|
24159
|
-
else if (!
|
|
24254
|
+
else if (!hasCloudSave) use = "local";
|
|
24160
24255
|
else if (empty || localHash === c.sha256) use = "cloud";
|
|
24161
24256
|
else if (mark && mark.version_number === c.version_number) use = "local";
|
|
24162
24257
|
else if (mark && mark.sha256 === localHash) use = "cloud";
|
|
24163
24258
|
else {
|
|
24164
24259
|
// Both played on since they last matched: the progress played most recently wins, the other is kept.
|
|
24165
|
-
use = deviceIsNewer(c.saved_at) ? "local" : "cloud";
|
|
24166
|
-
if (use === "cloud") conflicted("account", keepDevice(
|
|
24260
|
+
use = deviceIsNewer(c.saved_at, devicePlayedAtMs) ? "local" : "cloud";
|
|
24261
|
+
if (use === "cloud") conflicted("account", keepDevice(deviceText, devicePlayedAtMs));
|
|
24167
24262
|
else replacing = true; // The game hears the conflict once its save replaced the account's (store).
|
|
24168
24263
|
}
|
|
24169
24264
|
forceCloud = false;
|
|
24170
24265
|
keptBeforeReload = null;
|
|
24171
|
-
|
|
24172
|
-
|
|
24173
|
-
|
|
24266
|
+
// What goes in the game's storage: the account's save, this account's moved-aside progress, or nothing in place of another's.
|
|
24267
|
+
const putting = use === "cloud" && hasCloudSave ? { saveData: c.save_data } : aside && !empty ? { saveData: JSON.parse(deviceText), isDeviceProgress: true, aside } : isStorageOthers ? { saveData: JSON.parse(EMPTY_SAVE_TEXT) } : null;
|
|
24268
|
+
if (putting) {
|
|
24269
|
+
restoring = { c, ...putting };
|
|
24270
|
+
tell({ step: "restore", action: "use-cloud", data: putting.saveData });
|
|
24174
24271
|
} else {
|
|
24175
24272
|
paused = false;
|
|
24176
24273
|
// Keep this device's save; the game sends it as soon as it starts.
|
|
24177
|
-
synced = empty && !
|
|
24274
|
+
synced = empty && !hasCloudSave ? text : null;
|
|
24178
24275
|
tell({ step: "restore", action: "keep" });
|
|
24179
24276
|
say("Progress saves to your account.");
|
|
24180
24277
|
}
|
|
24181
24278
|
}
|
|
24182
24279
|
|
|
24183
|
-
// The game domain put
|
|
24280
|
+
// The game domain put a save in place (or couldn't). Only then does saving start.
|
|
24184
24281
|
function restored(ok) {
|
|
24185
|
-
const
|
|
24282
|
+
const r = restoring;
|
|
24186
24283
|
restoring = null;
|
|
24284
|
+
if (!r) return;
|
|
24187
24285
|
if (!ok) {
|
|
24286
|
+
if (r.aside) putAside(r.c.tag, r.aside);
|
|
24188
24287
|
say("Couldn't put your account's progress on this device, so this visit won't save to your account.");
|
|
24189
24288
|
return;
|
|
24190
24289
|
}
|
|
24191
|
-
|
|
24192
|
-
|
|
24193
|
-
|
|
24290
|
+
const c = r.c;
|
|
24291
|
+
const isAccountSave = r.saveData === c.save_data;
|
|
24292
|
+
// This account's own moved-aside progress back in place: the game sends it, and it saves from the account's version.
|
|
24293
|
+
synced = r.isDeviceProgress ? null : canonical(r.saveData);
|
|
24194
24294
|
paused = false;
|
|
24195
|
-
|
|
24295
|
+
if (isAccountSave) {
|
|
24296
|
+
marker(c.tag, { version_number: c.version_number, sha256: c.sha256 });
|
|
24297
|
+
played({ sha256: c.sha256, played_at_ms: Date.parse(c.saved_at) || null });
|
|
24298
|
+
} else {
|
|
24299
|
+
sha(canonical(r.saveData)).then((hash) => played({ sha256: hash, played_at_ms: r.isDeviceProgress ? r.aside.played_at_ms || null : null }));
|
|
24300
|
+
}
|
|
24301
|
+
say(isAccountSave ? "Progress saves to your account. Last saved " + ago(c.saved_at) + "." : "Progress saves to your account.");
|
|
24196
24302
|
}
|
|
24197
24303
|
|
|
24198
24304
|
async function store(text) {
|
|
24199
24305
|
if (paused) return;
|
|
24200
|
-
if (saving) { queued = text; return; }
|
|
24201
|
-
const body = JSON.stringify({ base_version_number: cloud.version_number, save_json: text, version_id: versionId, ...(replacing ? { replace: true } : {}) });
|
|
24202
|
-
saving =
|
|
24306
|
+
if (saving || retryTimer) { queued = text; return; }
|
|
24307
|
+
const body = JSON.stringify({ tag: cloud.tag, base_version_number: cloud.version_number, save_json: text, version_id: versionId, ...(replacing ? { replace: true } : {}) });
|
|
24308
|
+
saving = ask(SAVES.api, { method: "PUT", body, keepalive: body.length < 60000 }).catch(() => null);
|
|
24203
24309
|
const opened = openCount;
|
|
24204
24310
|
const res = await saving;
|
|
24205
24311
|
saving = null;
|
|
@@ -24208,9 +24314,13 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24208
24314
|
// Going back to an earlier version: a save that went through still moved the account on; anything else waits for that.
|
|
24209
24315
|
if (isRestoringCopy && !(res && res.ok)) return;
|
|
24210
24316
|
const out = res ? await res.json().catch(() => ({})) : {};
|
|
24211
|
-
|
|
24212
|
-
|
|
24317
|
+
// A 409 for exactly this save: it landed before (its answer was lost), so it's saved.
|
|
24318
|
+
const isSavedBefore = res && res.status === 409 && out.error !== "account_changed" && out.sha256 && out.sha256 === (await sha(text));
|
|
24319
|
+
if (opened !== openCount) return;
|
|
24320
|
+
if (res && (res.ok || isSavedBefore)) {
|
|
24321
|
+
if (replacing && res.ok) conflicted("this_device", Promise.resolve(out.kept_save_copy_id));
|
|
24213
24322
|
replacing = false;
|
|
24323
|
+
retryDelayMs = 0;
|
|
24214
24324
|
cloud.version_number = out.version_number;
|
|
24215
24325
|
cloud.sha256 = out.sha256;
|
|
24216
24326
|
synced = text;
|
|
@@ -24218,17 +24328,23 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24218
24328
|
say("Progress saves to your account. Last saved just now.");
|
|
24219
24329
|
if (welcome) pop("Your progress now saves to your account.");
|
|
24220
24330
|
welcome = false;
|
|
24331
|
+
} else if (res && res.status === 409 && out.error === "account_changed") {
|
|
24332
|
+
// Another account signed in on this browser: this page's progress never goes to it.
|
|
24333
|
+
return awaitSignIn(true);
|
|
24221
24334
|
} else if (res && res.status === 409) {
|
|
24222
24335
|
paused = true;
|
|
24223
24336
|
tell({ step: "pause" });
|
|
24337
|
+
// The newest progress this device has; a save waiting behind this one is from before whatever happens next.
|
|
24338
|
+
const newest = queued ?? text;
|
|
24339
|
+
queued = null;
|
|
24224
24340
|
// The developer's change always wins, and so does another device's progress played after this device's:
|
|
24225
24341
|
// reload the game with it, keeping this device's progress. Otherwise this device's, played just now, replaces it.
|
|
24226
24342
|
// A developer's change is never a conflict, and a game that listens for it takes it in place.
|
|
24227
24343
|
if (out.changed_by_kind === "developer" && listening.has("developerChange")) {
|
|
24228
|
-
takeDeveloperChange(
|
|
24344
|
+
takeDeveloperChange(newest);
|
|
24229
24345
|
} else if (out.changed_by_kind === "developer" || !deviceIsNewer(out.saved_at)) {
|
|
24230
|
-
const keeping = keepDevice(
|
|
24231
|
-
keptBeforeReload =
|
|
24346
|
+
const keeping = keepDevice(newest);
|
|
24347
|
+
keptBeforeReload = newest;
|
|
24232
24348
|
forceCloud = true;
|
|
24233
24349
|
openThroughRestore();
|
|
24234
24350
|
if (out.changed_by_kind !== "developer") conflicted("account", keeping);
|
|
@@ -24243,14 +24359,27 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24243
24359
|
overLimit();
|
|
24244
24360
|
return;
|
|
24245
24361
|
} else if (res && res.status === 401) {
|
|
24362
|
+
return awaitSignIn(false);
|
|
24363
|
+
} else if (res && res.status === 404) {
|
|
24364
|
+
// The game stopped saving to accounts (or isn't live): nothing more goes for this visit.
|
|
24246
24365
|
paused = true;
|
|
24366
|
+
queued = null;
|
|
24247
24367
|
tell({ step: "pause" });
|
|
24248
|
-
say("
|
|
24368
|
+
say("This game doesn't save to your account right now, so its progress stays on this device.");
|
|
24249
24369
|
return;
|
|
24250
24370
|
} else {
|
|
24251
|
-
//
|
|
24371
|
+
// Offline, too often or Zaisma had a problem: send the newest save again later, waiting longer each time.
|
|
24372
|
+
queued = queued ?? text;
|
|
24373
|
+
retryDelayMs = Math.min(retryDelayMs ? retryDelayMs * 2 : 30000, 300000);
|
|
24374
|
+
const retryAfterMs = res && res.status === 429 ? Number((res.headers && res.headers.get && res.headers.get("retry-after")) || 0) * 1000 : 0;
|
|
24252
24375
|
say("Couldn't save to your account. Trying again soon.");
|
|
24253
|
-
|
|
24376
|
+
retryTimer = setTimeout(() => {
|
|
24377
|
+
retryTimer = null;
|
|
24378
|
+
const next = queued;
|
|
24379
|
+
queued = null;
|
|
24380
|
+
if (next !== null && opened === openCount) store(next);
|
|
24381
|
+
}, Math.max(retryDelayMs, retryAfterMs || 0));
|
|
24382
|
+
return;
|
|
24254
24383
|
}
|
|
24255
24384
|
if (queued !== null) { const next = queued; queued = null; store(next); }
|
|
24256
24385
|
}
|
|
@@ -24260,7 +24389,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24260
24389
|
// asked for, so this costs one small read.
|
|
24261
24390
|
document.addEventListener("visibilitychange", async () => {
|
|
24262
24391
|
if (guest || paused || saving || document.visibilityState !== "visible" || !cloud || cloud instanceof Promise) return;
|
|
24263
|
-
const res = await
|
|
24392
|
+
const res = await ask(SAVES.api + "?version").catch(() => null);
|
|
24264
24393
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
24265
24394
|
if (!out || out.version_number === cloud.version_number || paused || saving) return;
|
|
24266
24395
|
if (out.changed_by_kind !== "developer" && deviceIsNewer(out.saved_at)) return;
|
|
@@ -24277,6 +24406,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24277
24406
|
// text is this device's progress since its last save, kept as an earlier version (null: nothing to keep).
|
|
24278
24407
|
async function takeDeveloperChange(text) {
|
|
24279
24408
|
const opened = openCount;
|
|
24409
|
+
queued = null;
|
|
24280
24410
|
const keeping = keepDevice(text);
|
|
24281
24411
|
const reload = () => {
|
|
24282
24412
|
keptBeforeReload = text;
|
|
@@ -24293,6 +24423,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24293
24423
|
const taking = applying;
|
|
24294
24424
|
if (!taking) return;
|
|
24295
24425
|
applying = null;
|
|
24426
|
+
queued = null; // From the game before the change: the game sends its own after it.
|
|
24296
24427
|
clearTimeout(taking.timer);
|
|
24297
24428
|
if (!ok) return taking.reload();
|
|
24298
24429
|
const c = taking.c;
|
|
@@ -24330,7 +24461,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24330
24461
|
reply({ status: "asking" });
|
|
24331
24462
|
if (guest || !signedIn()) return fail("account_needed", "Earlier versions are kept for players signed in to Zaisma: a guest's progress stays in this browser. Ask the player to sign in first (Zaisma.player.signIn).");
|
|
24332
24463
|
if (m.request === "list_copies") {
|
|
24333
|
-
const res = await
|
|
24464
|
+
const res = await ask(SAVES.api + "/copies").catch(() => null);
|
|
24334
24465
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
24335
24466
|
if (!out || !Array.isArray(out.save_copies)) return failed();
|
|
24336
24467
|
return reply({ answer: out.save_copies.map(copyFields) });
|
|
@@ -24339,7 +24470,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24339
24470
|
const copyPath = SAVES.api + "/copies/" + m.saveCopyId;
|
|
24340
24471
|
const noCopy = () => fail("no_save_copy", "The player has no earlier version " + m.saveCopyId + " in this game: Zaisma.saves.listCopies() lists the ones kept.");
|
|
24341
24472
|
if (m.request === "get_copy") {
|
|
24342
|
-
const res = await
|
|
24473
|
+
const res = await ask(copyPath).catch(() => null);
|
|
24343
24474
|
if (res && res.status === 404) return noCopy();
|
|
24344
24475
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
24345
24476
|
if (!out || !out.save_copy) return failed();
|
|
@@ -24351,7 +24482,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24351
24482
|
isRestoringCopy = true;
|
|
24352
24483
|
queued = null;
|
|
24353
24484
|
tell({ step: "pause" });
|
|
24354
|
-
const res = await
|
|
24485
|
+
const res = await ask(copyPath + "/restore", { method: "POST", body: JSON.stringify({ tag: cloud && !(cloud instanceof Promise) ? cloud.tag : null }) }).catch(() => null);
|
|
24355
24486
|
isRestoringCopy = false;
|
|
24356
24487
|
if (res && res.ok) {
|
|
24357
24488
|
reply({ answer: null });
|
|
@@ -24360,6 +24491,11 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24360
24491
|
openThroughRestore();
|
|
24361
24492
|
return;
|
|
24362
24493
|
}
|
|
24494
|
+
// Another account signed in on this browser: the game reopens as that account.
|
|
24495
|
+
if (res && res.status === 409) {
|
|
24496
|
+
awaitSignIn(true);
|
|
24497
|
+
return failed();
|
|
24498
|
+
}
|
|
24363
24499
|
paused = wasPaused;
|
|
24364
24500
|
if (!wasPaused) tell({ step: "sync", text: synced });
|
|
24365
24501
|
if (res && res.status === 404) return noCopy();
|
|
@@ -43523,6 +43659,9 @@ function isLocalPreview(env2) {
|
|
|
43523
43659
|
return siteHost === "localhost" || siteHost === "127.0.0.1" || siteHost.endsWith(".ts.net");
|
|
43524
43660
|
}
|
|
43525
43661
|
|
|
43662
|
+
// ../../apps/site/src/credit-api.ts
|
|
43663
|
+
var PENDING_REFUND_FIRST_RETRY_MS = 5 * 6e4;
|
|
43664
|
+
|
|
43526
43665
|
// ../../apps/site/src/stripe-account-api.ts
|
|
43527
43666
|
var CONNECT_STATE_DURATION_MS = 10 * 6e4;
|
|
43528
43667
|
|
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
|
@@ -1005,7 +1005,8 @@ declare namespace Zaisma {
|
|
|
1005
1005
|
readonly canChat: boolean;
|
|
1006
1006
|
/**
|
|
1007
1007
|
* 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)
|
|
1008
|
+
* month, an hour after the pause (the "ending" event); null again if Zaisma calls the end off (the
|
|
1009
|
+
* "endingCancelled" event). Never set in a room on your own server.
|
|
1009
1010
|
*/
|
|
1010
1011
|
readonly endsAt: string | null;
|
|
1011
1012
|
/**
|
|
@@ -1222,6 +1223,11 @@ declare namespace Zaisma {
|
|
|
1222
1223
|
* "ended" follows then.
|
|
1223
1224
|
*/
|
|
1224
1225
|
ending: (ending: Ending) => void;
|
|
1226
|
+
/**
|
|
1227
|
+
* Zaisma called off the end the "ending" event announced, because the game's hosting started again:
|
|
1228
|
+
* room.endsAt is null, and the match goes on. Optional, as events added after v1 are, so v1's types still fit.
|
|
1229
|
+
*/
|
|
1230
|
+
endingCancelled?: () => void;
|
|
1225
1231
|
/** The game server or the host removed the player from the room. The connection closes. */
|
|
1226
1232
|
kicked: (kicked: Kicked) => void;
|
|
1227
1233
|
/** 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;
|
|
@@ -640,6 +640,9 @@
|
|
|
640
640
|
} else if (message.type === "ending") {
|
|
641
641
|
room.endsAt = message.endsAt;
|
|
642
642
|
room._emit("ending", { endsAt: message.endsAt });
|
|
643
|
+
} else if (message.type === "ending_cancelled") {
|
|
644
|
+
room.endsAt = null;
|
|
645
|
+
room._emit("endingCancelled");
|
|
643
646
|
} else if (message.type === "version_update") {
|
|
644
647
|
var versionUpdate = { versionId: message.versionId, appliesAt: message.appliesAt, isReloading: message.isReloading === true };
|
|
645
648
|
if (message.step === "now") room._isReloadingForVersion = versionUpdate.isReloading;
|
package/src/room.d.ts
CHANGED
|
@@ -98,7 +98,8 @@ export interface Room {
|
|
|
98
98
|
/**
|
|
99
99
|
* When Zaisma will end this match, an ISO time, or null. Set once the game's hosting on Zaisma rooms
|
|
100
100
|
* pauses for the month (its free allowance, or its developer's budget or credit, is used), an hour after
|
|
101
|
-
* the pause, so your game can save and tell its players (onEnding runs then);
|
|
101
|
+
* the pause, so your game can save and tell its players (onEnding runs then); back to null when Zaisma calls the
|
|
102
|
+
* end off because the game's hosting started again (onEndingCancelled runs then). Never set on your own server.
|
|
102
103
|
*/
|
|
103
104
|
readonly endsAt: string | null;
|
|
104
105
|
/** Shown in room lists (the game page and Zaisma.rooms.list). A hidden room still works by code and link. */
|
|
@@ -541,6 +542,12 @@ export interface GameServer<S = any> {
|
|
|
541
542
|
* the "ending" event. Never runs on your own server.
|
|
542
543
|
*/
|
|
543
544
|
onEnding?(state: S, room: Room): HandlerResult<S>;
|
|
545
|
+
/**
|
|
546
|
+
* Zaisma called off the end onEnding announced, because the game's hosting started again (credit came in, its
|
|
547
|
+
* budget was raised or the month reset): room.endsAt is null again, the match goes on and takes new players.
|
|
548
|
+
* Runs once; each player's game also gets the "endingCancelled" event. Never runs on your own server.
|
|
549
|
+
*/
|
|
550
|
+
onEndingCancelled?(state: S, room: Room): HandlerResult<S>;
|
|
544
551
|
/**
|
|
545
552
|
* The room restarted (Zaisma restarts its rooms on every update, and your own server may restart) and
|
|
546
553
|
* came back with the state it last saved: restoreInfo.savedAt says when, and restoreInfo.lostDurationSec
|
|
@@ -634,7 +641,7 @@ export declare function readJoinAnswer(answer: unknown): {
|
|
|
634
641
|
mistake: GameServerError;
|
|
635
642
|
};
|
|
636
643
|
/** Everything a game server object may have, in the order the docs give them. Anything else is refused. */
|
|
637
|
-
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "adultChatFilterEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onChat", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
644
|
+
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "adultChatFilterEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onChat", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onEndingCancelled", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
638
645
|
export declare const ROOM_LIMITS: {
|
|
639
646
|
/** Players in one room: what one room carries at 60 ticks a second (test/load/room-load.test.ts). */
|
|
640
647
|
readonly maximumPlayerCount: 100;
|
|
@@ -843,6 +850,8 @@ export type ServerMessage = {
|
|
|
843
850
|
} | {
|
|
844
851
|
type: "ending";
|
|
845
852
|
endsAt: string;
|
|
853
|
+
} | {
|
|
854
|
+
type: "ending_cancelled";
|
|
846
855
|
} | {
|
|
847
856
|
type: "version_update";
|
|
848
857
|
step: "coming" | "now";
|
|
@@ -1664,6 +1673,11 @@ export declare class RoomCore<S = any> {
|
|
|
1664
1673
|
* Only Zaisma rooms call it, when the game's hosting pauses.
|
|
1665
1674
|
*/
|
|
1666
1675
|
startEnding(endsAt: string): void;
|
|
1676
|
+
/**
|
|
1677
|
+
* Zaisma calls off the end startEnding announced, since the game's hosting started again: endsAt is null, every
|
|
1678
|
+
* player hears it and onEndingCancelled runs, once. Only Zaisma rooms call it.
|
|
1679
|
+
*/
|
|
1680
|
+
cancelEnding(): void;
|
|
1667
1681
|
/** End the match: everyone hears message and why (endReason), and their connections close. */
|
|
1668
1682
|
end(message: string, endReason: EndReason): void;
|
|
1669
1683
|
/** The players as every player's game lists them, with why each isn't playing. */
|
package/src/room.js
CHANGED
|
@@ -94,6 +94,7 @@ export const GAME_SERVER_OPTIONS = [
|
|
|
94
94
|
"onRefund",
|
|
95
95
|
"onTick",
|
|
96
96
|
"onEnding",
|
|
97
|
+
"onEndingCancelled",
|
|
97
98
|
"onRestored",
|
|
98
99
|
"stateView",
|
|
99
100
|
"onVersionUpdate",
|
|
@@ -2210,6 +2211,19 @@ export class RoomCore {
|
|
|
2210
2211
|
if (this.server.onEnding)
|
|
2211
2212
|
this.run(() => this.server.onEnding(this.state, this.api), "onEnding");
|
|
2212
2213
|
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Zaisma calls off the end startEnding announced, since the game's hosting started again: endsAt is null, every
|
|
2216
|
+
* player hears it and onEndingCancelled runs, once. Only Zaisma rooms call it.
|
|
2217
|
+
*/
|
|
2218
|
+
cancelEnding() {
|
|
2219
|
+
if (this.ended || this.endsAt === null)
|
|
2220
|
+
return;
|
|
2221
|
+
this.endsAt = null;
|
|
2222
|
+
this.dirty = true;
|
|
2223
|
+
this.sendEach(this.everyone(), JSON.stringify({ type: "ending_cancelled" }));
|
|
2224
|
+
if (this.server.onEndingCancelled)
|
|
2225
|
+
this.run(() => this.server.onEndingCancelled(this.state, this.api), "onEndingCancelled");
|
|
2226
|
+
}
|
|
2213
2227
|
/** End the match: everyone hears message and why (endReason), and their connections close. */
|
|
2214
2228
|
end(message, endReason) {
|
|
2215
2229
|
if (this.ended)
|