@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 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, in a query and in the database. */
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
- 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}<span class="usage-fill" data-cut-line="4" style="width:${sharePct.toFixed(2)}%"></span></div>`;
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((Date.now() - Date.parse(savedAt)) / 60000);
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 p = played();
23838
- const body = { keep: true, save_json: text, version_id: versionId, played_at: p && p.played_at_ms ? new Date(p.played_at_ms).toISOString() : null };
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: Date.now() });
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 fetch(SAVES.api + "/copies", { credentials: "same-origin" }).catch(() => null);
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 fetch(SAVES.api, { credentials: "same-origin" });
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
- addEventListener("focus", joined);
24053
- document.addEventListener("visibilitychange", joined);
24054
- try { new BroadcastChannel("zaisma").onmessage = (e) => e.data === "signed-in" && joined(); } catch {}
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
- const localHash = await sha(text);
24070
- const empty = text === EMPTY_SAVE_TEXT;
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 && c.version_number) {
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) && text !== keptBeforeReload) keepDevice(text);
24252
+ if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) && deviceText !== keptBeforeReload) keepDevice(deviceText, devicePlayedAtMs);
24081
24253
  }
24082
- else if (!c.version_number) use = "local";
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(text));
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
- if (use === "cloud" && c.version_number) {
24095
- restoring = c;
24096
- tell({ step: "restore", action: "use-cloud", data: c.save_data });
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 && !c.version_number ? text : null;
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 the account's save in place (or couldn't). Only then does saving start.
24280
+ // The game domain put a save in place (or couldn't). Only then does saving start.
24107
24281
  function restored(ok) {
24108
- const c = restoring;
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
- synced = canonical(c.save_data);
24115
- marker(c.tag, { version_number: c.version_number, sha256: c.sha256 });
24116
- played({ sha256: c.sha256, played_at_ms: Date.parse(c.saved_at) || null });
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
- say("Progress saves to your account. Last saved " + ago(c.saved_at) + ".");
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 = fetch(SAVES.api, { method: "PUT", body, credentials: "same-origin", keepalive: body.length < 60000 }).catch(() => null);
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
- if (res && res.ok) {
24135
- if (replacing) conflicted("this_device", Promise.resolve(out.kept_save_copy_id));
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(text);
24344
+ takeDeveloperChange(newest);
24152
24345
  } else if (out.changed_by_kind === "developer" || !deviceIsNewer(out.saved_at)) {
24153
- const keeping = keepDevice(text);
24154
- keptBeforeReload = text;
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("Sign in again to keep saving to your account.");
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
- // Let the game send this save again on its next check.
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
- tell({ step: "sync", text: synced });
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 fetch(SAVES.api + "?version", { credentials: "same-origin" }).catch(() => null);
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 fetch(SAVES.api + "/copies", { credentials: "same-origin" }).catch(() => null);
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 fetch(copyPath, { credentials: "same-origin" }).catch(() => null);
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 fetch(copyPath + "/restore", { method: "POST", credentials: "same-origin" }).catch(() => null);
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