@zaisma/game-server 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bench/glass.js +276 -60
- package/bin/node-parts.js +2 -1
- 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/database.d.ts +21 -8
- package/src/database.js +57 -15
- package/src/dump.d.ts +4 -1
- package/src/dump.js +166 -18
- package/src/room.d.ts +16 -2
- package/src/room.js +14 -0
package/bench/glass.js
CHANGED
|
@@ -6255,6 +6255,7 @@ var ERROR_CODES = {
|
|
|
6255
6255
|
bad_values: { httpStatus: 400, meaning: "values isn't a list of text, numbers, null and { blob_base64 } for the SQL's ?s." },
|
|
6256
6256
|
bad_player_id: { httpStatus: 400, meaning: "player_id isn't a player ID (player_\u2026)." },
|
|
6257
6257
|
too_many_rows: { httpStatus: 400, meaning: "A statement answered more than 10,000 rows." },
|
|
6258
|
+
integer_too_large: { httpStatus: 400, meaning: "A statement answered a whole number past 2^53, which JavaScript can't hold exactly." },
|
|
6258
6259
|
answer_too_big: { httpStatus: 400, meaning: "The rows answered come to more than 8 MB." },
|
|
6259
6260
|
database_full: { httpStatus: 403, meaning: "The statement would grow the game's storage (its database and kept rooms) past the 10 GB a game can keep." },
|
|
6260
6261
|
database_unavailable: { httpStatus: 503, meaning: "The game's database didn't answer." },
|
|
@@ -7634,6 +7635,30 @@ var MAXIMUM_DATABASE_SIZE_BYTES = 10 * 1024 ** 3;
|
|
|
7634
7635
|
|
|
7635
7636
|
// ../core/src/hosting.ts
|
|
7636
7637
|
var DAY_MS = 24 * 36e5;
|
|
7638
|
+
var USAGE_FIELDS = {
|
|
7639
|
+
room_play_duration_sec: "roomPlayDurationSec",
|
|
7640
|
+
room_awake_duration_sec: "roomAwakeDurationSec",
|
|
7641
|
+
message_count: "inboundMessageCount",
|
|
7642
|
+
room_connection_count: "roomConnectionCount",
|
|
7643
|
+
object_request_count: "objectRequestCount",
|
|
7644
|
+
bookkeeping_written_row_count: "bookkeepingWrittenRowCount",
|
|
7645
|
+
relayed_size_bytes: "relayedSizeBytes",
|
|
7646
|
+
game_server_cpu_duration_ms: "roomCpuDurationMs",
|
|
7647
|
+
outside_room_cpu_duration_ms: "outsideRoomCpuDurationMs",
|
|
7648
|
+
game_server_startup_cpu_duration_ms: "startupCpuDurationMs",
|
|
7649
|
+
outside_call_count: "outsideCallCount",
|
|
7650
|
+
game_server_session_count: "gameServerSessionCount",
|
|
7651
|
+
log_file_count: "logFileCount",
|
|
7652
|
+
game_server_request_count: "requestCount",
|
|
7653
|
+
game_server_scheduled_run_count: "scheduledRunCount",
|
|
7654
|
+
object_awake_duration_sec: "objectAwakeDurationSec",
|
|
7655
|
+
database_written_row_count: "writtenRowCount",
|
|
7656
|
+
database_read_row_count: "readRowCount",
|
|
7657
|
+
database_call_count: "databaseCallCount",
|
|
7658
|
+
database_awake_duration_sec: "databaseAwakeDurationSec",
|
|
7659
|
+
adult_chat_check_count: "adultChatCheckCount"
|
|
7660
|
+
};
|
|
7661
|
+
var USAGE_COLUMNS = Object.keys(USAGE_FIELDS);
|
|
7637
7662
|
|
|
7638
7663
|
// ../core/src/forum.ts
|
|
7639
7664
|
var FORUM_SECTIONS = [
|
|
@@ -7714,12 +7739,16 @@ var OUTSIDE_CALL_LIMITS = {
|
|
|
7714
7739
|
|
|
7715
7740
|
// src/database.ts
|
|
7716
7741
|
var DATABASE_LIMITS = {
|
|
7717
|
-
/** One statement's SQL. */
|
|
7742
|
+
/** One statement's SQL, in characters. */
|
|
7718
7743
|
maximumSqlCharacterCount: 1e5,
|
|
7744
|
+
/** One statement's SQL, in UTF-8 bytes: Cloudflare's SQLite takes 100 KB, so text in other scripts is held to it too. */
|
|
7745
|
+
maximumSqlSizeBytes: 1e5,
|
|
7719
7746
|
/** Values for one statement's ?s. */
|
|
7720
7747
|
maximumValueCount: 100,
|
|
7721
|
-
/** One text or blob value
|
|
7748
|
+
/** One text or blob value a game server or query binds. */
|
|
7722
7749
|
maximumValueSizeBytes: 1024 * 1024,
|
|
7750
|
+
/** One text or blob value in an import, which brings back every row an export wrote: what the database holds in a value. */
|
|
7751
|
+
maximumImportValueSizeBytes: 2 * 1024 * 1024,
|
|
7723
7752
|
/** Statements in one batch. */
|
|
7724
7753
|
maximumStatementCount: 100,
|
|
7725
7754
|
/** Rows one statement may answer; past it, page with LIMIT and OFFSET or a WHERE. */
|
|
@@ -8117,6 +8146,7 @@ var GAME_SERVER_OPTIONS = [
|
|
|
8117
8146
|
"onRefund",
|
|
8118
8147
|
"onTick",
|
|
8119
8148
|
"onEnding",
|
|
8149
|
+
"onEndingCancelled",
|
|
8120
8150
|
"onRestored",
|
|
8121
8151
|
"stateView",
|
|
8122
8152
|
"onVersionUpdate",
|
|
@@ -10499,7 +10529,7 @@ var LIGHT_GLASS = `color-scheme: light;
|
|
|
10499
10529
|
--divider: 1px solid var(--line);
|
|
10500
10530
|
--toggle-off: rgba(60, 72, 94, 0.2); --check-press: rgba(60, 72, 94, 0.16); --row-hover: rgba(255, 255, 255, 0.24); --row-press: rgba(20, 23, 28, 0.05); --tag-new: rgba(34, 160, 90, 0.26); --tag-new-ink: #0A5A2C; --tag-warning: rgba(217, 165, 28, 0.3); --tag-warning-ink: var(--warning); --tag-plain: rgba(60, 72, 94, 0.1); --tag-carve: inset 0 1px 2px rgba(20, 30, 50, 0.22), inset 0 -1px 0 rgba(255, 255, 255, 0.75); --face-ring: rgba(255, 255, 255, 0.85); --presence-ring: #fff; --online: #3BB273; --picture-shadow: rgba(20, 23, 28, 0.3);
|
|
10501
10531
|
/* Data: a table's header row over the rows scrolling under it, a chart's fill under its line, a meter's fill and its amber past 80%. */
|
|
10502
|
-
--table-head: rgba(250, 252, 255, 0.9); --table-row-head: rgba(244, 246, 250, 0.97); --chart-fill: rgba(20, 23, 28, 0.07); --chart-bar: rgba(60, 72, 94, 0.26); --chart-ring: rgba(250, 252, 255, 0.9); --chart-tip: rgba(250, 252, 255, 0.94); --meter-fill: rgba(60, 72, 94, 0.42); --meter-near: #D9A51C; --meter-full: #A87C0E; --meter-full-ink: var(--warning);
|
|
10532
|
+
--table-head: rgba(250, 252, 255, 0.9); --table-row-head: rgba(244, 246, 250, 0.97); --chart-fill: rgba(20, 23, 28, 0.07); --chart-bar: rgba(60, 72, 94, 0.26); --chart-ring: rgba(250, 252, 255, 0.9); --chart-tip: rgba(250, 252, 255, 0.94); --meter-fill: rgba(60, 72, 94, 0.42); --meter-near: #D9A51C; --meter-full: #A87C0E; --meter-full-ink: var(--warning); --part-0: #3E6FD8; --part-1: #7B5BD6; --part-2: #1E9C87; --part-3: #5E8C2E; --part-4: #D2577F; --part-5: #2A8FB8;
|
|
10503
10533
|
/* A file drop's dashed outline, waiting for a file. */
|
|
10504
10534
|
--drop-dash: rgba(20, 23, 28, 0.3);
|
|
10505
10535
|
--rail-line: rgba(20, 23, 28, 0.2); --rail-thumb: rgba(24, 28, 36, 0.55); --rail-edge: rgba(255, 255, 255, 0.55); --rail-mark: rgba(20, 23, 28, 0.45);
|
|
@@ -10535,7 +10565,7 @@ var SMOKED_GLASS = `color-scheme: dark;
|
|
|
10535
10565
|
--glass-btn-lift: inset 0 -12px 18px -12px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.22); --glass-btn-hair: rgba(26, 44, 80, 0.16); --glass-btn-hair-rest: rgba(26, 44, 80, 0.28);
|
|
10536
10566
|
--clear-hover: rgba(255, 255, 255, 0.08); --clear-press: rgba(255, 255, 255, 0.04); --clear-glass-mark: #fff; --on-light: none; --on-smoked: inline; --divider: 1px solid var(--line);
|
|
10537
10567
|
--toggle-off: rgba(0, 0, 0, 0.34); --check-press: rgba(0, 0, 0, 0.3); --row-hover: rgba(255, 255, 255, 0.1); --row-press: rgba(255, 255, 255, 0.22); --tag-new: rgba(30, 150, 80, 0.32); --tag-new-ink: #A6F0C6; --tag-warning: rgba(230, 181, 58, 0.24); --tag-warning-ink: var(--warning); --tag-plain: rgba(0, 0, 0, 0.34); --tag-carve: inset 0 1.5px 3px rgba(0, 0, 0, 0.8), inset 0 -1px 0 rgba(255, 255, 255, 0.24); --face-ring: rgba(6, 8, 12, 0.55); --presence-ring: #1A1D24; --online: #3BB273; --picture-shadow: rgba(0, 0, 0, 0.55);
|
|
10538
|
-
--table-head: rgba(14, 17, 24, 0.9); --table-row-head: rgba(24, 28, 36, 0.97); --chart-fill: rgba(255, 255, 255, 0.08); --chart-bar: rgba(255, 255, 255, 0.3); --chart-ring: rgba(20, 23, 30, 0.9); --chart-tip: rgba(28, 32, 40, 0.94); --meter-fill: rgba(255, 255, 255, 0.6); --meter-near: #E6B53A; --meter-full: #A9811C; --meter-full-ink: var(--warning); --drop-dash: rgba(255, 255, 255, 0.34);
|
|
10568
|
+
--table-head: rgba(14, 17, 24, 0.9); --table-row-head: rgba(24, 28, 36, 0.97); --chart-fill: rgba(255, 255, 255, 0.08); --chart-bar: rgba(255, 255, 255, 0.3); --chart-ring: rgba(20, 23, 30, 0.9); --chart-tip: rgba(28, 32, 40, 0.94); --meter-fill: rgba(255, 255, 255, 0.6); --meter-near: #E6B53A; --meter-full: #A9811C; --meter-full-ink: var(--warning); --part-0: #6E97F0; --part-1: #A08AF0; --part-2: #3CC4AC; --part-3: #8DBA55; --part-4: #EC7FA3; --part-5: #5BC0E3; --drop-dash: rgba(255, 255, 255, 0.34);
|
|
10539
10569
|
--rail-line: rgba(255, 255, 255, 0.2); --rail-thumb: rgba(255, 255, 255, 0.55); --rail-edge: rgba(255, 255, 255, 0.35); --rail-mark: rgba(255, 255, 255, 0.5);
|
|
10540
10570
|
/* A light rim on dark glass stands out far more than on light glass, so smoked glass draws it fainter than light glass's. It has no caustics:
|
|
10541
10571
|
on light glass they're light on light and only round the edge off, but on dark glass they read as a glow fanning in from the rim, which
|
|
@@ -15947,17 +15977,24 @@ function meterState(options) {
|
|
|
15947
15977
|
}
|
|
15948
15978
|
function usageTrack(options) {
|
|
15949
15979
|
const { sharePct, tone } = meterState(options);
|
|
15950
|
-
return html`<span class="usage${tone}">${meterTrack(options, sharePct)}</span>`;
|
|
15980
|
+
return html`<span class="usage${tone}">${meterTrack(options, sharePct)}${usageKey(options)}</span>`;
|
|
15951
15981
|
}
|
|
15952
15982
|
function meterTrack(options, sharePct) {
|
|
15953
|
-
|
|
15983
|
+
const parts2 = options.parts?.filter((part) => part.sharePct > 0) ?? [];
|
|
15984
|
+
const fill = parts2.length ? html`<span class="usage-fill has-parts" data-cut-line="4" style="width:${sharePct.toFixed(2)}%">${parts2.map((part) => html`<span class="usage-part" data-part="${String(part.partColorIndex)}" style="flex-grow:${String(part.sharePct)}"></span>`)}</span>` : html`<span class="usage-fill" data-cut-line="4" style="width:${sharePct.toFixed(2)}%"></span>`;
|
|
15985
|
+
return html`<div class="usage-track cut-carved" data-cut-line="6" role="meter" aria-label="${options.label}" aria-valuemin="0" aria-valuemax="${options.limitNumber}" aria-valuenow="${Math.min(options.usedNumber, options.limitNumber)}">${CUT_LINE}${fill}</div>`;
|
|
15986
|
+
}
|
|
15987
|
+
function usageKey(options) {
|
|
15988
|
+
const parts2 = options.parts?.filter((part) => part.sharePct > 0) ?? [];
|
|
15989
|
+
if (!parts2.length) return "";
|
|
15990
|
+
return html`<ul class="usage-key" aria-label="What used ${options.label}">${parts2.map((part) => html`<li data-part="${String(part.partColorIndex)}"><span class="usage-swatch" aria-hidden="true"></span>${part.label}<b>${String(part.sharePct)}%</b></li>`)}</ul>`;
|
|
15954
15991
|
}
|
|
15955
15992
|
function usageMeter(options) {
|
|
15956
15993
|
const { sharePct, isAllUsed, tone } = meterState(options);
|
|
15957
15994
|
const overNumber = options.usedNumber - options.limitNumber;
|
|
15958
15995
|
const allUsedWords = overNumber > 0 ? `, ${chartWords(overNumber)} ${options.unit} over` : ", all used";
|
|
15959
15996
|
const resetWords = options.resetDate ? `Resets ${(/* @__PURE__ */ new Date(`${options.resetDate}T00:00:00Z`)).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}.` : "";
|
|
15960
|
-
return html`<div class="usage${tone}"><span class="usage-label">${isAllUsed ? icon("warning") : ""}${options.label}</span><p class="usage-numbers"><span class="usage-fact">${options.shownWords ?? html`${chartWords(options.usedNumber)} of ${chartWords(options.limitNumber)}\u00a0${options.unit}`}${isAllUsed ? html`<b>${allUsedWords}</b>` : ""}${resetWords ? "\xA0\xB7" : ""}</span>${resetWords ? html` <span class="usage-fact">${resetWords}</span>` : ""}</p>${meterTrack(options, sharePct)}</div>`;
|
|
15997
|
+
return html`<div class="usage${tone}"><span class="usage-label">${isAllUsed ? icon("warning") : ""}${options.label}</span><p class="usage-numbers"><span class="usage-fact">${options.shownWords ?? html`${chartWords(options.usedNumber)} of ${chartWords(options.limitNumber)}\u00a0${options.unit}`}${isAllUsed ? html`<b>${allUsedWords}</b>` : ""}${resetWords ? "\xA0\xB7" : ""}</span>${resetWords ? html` <span class="usage-fact">${resetWords}</span>` : ""}</p>${usageKey(options)}${meterTrack(options, sharePct)}</div>`;
|
|
15961
15998
|
}
|
|
15962
15999
|
var meterPane = (options) => glassPane({ paneWidth: "fit", body: usageMeter(options) });
|
|
15963
16000
|
component({
|
|
@@ -15981,6 +16018,24 @@ component({
|
|
|
15981
16018
|
.usage:is(.is-used, .is-near) .usage-fill { min-width: 6px; }
|
|
15982
16019
|
.usage.is-near .usage-fill { background: var(--meter-near); }
|
|
15983
16020
|
.usage.is-full .usage-fill { background: repeating-linear-gradient(-45deg, var(--meter-near) 0 6px, var(--meter-full) 6px 12px); }
|
|
16021
|
+
/* With parts, the fill is what used it, side by side in each part's color, a hairline of the track between them; past 80% the colors
|
|
16022
|
+
stay and the numbers say so, and all used, amber stripes run across them. The key names each part with its share: over the track on a
|
|
16023
|
+
meter of its own, so the track stays its foot, and under it in a list row or another plate, where the row's lines are over it. Each
|
|
16024
|
+
swatch hangs before its words, so the words keep the margin. */
|
|
16025
|
+
.usage-fill.has-parts, .usage:is(.is-near, .is-full) .usage-fill.has-parts { display: flex; gap: var(--gap-control-row); background: none; }
|
|
16026
|
+
.usage-part { display: block; min-width: 3px; background: var(--part-color); }
|
|
16027
|
+
.usage.is-full .usage-part { background: repeating-linear-gradient(-45deg, var(--part-color) 0 6px, var(--meter-full) 6px 12px); }
|
|
16028
|
+
.usage [data-part="0"] { --part-color: var(--part-0); }
|
|
16029
|
+
.usage [data-part="1"] { --part-color: var(--part-1); }
|
|
16030
|
+
.usage [data-part="2"] { --part-color: var(--part-2); }
|
|
16031
|
+
.usage [data-part="3"] { --part-color: var(--part-3); }
|
|
16032
|
+
.usage [data-part="4"] { --part-color: var(--part-4); }
|
|
16033
|
+
.usage [data-part="5"] { --part-color: var(--part-5); }
|
|
16034
|
+
.usage-key { display: flex; flex-wrap: wrap; gap: var(--gap-words-words) var(--gap-block-block); list-style: none; margin: 0; padding: 0 var(--pad-words); }
|
|
16035
|
+
.usage-key li { position: relative; display: inline-flex; align-items: center; gap: var(--gap-words-words); font: var(--type-facts); color: var(--ink-2); }
|
|
16036
|
+
.usage-key .usage-swatch { position: absolute; left: calc(-10px - var(--gap-words-words)); top: calc(50% - 5px); }
|
|
16037
|
+
.usage-key b { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
16038
|
+
.usage-swatch { width: 10px; height: 10px; background: var(--part-color); }
|
|
15984
16039
|
`,
|
|
15985
16040
|
render: usageMeter,
|
|
15986
16041
|
examples: [
|
|
@@ -15991,7 +16046,30 @@ component({
|
|
|
15991
16046
|
{ label: "A little used: still a sliver", markup: meterPane({ label: "Requests this month", usedNumber: 140, limitNumber: 1e5, unit: "requests", resetDate: "2026-11-01" }) },
|
|
15992
16047
|
{ label: "None used", markup: meterPane({ label: "Database rows", usedNumber: 0, limitNumber: 5e4, unit: "rows" }) },
|
|
15993
16048
|
{ label: "In its own words: a share of a free allowance", markup: meterPane({ label: "Starfall", usedNumber: 62, limitNumber: 100, unit: "%", resetDate: "2026-11-01", shownWords: "62% of this month's free hosting" }) },
|
|
15994
|
-
{ label: "Two allowances on one track: free, then a budget the game lives on (used up is not a warning until the budget is)", markup: meterPane({ label: "Starfall", usedNumber: 100 + 2 * 45, limitNumber: 300, warnFromNumber: 260, isFullFine: true, unit: "", resetDate: "2026-11-01", shownWords: "Free part used. $18 of $40 budget" }) }
|
|
16049
|
+
{ label: "Two allowances on one track: free, then a budget the game lives on (used up is not a warning until the budget is)", markup: meterPane({ label: "Starfall", usedNumber: 100 + 2 * 45, limitNumber: 300, warnFromNumber: 260, isFullFine: true, unit: "", resetDate: "2026-11-01", shownWords: "Free part used. $18 of $40 budget" }) },
|
|
16050
|
+
{ label: "Split by what used it: a color for each part, with its share in the key", markup: meterPane({ label: "Starfall", usedNumber: 62, limitNumber: 100, unit: "%", resetDate: "2026-11-01", shownWords: "62% of this month's free hosting", parts: [{ label: "Rooms", sharePct: 48, partColorIndex: 0 }, { label: "Game server", sharePct: 27, partColorIndex: 1 }, { label: "Database", sharePct: 15, partColorIndex: 3 }, { label: "Requests", sharePct: 10, partColorIndex: 4 }] }) },
|
|
16051
|
+
{ label: "Split, past 80%", markup: meterPane({ label: "Kestrel", usedNumber: 86, limitNumber: 100, unit: "%", resetDate: "2026-11-01", shownWords: "86% of this month's free hosting", parts: [{ label: "Relay", sharePct: 75, partColorIndex: 2 }, { label: "Rooms", sharePct: 25, partColorIndex: 0 }] }) },
|
|
16052
|
+
{ label: "Split, all used", markup: meterPane({ label: "Ashen Crown", usedNumber: 100, limitNumber: 100, unit: "%", resetDate: "2026-11-01", shownWords: "100% of this month's free hosting", parts: [{ label: "Rooms", sharePct: 100, partColorIndex: 0 }] }) }
|
|
16053
|
+
]
|
|
16054
|
+
});
|
|
16055
|
+
component({
|
|
16056
|
+
name: "Usage track",
|
|
16057
|
+
slug: "usage-track",
|
|
16058
|
+
group: "data",
|
|
16059
|
+
summary: "A usage meter's track and its parts' key alone, for a list row or a plate whose own lines say the numbers: a game's hosting in developer settings and on its dashboard.",
|
|
16060
|
+
styles: "",
|
|
16061
|
+
render: usageTrack,
|
|
16062
|
+
examples: [
|
|
16063
|
+
{
|
|
16064
|
+
label: "Split by what used it, its key under the track, in a list row whose lines say the numbers",
|
|
16065
|
+
markup: glassPane({
|
|
16066
|
+
paneWidth: "fit",
|
|
16067
|
+
body: listRows({
|
|
16068
|
+
whenEmpty: html``,
|
|
16069
|
+
rows: [{ title: "Starfall", detail: "62% of this month's free hosting \xB7 Resets Nov 1", meter: { label: "Starfall", usedNumber: 62, limitNumber: 100, unit: "%", parts: [{ label: "Rooms", sharePct: 48, partColorIndex: 0 }, { label: "Game server", sharePct: 27, partColorIndex: 1 }, { label: "Database", sharePct: 15, partColorIndex: 3 }, { label: "Requests", sharePct: 10, partColorIndex: 4 }] } }]
|
|
16070
|
+
})
|
|
16071
|
+
})
|
|
16072
|
+
}
|
|
15995
16073
|
]
|
|
15996
16074
|
});
|
|
15997
16075
|
|
|
@@ -23796,24 +23874,63 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23796
23874
|
let applying = null; // a developer's change while the game domain puts it in place under the running game
|
|
23797
23875
|
let isRestoringCopy = false; // the game is going back to an earlier version (Zaisma.saves.restoreCopy)
|
|
23798
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
|
|
23799
23881
|
// A save with nothing in it, exactly as canonicalSave in packages/core/src/saves.ts writes it.
|
|
23800
23882
|
const EMPTY_SAVE_TEXT = ${JSON.stringify(EMPTY_SAVE_TEXT)};
|
|
23801
23883
|
|
|
23802
23884
|
// A save as one text, the same as canonicalSave on the server: all three keys in this order, localStorage keys sorted.
|
|
23803
23885
|
const canonical = (save) => {
|
|
23886
|
+
if (!save || !save.local_storage_items) return null;
|
|
23804
23887
|
const items = {};
|
|
23805
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 }));
|
|
23806
23889
|
return JSON.stringify({ save_format_version_number: 1, local_storage_items: items, indexeddb_databases: save.indexeddb_databases });
|
|
23807
23890
|
};
|
|
23808
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.
|
|
23809
23893
|
const marker = (tag, value) => {
|
|
23810
23894
|
try {
|
|
23811
23895
|
if (value === undefined) return JSON.parse(localStorage.getItem("zaisma:save:" + tag) || "null");
|
|
23812
|
-
localStorage.setItem("zaisma:save:" + tag, JSON.stringify(value));
|
|
23896
|
+
localStorage.setItem("zaisma:save:" + tag, JSON.stringify({ ...value, slug: SAVES.slug, marked_at_ms: serverNow() }));
|
|
23897
|
+
} catch { return null; }
|
|
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;
|
|
23813
23922
|
} catch { return null; }
|
|
23814
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
|
+
});
|
|
23815
23932
|
const ago = (savedAt) => {
|
|
23816
|
-
const m = Math.round((
|
|
23933
|
+
const m = Math.round((serverNow() - Date.parse(savedAt)) / 60000);
|
|
23817
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";
|
|
23818
23935
|
};
|
|
23819
23936
|
// Where progress goes, in the Keep your progress card.
|
|
@@ -23832,11 +23949,10 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23832
23949
|
}
|
|
23833
23950
|
// The account's progress wins: keep this device's as an earlier version on the account, dated when this device
|
|
23834
23951
|
// last played it (played_at; null when that isn't known). Answers the copy's ID, or null.
|
|
23835
|
-
const keepDevice = (text) => {
|
|
23836
|
-
if (!text || text === EMPTY_SAVE_TEXT) return Promise.resolve(null);
|
|
23837
|
-
const
|
|
23838
|
-
|
|
23839
|
-
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) })
|
|
23840
23956
|
.then((r) => (r.ok ? r.json() : null))
|
|
23841
23957
|
.then((out) => (out && out.save_copy_id) || null)
|
|
23842
23958
|
.catch(() => null);
|
|
@@ -23848,25 +23964,22 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23848
23964
|
localStorage.setItem(playedKey, JSON.stringify(value));
|
|
23849
23965
|
} catch { return null; }
|
|
23850
23966
|
};
|
|
23851
|
-
// 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).
|
|
23852
23968
|
async function notePlayed(text) {
|
|
23853
23969
|
const hash = await sha(text);
|
|
23854
23970
|
const was = played();
|
|
23855
23971
|
if (!was) played({ sha256: hash, played_at_ms: null });
|
|
23856
|
-
else if (was.sha256 !== hash) played({ sha256: hash, played_at_ms:
|
|
23972
|
+
else if (was.sha256 !== hash) played({ sha256: hash, played_at_ms: serverNow() });
|
|
23857
23973
|
}
|
|
23858
23974
|
// Whether this device's progress was played after the account's was saved. Not known: the account's wins.
|
|
23859
|
-
const deviceIsNewer = (savedAt) =>
|
|
23860
|
-
const p = played();
|
|
23861
|
-
return Boolean(p && p.played_at_ms && savedAt && p.played_at_ms > Date.parse(savedAt));
|
|
23862
|
-
};
|
|
23975
|
+
const deviceIsNewer = (savedAt, playedAtMs = (played() || {}).played_at_ms) => Boolean(playedAtMs && savedAt && playedAtMs > Date.parse(savedAt));
|
|
23863
23976
|
const tell = (msg) => frame && frame.contentWindow && frame.contentWindow.postMessage({ type: "zaisma:saves", ...msg }, SAVES.frameOrigin);
|
|
23864
23977
|
// To the game itself (the SDK), not the game domain's saves script.
|
|
23865
23978
|
const toGame = (msg) => frame && frame.contentWindow && frame.contentWindow.postMessage(msg, SAVES.frameOrigin);
|
|
23866
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 });
|
|
23867
23980
|
async function copyById(saveCopyId) {
|
|
23868
23981
|
if (!saveCopyId) return null;
|
|
23869
|
-
const res = await
|
|
23982
|
+
const res = await ask(SAVES.api + "/copies").catch(() => null);
|
|
23870
23983
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
23871
23984
|
const copy = out && Array.isArray(out.save_copies) ? out.save_copies.find((each) => each.id === saveCopyId) : null;
|
|
23872
23985
|
return copy ? copyFields(copy) : null;
|
|
@@ -23887,7 +24000,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23887
24000
|
// One retry for a passing hiccup; a signed-out player or a game without saves just plays.
|
|
23888
24001
|
async function load(retry = true) {
|
|
23889
24002
|
try {
|
|
23890
|
-
const res = await
|
|
24003
|
+
const res = await ask(SAVES.api);
|
|
23891
24004
|
if (res.ok) return await res.json();
|
|
23892
24005
|
if (res.status === 401 || res.status === 404 || !retry) return null;
|
|
23893
24006
|
} catch { if (!retry) return null; }
|
|
@@ -23907,6 +24020,9 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
23907
24020
|
pendingConflict = null; // A conflict from this reload comes after it (conflicted).
|
|
23908
24021
|
if (applying) { clearTimeout(applying.timer); applying = null; }
|
|
23909
24022
|
queued = null; // From the game as it was; the reloaded game sends its own.
|
|
24023
|
+
clearTimeout(retryTimer);
|
|
24024
|
+
retryTimer = null;
|
|
24025
|
+
retryDelayMs = 0;
|
|
23910
24026
|
cloud = load();
|
|
23911
24027
|
openAt("_zaisma/restore#" + encodeURIComponent(SAVES.query));
|
|
23912
24028
|
clearTimeout(fallback);
|
|
@@ -24017,8 +24133,8 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24017
24133
|
const c = await load();
|
|
24018
24134
|
if (!c) return say("Couldn't reach your saved progress, so this visit won't save to your account.");
|
|
24019
24135
|
cloud = c;
|
|
24020
|
-
if (!c.version_number) {
|
|
24021
|
-
// 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.
|
|
24022
24138
|
paused = false;
|
|
24023
24139
|
synced = null;
|
|
24024
24140
|
welcome = true;
|
|
@@ -24031,6 +24147,10 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24031
24147
|
tell({ step: "sync", text: guestText });
|
|
24032
24148
|
say("Progress saves to your account.");
|
|
24033
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();
|
|
24034
24154
|
} else if (deviceIsNewer(c.saved_at)) {
|
|
24035
24155
|
// Both have progress, and this device's was played more recently: it replaces the account's, which is kept
|
|
24036
24156
|
// as an earlier version (the game hears the conflict once that save is stored).
|
|
@@ -24049,9 +24169,37 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24049
24169
|
pop("You're signed in. Your account's progress in this game is newer, so the game reloaded with it.");
|
|
24050
24170
|
}
|
|
24051
24171
|
}
|
|
24052
|
-
|
|
24053
|
-
|
|
24054
|
-
|
|
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
|
+
}
|
|
24055
24203
|
|
|
24056
24204
|
async function restore(text, broken) {
|
|
24057
24205
|
clearTimeout(fallback);
|
|
@@ -24066,63 +24214,98 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24066
24214
|
say("Couldn't reach your saved progress, so this visit won't save to your account.");
|
|
24067
24215
|
return tell({ step: "restore", action: "keep" });
|
|
24068
24216
|
}
|
|
24069
|
-
|
|
24070
|
-
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);
|
|
24071
24220
|
// The first time this page sees this device's save: from now on a change to it counts as playing.
|
|
24072
24221
|
if (!played()) played({ sha256: localHash, played_at_ms: null });
|
|
24073
24222
|
// A marker from before version_number and sha256 matches neither, so the most recently played wins.
|
|
24074
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;
|
|
24075
24247
|
let use;
|
|
24076
|
-
if (forceCloud &&
|
|
24248
|
+
if (forceCloud && hasCloudSave) {
|
|
24077
24249
|
use = "cloud";
|
|
24078
24250
|
// This device's progress since its last save (the account keeps one copy of each progress).
|
|
24079
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.
|
|
24080
|
-
if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) &&
|
|
24252
|
+
if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) && deviceText !== keptBeforeReload) keepDevice(deviceText, devicePlayedAtMs);
|
|
24081
24253
|
}
|
|
24082
|
-
else if (!
|
|
24254
|
+
else if (!hasCloudSave) use = "local";
|
|
24083
24255
|
else if (empty || localHash === c.sha256) use = "cloud";
|
|
24084
24256
|
else if (mark && mark.version_number === c.version_number) use = "local";
|
|
24085
24257
|
else if (mark && mark.sha256 === localHash) use = "cloud";
|
|
24086
24258
|
else {
|
|
24087
24259
|
// Both played on since they last matched: the progress played most recently wins, the other is kept.
|
|
24088
|
-
use = deviceIsNewer(c.saved_at) ? "local" : "cloud";
|
|
24089
|
-
if (use === "cloud") conflicted("account", keepDevice(
|
|
24260
|
+
use = deviceIsNewer(c.saved_at, devicePlayedAtMs) ? "local" : "cloud";
|
|
24261
|
+
if (use === "cloud") conflicted("account", keepDevice(deviceText, devicePlayedAtMs));
|
|
24090
24262
|
else replacing = true; // The game hears the conflict once its save replaced the account's (store).
|
|
24091
24263
|
}
|
|
24092
24264
|
forceCloud = false;
|
|
24093
24265
|
keptBeforeReload = null;
|
|
24094
|
-
|
|
24095
|
-
|
|
24096
|
-
|
|
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 });
|
|
24097
24271
|
} else {
|
|
24098
24272
|
paused = false;
|
|
24099
24273
|
// Keep this device's save; the game sends it as soon as it starts.
|
|
24100
|
-
synced = empty && !
|
|
24274
|
+
synced = empty && !hasCloudSave ? text : null;
|
|
24101
24275
|
tell({ step: "restore", action: "keep" });
|
|
24102
24276
|
say("Progress saves to your account.");
|
|
24103
24277
|
}
|
|
24104
24278
|
}
|
|
24105
24279
|
|
|
24106
|
-
// The game domain put
|
|
24280
|
+
// The game domain put a save in place (or couldn't). Only then does saving start.
|
|
24107
24281
|
function restored(ok) {
|
|
24108
|
-
const
|
|
24282
|
+
const r = restoring;
|
|
24109
24283
|
restoring = null;
|
|
24284
|
+
if (!r) return;
|
|
24110
24285
|
if (!ok) {
|
|
24286
|
+
if (r.aside) putAside(r.c.tag, r.aside);
|
|
24111
24287
|
say("Couldn't put your account's progress on this device, so this visit won't save to your account.");
|
|
24112
24288
|
return;
|
|
24113
24289
|
}
|
|
24114
|
-
|
|
24115
|
-
|
|
24116
|
-
|
|
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);
|
|
24117
24294
|
paused = false;
|
|
24118
|
-
|
|
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.");
|
|
24119
24302
|
}
|
|
24120
24303
|
|
|
24121
24304
|
async function store(text) {
|
|
24122
24305
|
if (paused) return;
|
|
24123
|
-
if (saving) { queued = text; return; }
|
|
24124
|
-
const body = JSON.stringify({ base_version_number: cloud.version_number, save_json: text, version_id: versionId, ...(replacing ? { replace: true } : {}) });
|
|
24125
|
-
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);
|
|
24126
24309
|
const opened = openCount;
|
|
24127
24310
|
const res = await saving;
|
|
24128
24311
|
saving = null;
|
|
@@ -24131,9 +24314,13 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24131
24314
|
// Going back to an earlier version: a save that went through still moved the account on; anything else waits for that.
|
|
24132
24315
|
if (isRestoringCopy && !(res && res.ok)) return;
|
|
24133
24316
|
const out = res ? await res.json().catch(() => ({})) : {};
|
|
24134
|
-
|
|
24135
|
-
|
|
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));
|
|
24136
24322
|
replacing = false;
|
|
24323
|
+
retryDelayMs = 0;
|
|
24137
24324
|
cloud.version_number = out.version_number;
|
|
24138
24325
|
cloud.sha256 = out.sha256;
|
|
24139
24326
|
synced = text;
|
|
@@ -24141,17 +24328,23 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24141
24328
|
say("Progress saves to your account. Last saved just now.");
|
|
24142
24329
|
if (welcome) pop("Your progress now saves to your account.");
|
|
24143
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);
|
|
24144
24334
|
} else if (res && res.status === 409) {
|
|
24145
24335
|
paused = true;
|
|
24146
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;
|
|
24147
24340
|
// The developer's change always wins, and so does another device's progress played after this device's:
|
|
24148
24341
|
// reload the game with it, keeping this device's progress. Otherwise this device's, played just now, replaces it.
|
|
24149
24342
|
// A developer's change is never a conflict, and a game that listens for it takes it in place.
|
|
24150
24343
|
if (out.changed_by_kind === "developer" && listening.has("developerChange")) {
|
|
24151
|
-
takeDeveloperChange(
|
|
24344
|
+
takeDeveloperChange(newest);
|
|
24152
24345
|
} else if (out.changed_by_kind === "developer" || !deviceIsNewer(out.saved_at)) {
|
|
24153
|
-
const keeping = keepDevice(
|
|
24154
|
-
keptBeforeReload =
|
|
24346
|
+
const keeping = keepDevice(newest);
|
|
24347
|
+
keptBeforeReload = newest;
|
|
24155
24348
|
forceCloud = true;
|
|
24156
24349
|
openThroughRestore();
|
|
24157
24350
|
if (out.changed_by_kind !== "developer") conflicted("account", keeping);
|
|
@@ -24166,14 +24359,27 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24166
24359
|
overLimit();
|
|
24167
24360
|
return;
|
|
24168
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.
|
|
24169
24365
|
paused = true;
|
|
24366
|
+
queued = null;
|
|
24170
24367
|
tell({ step: "pause" });
|
|
24171
|
-
say("
|
|
24368
|
+
say("This game doesn't save to your account right now, so its progress stays on this device.");
|
|
24172
24369
|
return;
|
|
24173
24370
|
} else {
|
|
24174
|
-
//
|
|
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;
|
|
24175
24375
|
say("Couldn't save to your account. Trying again soon.");
|
|
24176
|
-
|
|
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;
|
|
24177
24383
|
}
|
|
24178
24384
|
if (queued !== null) { const next = queued; queued = null; store(next); }
|
|
24179
24385
|
}
|
|
@@ -24183,7 +24389,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24183
24389
|
// asked for, so this costs one small read.
|
|
24184
24390
|
document.addEventListener("visibilitychange", async () => {
|
|
24185
24391
|
if (guest || paused || saving || document.visibilityState !== "visible" || !cloud || cloud instanceof Promise) return;
|
|
24186
|
-
const res = await
|
|
24392
|
+
const res = await ask(SAVES.api + "?version").catch(() => null);
|
|
24187
24393
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
24188
24394
|
if (!out || out.version_number === cloud.version_number || paused || saving) return;
|
|
24189
24395
|
if (out.changed_by_kind !== "developer" && deviceIsNewer(out.saved_at)) return;
|
|
@@ -24200,6 +24406,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24200
24406
|
// text is this device's progress since its last save, kept as an earlier version (null: nothing to keep).
|
|
24201
24407
|
async function takeDeveloperChange(text) {
|
|
24202
24408
|
const opened = openCount;
|
|
24409
|
+
queued = null;
|
|
24203
24410
|
const keeping = keepDevice(text);
|
|
24204
24411
|
const reload = () => {
|
|
24205
24412
|
keptBeforeReload = text;
|
|
@@ -24216,6 +24423,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24216
24423
|
const taking = applying;
|
|
24217
24424
|
if (!taking) return;
|
|
24218
24425
|
applying = null;
|
|
24426
|
+
queued = null; // From the game before the change: the game sends its own after it.
|
|
24219
24427
|
clearTimeout(taking.timer);
|
|
24220
24428
|
if (!ok) return taking.reload();
|
|
24221
24429
|
const c = taking.c;
|
|
@@ -24253,7 +24461,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24253
24461
|
reply({ status: "asking" });
|
|
24254
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).");
|
|
24255
24463
|
if (m.request === "list_copies") {
|
|
24256
|
-
const res = await
|
|
24464
|
+
const res = await ask(SAVES.api + "/copies").catch(() => null);
|
|
24257
24465
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
24258
24466
|
if (!out || !Array.isArray(out.save_copies)) return failed();
|
|
24259
24467
|
return reply({ answer: out.save_copies.map(copyFields) });
|
|
@@ -24262,7 +24470,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24262
24470
|
const copyPath = SAVES.api + "/copies/" + m.saveCopyId;
|
|
24263
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.");
|
|
24264
24472
|
if (m.request === "get_copy") {
|
|
24265
|
-
const res = await
|
|
24473
|
+
const res = await ask(copyPath).catch(() => null);
|
|
24266
24474
|
if (res && res.status === 404) return noCopy();
|
|
24267
24475
|
const out = res && res.ok ? await res.json().catch(() => null) : null;
|
|
24268
24476
|
if (!out || !out.save_copy) return failed();
|
|
@@ -24274,7 +24482,7 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24274
24482
|
isRestoringCopy = true;
|
|
24275
24483
|
queued = null;
|
|
24276
24484
|
tell({ step: "pause" });
|
|
24277
|
-
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);
|
|
24278
24486
|
isRestoringCopy = false;
|
|
24279
24487
|
if (res && res.ok) {
|
|
24280
24488
|
reply({ answer: null });
|
|
@@ -24283,6 +24491,11 @@ var SAVES_SCRIPT = pageScript("saves", `
|
|
|
24283
24491
|
openThroughRestore();
|
|
24284
24492
|
return;
|
|
24285
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
|
+
}
|
|
24286
24499
|
paused = wasPaused;
|
|
24287
24500
|
if (!wasPaused) tell({ step: "sync", text: synced });
|
|
24288
24501
|
if (res && res.status === 404) return noCopy();
|
|
@@ -43446,6 +43659,9 @@ function isLocalPreview(env2) {
|
|
|
43446
43659
|
return siteHost === "localhost" || siteHost === "127.0.0.1" || siteHost.endsWith(".ts.net");
|
|
43447
43660
|
}
|
|
43448
43661
|
|
|
43662
|
+
// ../../apps/site/src/credit-api.ts
|
|
43663
|
+
var PENDING_REFUND_FIRST_RETRY_MS = 5 * 6e4;
|
|
43664
|
+
|
|
43449
43665
|
// ../../apps/site/src/stripe-account-api.ts
|
|
43450
43666
|
var CONNECT_STATE_DURATION_MS = 10 * 6e4;
|
|
43451
43667
|
|