@zaisma/game-server 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bench/glass.js CHANGED
@@ -6273,6 +6273,7 @@ var ERROR_CODES = {
6273
6273
  confirmation_needed: { httpStatus: 403, meaning: "This needs the developer's passkey on zaisma.com (turning spending on or up, or connecting a Stripe account): confirm_url is where they do it." },
6274
6274
  bad_monthly_budget_cents: { httpStatus: 400, meaning: "monthly_budget_cents isn't a whole number of cents of at least 100 ($1), or null for no paid hosting." },
6275
6275
  bad_top_up_amount: { httpStatus: 400, meaning: "amount_cents isn't a whole number of cents from 1,000 ($10) to 100,000 ($1,000)." },
6276
+ account_not_protected: { httpStatus: 403, meaning: "The account has no passkey and no email yet, and a top-up keeps a card, so it needs one of them first: add a passkey or an email on zaisma.com/account." },
6276
6277
  no_top_up: { httpStatus: 404, meaning: "You have no top-up with that ID." },
6277
6278
  no_spending_request: { httpStatus: 404, meaning: "You have no spending request with that ID." },
6278
6279
  top_up_not_refundable: { httpStatus: 409, meaning: "The top-up has nothing left to refund: it's spent, refunded, or over 180 days old." },
@@ -6437,6 +6438,62 @@ var MATROSKA_HEAD_BYTES = 64 * 1024;
6437
6438
  var MAXIMUM_EMBEDDED_CHARACTER_COUNT = 40 * 1024 * 1024;
6438
6439
  var PACK_WINDOW_SIZE_BYTES = 32 * 1024 * 1024;
6439
6440
 
6441
+ // ../core/src/scan/textures.ts
6442
+ var digitsOf = (digits) => [...digits].map(Number);
6443
+ var TRITS = [
6444
+ "0120012101220122012001210122012001200121012201220120012101220121012001210122012201200121012201220120012101220122012001210122012201200121012201220120012101220120012001210122012201200121012201210120012101220122012001210122012201200121012201220120012101220122",
6445
+ "0000111022202220000111112221000000001110222022200001111122211110000011102220222000011111222122200000111022202220000111112221222000001110222022200001111122210001000011102220222000011111222111110000111022202220000111112221222100001110222022200001111122212221",
6446
+ "0002000200022222111211121112000200020002000222221112111211120002000200020002222211121112111200020002000200022222111211121112222200020002000222221112111211121112000200020002222211121112111211120002000200022222111211121112111200020002000222221112111211122222",
6447
+ "0000000000000000000000000000222211111111111111111111111111112222222222222222222222222222222222220000000000000000000000000000222200000000000000000000000000002222111111111111111111111111111122222222222222222222222222222222222211111111111111111111111111112222",
6448
+ "0000000000000000000000000000222200000000000000000000000000002222000000000000000000000000000022222222222222222222222222222222222211111111111111111111111111112222111111111111111111111111111122221111111111111111111111111111222222222222222222222222222222222222"
6449
+ ].map(digitsOf);
6450
+ var QUINTS = [
6451
+ "01234044012341440123424401234344012340400123414101234242012343430123402301234123012342230123432301234001012341010123420101234301",
6452
+ "00000444111114442222244433333444000004041111141422222424333334340000040011111411222224223333343300000400111114112222242233333433",
6453
+ "00000004000000140000002400000034111111441111114411111144111111442222224422222244222222442222224433333344333333443333334433333344"
6454
+ ].map(digitsOf);
6455
+
6456
+ // ../core/src/scan/png.ts
6457
+ var CRC_TABLE = (() => {
6458
+ const table2 = new Uint32Array(256);
6459
+ for (let byteIndex = 0; byteIndex < 256; byteIndex++) {
6460
+ let remainderCode = byteIndex;
6461
+ for (let bitIndex = 0; bitIndex < 8; bitIndex++) remainderCode = remainderCode & 1 ? 3988292384 ^ remainderCode >>> 1 : remainderCode >>> 1;
6462
+ table2[byteIndex] = remainderCode >>> 0;
6463
+ }
6464
+ return table2;
6465
+ })();
6466
+
6467
+ // ../core/src/scan/unity.ts
6468
+ var MAXIMUM_BUNDLE_BLOCK_SIZE_BYTES = 32 * 1024 * 1024;
6469
+ var MAXIMUM_READ_INSIDE_SIZE_BYTES = 16 * 1024 * 1024;
6470
+ var READ_WINDOW_SIZE_BYTES = 8 * 1024 * 1024;
6471
+ var BLOCK_CACHE_SIZE_BYTES = 32 * 1024 * 1024;
6472
+ var UNITY_TEXTURE_FORMATS = {
6473
+ 2: "argb4444",
6474
+ 3: "rgb8",
6475
+ 4: "rgba8",
6476
+ 5: "argb8",
6477
+ 7: "rgb565",
6478
+ 10: "bc1",
6479
+ 11: "bc2",
6480
+ 12: "bc3",
6481
+ 13: "rgba4444",
6482
+ 14: "bgra8",
6483
+ 25: "bc7",
6484
+ 34: "etc1",
6485
+ 45: "etc2_rgb",
6486
+ 46: "etc2_rgba1",
6487
+ 47: "etc2_rgba8",
6488
+ 60: "etc1",
6489
+ 61: "etc2_rgba8",
6490
+ ...Object.fromEntries([4, 5, 6, 8, 10, 12].flatMap((sidePixelCount, i) => [[48 + i, `astc_${sidePixelCount}x${sidePixelCount}`], [54 + i, `astc_${sidePixelCount}x${sidePixelCount}`]]))
6491
+ };
6492
+
6493
+ // ../core/src/scan/godot.ts
6494
+ var GODOT_3_FILE_FORMAT_CODE = 1 << 20 | 1 << 21;
6495
+ var WINDOW_SIZE_BYTES = 8 * 1024 * 1024;
6496
+
6440
6497
  // ../core/src/plans.ts
6441
6498
  var PLAN_LIMITS = {
6442
6499
  free: { maximumLargeReviewsPerDay: 5, maximumSmallReviewsPerDay: 30, maximumDailyCheckCount: 20, maximumVersionsPerDay: 20, maximumGameSizeBytes: 2 * 1024 ** 3 },
@@ -6790,6 +6847,18 @@ var REVIEW_CODES = {
6790
6847
  fix: `Send the background without words or logos (add_image, or add_shader for a shader); words that are part of the scene, like a sign on a building, are fine. ${COVER_GUIDE} ${IF_THE_CHECK_IS_WRONG}`,
6791
6848
  hasOwnMessage: true
6792
6849
  },
6850
+ overlay_on_background: {
6851
+ meaning: "Something is laid over a background instead of being part of its world, like a button, badge or sticker, so it doesn't show; the message says what.",
6852
+ contentRule: null,
6853
+ fix: `Send the background without it (add_image, or add_shader for a shader). ${COVER_GUIDE} ${IF_THE_CHECK_IS_WRONG}`,
6854
+ hasOwnMessage: true
6855
+ },
6856
+ famous_name_on_background: {
6857
+ meaning: "A background shows a famous character, logo or name, from a film, show, game, brand or club, so it doesn't show; the message names it.",
6858
+ contentRule: null,
6859
+ fix: `Send a background of your game's own world (add_image, or add_shader for a shader). Only obvious ones count. If you own it or have permission, message_zaisma with the game_slug. ${COVER_GUIDE}`,
6860
+ hasOwnMessage: true
6861
+ },
6793
6862
  // The shader check (Backgrounds in docs/DEVELOPER-API.md): a shader is drawn before anyone sees it
6794
6863
  shader_does_not_compile: {
6795
6864
  meaning: "The shader didn't compile; the message gives the first compiler error.",
@@ -6832,18 +6901,19 @@ var SOCIAL_SETTING_CHOICES = {
6832
6901
  who_sees_playing_now: ["nobody", "friends", "anyone"],
6833
6902
  who_sees_played_games: ["nobody", "friends", "anyone"],
6834
6903
  who_sees_online: ["nobody", "friends"],
6835
- who_can_send_friend_requests: ["nobody", "anyone"],
6904
+ who_can_send_friend_requests: ["nobody", "played_with", "anyone"],
6836
6905
  who_sees_profile: ["friends", "anyone"]
6837
6906
  };
6838
6907
  var SOCIAL_SETTINGS = Object.keys(SOCIAL_SETTING_CHOICES);
6839
6908
  var SOCIAL_MODES = ["solo_focused", "friends_focused", "open_and_social"];
6840
6909
  var SOCIAL_MODE_SETTINGS = {
6841
6910
  solo_focused: { who_can_chat: "nobody", who_can_join: "friends", who_sees_playing_now: "friends", who_sees_played_games: "friends", who_sees_online: "friends", who_can_send_friend_requests: "nobody", who_sees_profile: "friends" },
6842
- friends_focused: { who_can_chat: "friends", who_can_join: "friends", who_sees_playing_now: "friends", who_sees_played_games: "friends", who_sees_online: "friends", who_can_send_friend_requests: "nobody", who_sees_profile: "friends" },
6911
+ // Friends-focused keeps every way to reach the player with friends, but anyone can offer friendship (Julius, Oct 27, 2026).
6912
+ friends_focused: { who_can_chat: "friends", who_can_join: "friends", who_sees_playing_now: "friends", who_sees_played_games: "friends", who_sees_online: "friends", who_can_send_friend_requests: "anyone", who_sees_profile: "friends" },
6843
6913
  open_and_social: { who_can_chat: "anyone", who_can_join: "anyone", who_sees_playing_now: "anyone", who_sees_played_games: "anyone", who_sees_online: "friends", who_can_send_friend_requests: "anyone", who_sees_profile: "anyone" }
6844
6914
  };
6845
6915
  var AGE_LIMITS = {
6846
- "13-15": { who_can_chat: "friends", who_can_join: "friends", who_sees_playing_now: "friends", who_sees_played_games: "friends", who_sees_online: "friends", who_can_send_friend_requests: "nobody", who_sees_profile: "anyone" },
6916
+ "13-15": { who_can_chat: "friends", who_can_join: "friends", who_sees_playing_now: "friends", who_sees_played_games: "friends", who_sees_online: "friends", who_can_send_friend_requests: "played_with", who_sees_profile: "anyone" },
6847
6917
  "16-17": { who_can_chat: "anyone", who_can_join: "friends", who_sees_playing_now: "friends", who_sees_played_games: "friends", who_sees_online: "friends", who_can_send_friend_requests: "anyone", who_sees_profile: "anyone" },
6848
6918
  "18+": { who_can_chat: "anyone", who_can_join: "anyone", who_sees_playing_now: "anyone", who_sees_played_games: "anyone", who_sees_online: "friends", who_can_send_friend_requests: "anyone", who_sees_profile: "anyone" }
6849
6919
  };
@@ -6863,7 +6933,8 @@ function defaultSocialMode(band) {
6863
6933
  return band === "13-15" ? "solo_focused" : "friends_focused";
6864
6934
  }
6865
6935
  function socialModeSettings(socialMode, band) {
6866
- return withinAgeLimits(SOCIAL_MODE_SETTINGS[socialMode], band);
6936
+ const settings = withinAgeLimits(SOCIAL_MODE_SETTINGS[socialMode], band);
6937
+ return band === "13-15" ? { ...settings, who_can_send_friend_requests: "nobody" } : settings;
6867
6938
  }
6868
6939
  function chatBandGroup(account) {
6869
6940
  if (account.who_can_chat === "nobody") return null;
@@ -8146,6 +8217,7 @@ var GAME_SERVER_OPTIONS = [
8146
8217
  "onRefund",
8147
8218
  "onTick",
8148
8219
  "onEnding",
8220
+ "onEndingCancelled",
8149
8221
  "onRestored",
8150
8222
  "stateView",
8151
8223
  "onVersionUpdate",
@@ -14781,8 +14853,10 @@ component({
14781
14853
  .choice-cards { --choice-row-least: calc(var(--choice-count) * var(--size-card)); display: flex; flex-wrap: wrap; align-items: stretch; gap: var(--gap-control-stack) 0; }
14782
14854
  .choice-cards > .choice-card { flex: 1 1 calc((var(--choice-row-least) - 100%) * 999); }
14783
14855
  .choice-cards > .choice-card:not(:last-child) { margin-inline-end: max(var(--gap-control-row-boxes), min(0px, (var(--choice-row-least) - 100%) * 999)); }
14784
- .choice-card { position: relative; display: flex; align-items: flex-start; gap: var(--gap-words-words); min-height: 76px; padding: 14px var(--pad-inside-words); cursor: pointer; -webkit-tap-highlight-color: transparent;
14856
+ .choice-card { position: relative; display: flex; align-items: flex-start; gap: var(--gap-words-words); min-height: 44px; padding: 14px var(--pad-inside-words); cursor: pointer; -webkit-tap-highlight-color: transparent;
14785
14857
  color: var(--muted); --face-color: var(--well); background: var(--face-color); box-shadow: var(--well-shade); clip-path: var(--cut-shape); transition: --face-color 0.2s, box-shadow 0.2s, color 0.2s; }
14858
+ /* A card with a line on what it means has room for it; one with only its name (an amount) is as tall as its name, never taller than its content. */
14859
+ .choice-card:has(.choice-text) { min-height: 76px; }
14786
14860
  .choice-card > :not(.cut-line):not(.choice-input) { position: relative; z-index: 2; }
14787
14861
  .choice-input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
14788
14862
  .choice-card > .ico { flex: none; margin-top: 1px; color: var(--muted); }
@@ -16778,7 +16852,7 @@ component({
16778
16852
  function notice(options) {
16779
16853
  const isProblem = options.tone === "problem";
16780
16854
  const undo = options.undoAction === void 0 ? "" : html`<form class="notice-undo" method="post" action="${options.undoAction}">${textButton("Undo", { type: "submit" })}</form>`;
16781
- return html`<div class="notice glass ${options.tone}${options.isInPlace ? " in-place" : ""}" role="${isProblem ? "alert" : "status"}" data-notice="${options.tone}"${undo ? raw(" data-notice-undo") : ""} data-glass data-glass-corners="menu">${EDGE}${icon(isProblem ? "warning" : "check")}<span class="notice-text">${options.text}</span>${undo}${isProblem ? cutButton("Close", { type: "button", icon: "close", isLabelHidden: true, isSmall: true }) : ""}</div>`;
16855
+ return html`<div class="notice glass ${options.tone}${options.isInPlace ? " in-place" : ""}" role="${isProblem ? "alert" : "status"}" data-notice="${options.tone}"${undo ? raw(" data-notice-undo") : ""} data-glass data-glass-corners="menu">${EDGE}${icon(isProblem ? "warning" : "check")}<span class="notice-text">${options.text}${options.link ? html` ${textLink(options.link.words, options.link.href)}` : ""}</span>${undo}${isProblem ? cutButton("Close", { type: "button", icon: "close", isLabelHidden: true, isSmall: true }) : ""}</div>`;
16782
16856
  }
16783
16857
  var STARTED_LOW_SCRIPT = `/* panes started low */(function () {
16784
16858
  function place(pane) {
@@ -16855,7 +16929,7 @@ ${touchReach(".notice")}
16855
16929
  examples: [
16856
16930
  { label: "Done", markup: notice({ text: "Saved", tone: "done", isInPlace: true }) },
16857
16931
  { label: "Done, longer", markup: notice({ text: "Version 14 is live", tone: "done", isInPlace: true }) },
16858
- { label: "A problem", markup: notice({ text: "Couldn't save: you're offline. Your changes are kept here.", tone: "problem", isInPlace: true }) },
16932
+ { label: "A problem", markup: notice({ text: "Couldn't save: you're offline. Your changes are kept here.", tone: "problem", link: { words: "Try again", href: "#notice" }, isInPlace: true }) },
16859
16933
  { label: "Done, with Undo", markup: notice({ text: "Removed Ashen Crown from your games", tone: "done", undoAction: "#notice", isInPlace: true }) },
16860
16934
  { label: "Two at once", markup: html`<div class="notice-stack">${notice({ text: "Copied", tone: "done", isInPlace: true })}${notice({ text: "Couldn't reach Zaisma. Check your connection.", tone: "problem", isInPlace: true })}</div>` }
16861
16935
  ]
@@ -23277,7 +23351,7 @@ function securityHeaders(env2, extra = {}, hasTurnstileCheck = false, frames = [
23277
23351
  var PRESENCE_SCRIPT = pageScript("presence", `(()=>{if(!/(^|; )(__Host-)?zaisma_in=1/.test(document.cookie))return;let last=0,off=false;const b=document.body,KEY="zaisma:checked-in-at-ms";
23278
23352
  try{last=Number(sessionStorage.getItem(KEY))||0}catch{}if(last>Date.now())last=0;
23279
23353
  const beat=()=>{if(off||document.visibilityState!=="visible")return;last=Date.now();try{sessionStorage.setItem(KEY,String(last))}catch{}const g=b.dataset.presenceGame;
23280
- fetch("/api/presence",{method:"POST",body:JSON.stringify(g?{game_slug:g}:{}),keepalive:true}).then(r=>{if(r.status===401)off=true}).catch(()=>{})};
23354
+ fetch("/api/presence",{method:"POST",body:JSON.stringify(g?{game_slug:g}:{}),keepalive:true}).then(r=>{if(r.status===401)off=true;else if(r.status===200)r.json().then(a=>dispatchEvent(new CustomEvent("zaisma:presence",{detail:a}))).catch(()=>{})}).catch(()=>{})};
23281
23355
  const start=()=>{beat();setInterval(beat,${PRESENCE_CHECK_IN_MS})};if(b.dataset.presenceGame||Date.now()-last>60000)start();else setTimeout(start,last+${PRESENCE_CHECK_IN_MS}-Date.now());document.addEventListener("visibilitychange",()=>{if(Date.now()-last>60000)beat()});
23282
23356
  new MutationObserver(beat).observe(b,{attributes:true,attributeFilter:["data-presence-game"]});
23283
23357
  addEventListener("pagehide",()=>{const g=b.dataset.presenceGame;if(g&&!off)navigator.sendBeacon("/api/presence",JSON.stringify({left_game_slug:g}))})})();`);
@@ -23873,24 +23947,63 @@ var SAVES_SCRIPT = pageScript("saves", `
23873
23947
  let applying = null; // a developer's change while the game domain puts it in place under the running game
23874
23948
  let isRestoringCopy = false; // the game is going back to an earlier version (Zaisma.saves.restoreCopy)
23875
23949
  let versionId = null; // the game version the game domain says it was served from (its last hello or save), a label on each save
23950
+ let clockOffsetMs = 0; // Zaisma's clock minus this device's, from the last answer's Date header
23951
+ let retryTimer = null; // a failed save waiting to be sent again
23952
+ let retryDelayMs = 0; // how long the last wait was: 30 seconds, doubling to 5 minutes
23953
+ let isAwaitingSignIn = false; // saving paused after a 401 or another account signing in, until the player is signed in
23876
23954
  // A save with nothing in it, exactly as canonicalSave in packages/core/src/saves.ts writes it.
23877
23955
  const EMPTY_SAVE_TEXT = ${JSON.stringify(EMPTY_SAVE_TEXT)};
23878
23956
 
23879
23957
  // A save as one text, the same as canonicalSave on the server: all three keys in this order, localStorage keys sorted.
23880
23958
  const canonical = (save) => {
23959
+ if (!save || !save.local_storage_items) return null;
23881
23960
  const items = {};
23882
23961
  Object.keys(save.local_storage_items).sort().forEach((k) => Object.defineProperty(items, k, { value: save.local_storage_items[k], enumerable: true, writable: true, configurable: true }));
23883
23962
  return JSON.stringify({ save_format_version_number: 1, local_storage_items: items, indexeddb_databases: save.indexeddb_databases });
23884
23963
  };
23885
23964
  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("");
23965
+ // Which save of an account this device last had: { version_number, sha256, slug, marked_at_ms }, per account tag.
23886
23966
  const marker = (tag, value) => {
23887
23967
  try {
23888
23968
  if (value === undefined) return JSON.parse(localStorage.getItem("zaisma:save:" + tag) || "null");
23889
- localStorage.setItem("zaisma:save:" + tag, JSON.stringify(value));
23969
+ localStorage.setItem("zaisma:save:" + tag, JSON.stringify({ ...value, slug: SAVES.slug, marked_at_ms: serverNow() }));
23970
+ } catch { return null; }
23971
+ };
23972
+ // Every account's marker for this game in this browser, with its tag (markers from before slug was kept are left out).
23973
+ const markersHere = () => {
23974
+ const found = [];
23975
+ try {
23976
+ for (let i = 0; i < localStorage.length; i++) {
23977
+ const key = localStorage.key(i);
23978
+ if (!key || !key.startsWith("zaisma:save:")) continue;
23979
+ const value = JSON.parse(localStorage.getItem(key) || "null");
23980
+ if (value && value.slug === SAVES.slug) found.push({ tag: key.slice("zaisma:save:".length), ...value });
23981
+ }
23982
+ } catch {}
23983
+ return found;
23984
+ };
23985
+ // Another account's progress, moved aside on a shared computer until that account signs in here again: { text, played_at_ms }.
23986
+ const asideKey = (tag) => "zaisma:unsynced:" + tag;
23987
+ const putAside = (tag, value) => {
23988
+ try { localStorage.setItem(asideKey(tag), JSON.stringify(value)); return true; } catch { return false; }
23989
+ };
23990
+ const takeAside = (tag) => {
23991
+ try {
23992
+ const value = JSON.parse(localStorage.getItem(asideKey(tag)) || "null");
23993
+ localStorage.removeItem(asideKey(tag));
23994
+ return value && typeof value.text === "string" ? value : null;
23890
23995
  } catch { return null; }
23891
23996
  };
23997
+ // Zaisma's clock: this device's, put right by the offset each answer's Date header shows.
23998
+ const serverNow = () => Date.now() + clockOffsetMs;
23999
+ // Every call to the account: learns Zaisma's clock from the answer.
24000
+ const ask = (path, init = {}) => fetch(path, { credentials: "same-origin", ...init }).then((res) => {
24001
+ const answeredAtMs = Date.parse((res.headers && res.headers.get && res.headers.get("date")) || "");
24002
+ if (answeredAtMs) clockOffsetMs = answeredAtMs - Date.now();
24003
+ return res;
24004
+ });
23892
24005
  const ago = (savedAt) => {
23893
- const m = Math.round((Date.now() - Date.parse(savedAt)) / 60000);
24006
+ const m = Math.round((serverNow() - Date.parse(savedAt)) / 60000);
23894
24007
  return m < 1 ? "just now" : m < 60 ? m + " min ago" : m < 1440 ? Math.round(m / 60) + " h ago" : Math.round(m / 1440) + " days ago";
23895
24008
  };
23896
24009
  // Where progress goes, in the Keep your progress card.
@@ -23909,11 +24022,10 @@ var SAVES_SCRIPT = pageScript("saves", `
23909
24022
  }
23910
24023
  // The account's progress wins: keep this device's as an earlier version on the account, dated when this device
23911
24024
  // last played it (played_at; null when that isn't known). Answers the copy's ID, or null.
23912
- const keepDevice = (text) => {
23913
- if (!text || text === EMPTY_SAVE_TEXT) return Promise.resolve(null);
23914
- const p = played();
23915
- 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 };
23916
- return fetch(SAVES.api, { method: "PUT", body: JSON.stringify(body), credentials: "same-origin" })
24025
+ const keepDevice = (text, playedAtMs = (played() || {}).played_at_ms) => {
24026
+ if (!text || text === EMPTY_SAVE_TEXT || !cloud || cloud instanceof Promise) return Promise.resolve(null);
24027
+ const body = { tag: cloud.tag, keep: true, save_json: text, version_id: versionId, played_at: playedAtMs ? new Date(playedAtMs).toISOString() : null };
24028
+ return ask(SAVES.api, { method: "PUT", body: JSON.stringify(body) })
23917
24029
  .then((r) => (r.ok ? r.json() : null))
23918
24030
  .then((out) => (out && out.save_copy_id) || null)
23919
24031
  .catch(() => null);
@@ -23925,25 +24037,22 @@ var SAVES_SCRIPT = pageScript("saves", `
23925
24037
  localStorage.setItem(playedKey, JSON.stringify(value));
23926
24038
  } catch { return null; }
23927
24039
  };
23928
- // The game sent its save: if it changed, this device played just now.
24040
+ // The game sent its save: if it changed, this device played just now (on Zaisma's clock).
23929
24041
  async function notePlayed(text) {
23930
24042
  const hash = await sha(text);
23931
24043
  const was = played();
23932
24044
  if (!was) played({ sha256: hash, played_at_ms: null });
23933
- else if (was.sha256 !== hash) played({ sha256: hash, played_at_ms: Date.now() });
24045
+ else if (was.sha256 !== hash) played({ sha256: hash, played_at_ms: serverNow() });
23934
24046
  }
23935
24047
  // Whether this device's progress was played after the account's was saved. Not known: the account's wins.
23936
- const deviceIsNewer = (savedAt) => {
23937
- const p = played();
23938
- return Boolean(p && p.played_at_ms && savedAt && p.played_at_ms > Date.parse(savedAt));
23939
- };
24048
+ const deviceIsNewer = (savedAt, playedAtMs = (played() || {}).played_at_ms) => Boolean(playedAtMs && savedAt && playedAtMs > Date.parse(savedAt));
23940
24049
  const tell = (msg) => frame && frame.contentWindow && frame.contentWindow.postMessage({ type: "zaisma:saves", ...msg }, SAVES.frameOrigin);
23941
24050
  // To the game itself (the SDK), not the game domain's saves script.
23942
24051
  const toGame = (msg) => frame && frame.contentWindow && frame.contentWindow.postMessage(msg, SAVES.frameOrigin);
23943
24052
  const copyFields = (copy) => ({ id: copy.id, savedAt: copy.saved_at, copyReason: copy.copy_reason, sizeBytes: copy.size_bytes, isCurrentSave: copy.is_current_save, versionId: copy.version_id ?? null });
23944
24053
  async function copyById(saveCopyId) {
23945
24054
  if (!saveCopyId) return null;
23946
- const res = await fetch(SAVES.api + "/copies", { credentials: "same-origin" }).catch(() => null);
24055
+ const res = await ask(SAVES.api + "/copies").catch(() => null);
23947
24056
  const out = res && res.ok ? await res.json().catch(() => null) : null;
23948
24057
  const copy = out && Array.isArray(out.save_copies) ? out.save_copies.find((each) => each.id === saveCopyId) : null;
23949
24058
  return copy ? copyFields(copy) : null;
@@ -23964,7 +24073,7 @@ var SAVES_SCRIPT = pageScript("saves", `
23964
24073
  // One retry for a passing hiccup; a signed-out player or a game without saves just plays.
23965
24074
  async function load(retry = true) {
23966
24075
  try {
23967
- const res = await fetch(SAVES.api, { credentials: "same-origin" });
24076
+ const res = await ask(SAVES.api);
23968
24077
  if (res.ok) return await res.json();
23969
24078
  if (res.status === 401 || res.status === 404 || !retry) return null;
23970
24079
  } catch { if (!retry) return null; }
@@ -23984,6 +24093,9 @@ var SAVES_SCRIPT = pageScript("saves", `
23984
24093
  pendingConflict = null; // A conflict from this reload comes after it (conflicted).
23985
24094
  if (applying) { clearTimeout(applying.timer); applying = null; }
23986
24095
  queued = null; // From the game as it was; the reloaded game sends its own.
24096
+ clearTimeout(retryTimer);
24097
+ retryTimer = null;
24098
+ retryDelayMs = 0;
23987
24099
  cloud = load();
23988
24100
  openAt("_zaisma/restore#" + encodeURIComponent(SAVES.query));
23989
24101
  clearTimeout(fallback);
@@ -24094,8 +24206,8 @@ var SAVES_SCRIPT = pageScript("saves", `
24094
24206
  const c = await load();
24095
24207
  if (!c) return say("Couldn't reach your saved progress, so this visit won't save to your account.");
24096
24208
  cloud = c;
24097
- if (!c.version_number) {
24098
- // A new account: whatever this device has becomes the account's save.
24209
+ if (!c.version_number || !c.save_data) {
24210
+ // A new account (or one whose save has no data): whatever this device has becomes the account's save.
24099
24211
  paused = false;
24100
24212
  synced = null;
24101
24213
  welcome = true;
@@ -24108,6 +24220,10 @@ var SAVES_SCRIPT = pageScript("saves", `
24108
24220
  tell({ step: "sync", text: guestText });
24109
24221
  say("Progress saves to your account.");
24110
24222
  pop("Your progress now saves to your account.");
24223
+ } else if (!guestText || guestText === EMPTY_SAVE_TEXT) {
24224
+ // Nothing on this device: the game reopens with the account's progress.
24225
+ forceCloud = true;
24226
+ openThroughRestore();
24111
24227
  } else if (deviceIsNewer(c.saved_at)) {
24112
24228
  // Both have progress, and this device's was played more recently: it replaces the account's, which is kept
24113
24229
  // as an earlier version (the game hears the conflict once that save is stored).
@@ -24126,9 +24242,37 @@ var SAVES_SCRIPT = pageScript("saves", `
24126
24242
  pop("You're signed in. Your account's progress in this game is newer, so the game reloaded with it.");
24127
24243
  }
24128
24244
  }
24129
- addEventListener("focus", joined);
24130
- document.addEventListener("visibilitychange", joined);
24131
- try { new BroadcastChannel("zaisma").onmessage = (e) => e.data === "signed-in" && joined(); } catch {}
24245
+ // Saving paused after a 401 or another account signing in: once the account answers again, the game reopens
24246
+ // through the restore page as whoever is signed in now, which checks whose progress is on this device.
24247
+ async function signedInAgain() {
24248
+ if (!isAwaitingSignIn || guest || !signedIn()) return;
24249
+ const res = await ask(SAVES.api + "?version").catch(() => null);
24250
+ if (!isAwaitingSignIn || !res || !res.ok) return;
24251
+ isAwaitingSignIn = false;
24252
+ openThroughRestore();
24253
+ }
24254
+ function awaitSignIn(isOtherAccount) {
24255
+ paused = true;
24256
+ queued = null;
24257
+ tell({ step: "pause" });
24258
+ isAwaitingSignIn = true;
24259
+ if (isOtherAccount) signedInAgain();
24260
+ else say("Sign in again to keep saving to your account.");
24261
+ }
24262
+ const heardSignIn = () => { joined(); signedInAgain(); };
24263
+ addEventListener("focus", heardSignIn);
24264
+ document.addEventListener("visibilitychange", heardSignIn);
24265
+ try { new BroadcastChannel("zaisma").onmessage = (e) => e.data === "signed-in" && heardSignIn(); } catch {}
24266
+
24267
+ // Whose this device's progress is, when it isn't this account's (tag): another account's synced save (isSynced),
24268
+ // or progress never synced, which belongs to the account that played here last. Null: this account's, or no one's.
24269
+ function progressOwner(localHash, tag) {
24270
+ const markers = markersHere();
24271
+ const synced = markers.find((each) => each.tag !== tag && each.sha256 === localHash);
24272
+ if (synced) return { tag: synced.tag, isSynced: true };
24273
+ const last = markers.reduce((newest, each) => (!newest || (each.marked_at_ms || 0) > (newest.marked_at_ms || 0) ? each : newest), null);
24274
+ return last && last.tag !== tag ? { tag: last.tag, isSynced: false } : null;
24275
+ }
24132
24276
 
24133
24277
  async function restore(text, broken) {
24134
24278
  clearTimeout(fallback);
@@ -24143,63 +24287,98 @@ var SAVES_SCRIPT = pageScript("saves", `
24143
24287
  say("Couldn't reach your saved progress, so this visit won't save to your account.");
24144
24288
  return tell({ step: "restore", action: "keep" });
24145
24289
  }
24146
- const localHash = await sha(text);
24147
- const empty = text === EMPTY_SAVE_TEXT;
24290
+ // An answer without its data counts as no save on the account; the next save still starts from its version.
24291
+ const hasCloudSave = Boolean(c.version_number && c.save_data);
24292
+ let localHash = await sha(text);
24148
24293
  // The first time this page sees this device's save: from now on a change to it counts as playing.
24149
24294
  if (!played()) played({ sha256: localHash, played_at_ms: null });
24150
24295
  // A marker from before version_number and sha256 matches neither, so the most recently played wins.
24151
24296
  const mark = marker(c.tag);
24297
+ // A shared computer: progress here that's another account's stays that account's, and this account starts from its own.
24298
+ let deviceText = text;
24299
+ let isStorageOthers = false;
24300
+ if (text !== EMPTY_SAVE_TEXT && localHash !== c.sha256 && !(mark && mark.sha256 === localHash)) {
24301
+ const owner = progressOwner(localHash, c.tag);
24302
+ if (owner) {
24303
+ if (!owner.isSynced && !putAside(owner.tag, { text, played_at_ms: (played() || {}).played_at_ms || null })) {
24304
+ say("Another account's progress is on this device, so this visit won't save to your account.");
24305
+ return tell({ step: "restore", action: "keep" });
24306
+ }
24307
+ isStorageOthers = true;
24308
+ deviceText = EMPTY_SAVE_TEXT;
24309
+ }
24310
+ }
24311
+ // This account's own progress, moved aside while another account played here: it's this device's progress again.
24312
+ const aside = takeAside(c.tag);
24313
+ let devicePlayedAtMs = (played() || {}).played_at_ms;
24314
+ if (aside) {
24315
+ deviceText = aside.text;
24316
+ devicePlayedAtMs = aside.played_at_ms;
24317
+ localHash = await sha(aside.text);
24318
+ }
24319
+ const empty = deviceText === EMPTY_SAVE_TEXT;
24152
24320
  let use;
24153
- if (forceCloud && c.version_number) {
24321
+ if (forceCloud && hasCloudSave) {
24154
24322
  use = "cloud";
24155
24323
  // This device's progress since its last save (the account keeps one copy of each progress).
24156
24324
  // Unchanged since it last synced, it was the account's own save, so there's nothing to keep; nor when it was kept before the reload.
24157
- if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) && text !== keptBeforeReload) keepDevice(text);
24325
+ if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) && deviceText !== keptBeforeReload) keepDevice(deviceText, devicePlayedAtMs);
24158
24326
  }
24159
- else if (!c.version_number) use = "local";
24327
+ else if (!hasCloudSave) use = "local";
24160
24328
  else if (empty || localHash === c.sha256) use = "cloud";
24161
24329
  else if (mark && mark.version_number === c.version_number) use = "local";
24162
24330
  else if (mark && mark.sha256 === localHash) use = "cloud";
24163
24331
  else {
24164
24332
  // Both played on since they last matched: the progress played most recently wins, the other is kept.
24165
- use = deviceIsNewer(c.saved_at) ? "local" : "cloud";
24166
- if (use === "cloud") conflicted("account", keepDevice(text));
24333
+ use = deviceIsNewer(c.saved_at, devicePlayedAtMs) ? "local" : "cloud";
24334
+ if (use === "cloud") conflicted("account", keepDevice(deviceText, devicePlayedAtMs));
24167
24335
  else replacing = true; // The game hears the conflict once its save replaced the account's (store).
24168
24336
  }
24169
24337
  forceCloud = false;
24170
24338
  keptBeforeReload = null;
24171
- if (use === "cloud" && c.version_number) {
24172
- restoring = c;
24173
- tell({ step: "restore", action: "use-cloud", data: c.save_data });
24339
+ // What goes in the game's storage: the account's save, this account's moved-aside progress, or nothing in place of another's.
24340
+ 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;
24341
+ if (putting) {
24342
+ restoring = { c, ...putting };
24343
+ tell({ step: "restore", action: "use-cloud", data: putting.saveData });
24174
24344
  } else {
24175
24345
  paused = false;
24176
24346
  // Keep this device's save; the game sends it as soon as it starts.
24177
- synced = empty && !c.version_number ? text : null;
24347
+ synced = empty && !hasCloudSave ? text : null;
24178
24348
  tell({ step: "restore", action: "keep" });
24179
24349
  say("Progress saves to your account.");
24180
24350
  }
24181
24351
  }
24182
24352
 
24183
- // The game domain put the account's save in place (or couldn't). Only then does saving start.
24353
+ // The game domain put a save in place (or couldn't). Only then does saving start.
24184
24354
  function restored(ok) {
24185
- const c = restoring;
24355
+ const r = restoring;
24186
24356
  restoring = null;
24357
+ if (!r) return;
24187
24358
  if (!ok) {
24359
+ if (r.aside) putAside(r.c.tag, r.aside);
24188
24360
  say("Couldn't put your account's progress on this device, so this visit won't save to your account.");
24189
24361
  return;
24190
24362
  }
24191
- synced = canonical(c.save_data);
24192
- marker(c.tag, { version_number: c.version_number, sha256: c.sha256 });
24193
- played({ sha256: c.sha256, played_at_ms: Date.parse(c.saved_at) || null });
24363
+ const c = r.c;
24364
+ const isAccountSave = r.saveData === c.save_data;
24365
+ // This account's own moved-aside progress back in place: the game sends it, and it saves from the account's version.
24366
+ synced = r.isDeviceProgress ? null : canonical(r.saveData);
24194
24367
  paused = false;
24195
- say("Progress saves to your account. Last saved " + ago(c.saved_at) + ".");
24368
+ if (isAccountSave) {
24369
+ marker(c.tag, { version_number: c.version_number, sha256: c.sha256 });
24370
+ played({ sha256: c.sha256, played_at_ms: Date.parse(c.saved_at) || null });
24371
+ } else {
24372
+ sha(canonical(r.saveData)).then((hash) => played({ sha256: hash, played_at_ms: r.isDeviceProgress ? r.aside.played_at_ms || null : null }));
24373
+ }
24374
+ say(isAccountSave ? "Progress saves to your account. Last saved " + ago(c.saved_at) + "." : "Progress saves to your account.");
24196
24375
  }
24197
24376
 
24198
24377
  async function store(text) {
24199
24378
  if (paused) return;
24200
- if (saving) { queued = text; return; }
24201
- const body = JSON.stringify({ base_version_number: cloud.version_number, save_json: text, version_id: versionId, ...(replacing ? { replace: true } : {}) });
24202
- saving = fetch(SAVES.api, { method: "PUT", body, credentials: "same-origin", keepalive: body.length < 60000 }).catch(() => null);
24379
+ if (saving || retryTimer) { queued = text; return; }
24380
+ const body = JSON.stringify({ tag: cloud.tag, base_version_number: cloud.version_number, save_json: text, version_id: versionId, ...(replacing ? { replace: true } : {}) });
24381
+ saving = ask(SAVES.api, { method: "PUT", body, keepalive: body.length < 60000 }).catch(() => null);
24203
24382
  const opened = openCount;
24204
24383
  const res = await saving;
24205
24384
  saving = null;
@@ -24208,9 +24387,13 @@ var SAVES_SCRIPT = pageScript("saves", `
24208
24387
  // Going back to an earlier version: a save that went through still moved the account on; anything else waits for that.
24209
24388
  if (isRestoringCopy && !(res && res.ok)) return;
24210
24389
  const out = res ? await res.json().catch(() => ({})) : {};
24211
- if (res && res.ok) {
24212
- if (replacing) conflicted("this_device", Promise.resolve(out.kept_save_copy_id));
24390
+ // A 409 for exactly this save: it landed before (its answer was lost), so it's saved.
24391
+ const isSavedBefore = res && res.status === 409 && out.error !== "account_changed" && out.sha256 && out.sha256 === (await sha(text));
24392
+ if (opened !== openCount) return;
24393
+ if (res && (res.ok || isSavedBefore)) {
24394
+ if (replacing && res.ok) conflicted("this_device", Promise.resolve(out.kept_save_copy_id));
24213
24395
  replacing = false;
24396
+ retryDelayMs = 0;
24214
24397
  cloud.version_number = out.version_number;
24215
24398
  cloud.sha256 = out.sha256;
24216
24399
  synced = text;
@@ -24218,17 +24401,23 @@ var SAVES_SCRIPT = pageScript("saves", `
24218
24401
  say("Progress saves to your account. Last saved just now.");
24219
24402
  if (welcome) pop("Your progress now saves to your account.");
24220
24403
  welcome = false;
24404
+ } else if (res && res.status === 409 && out.error === "account_changed") {
24405
+ // Another account signed in on this browser: this page's progress never goes to it.
24406
+ return awaitSignIn(true);
24221
24407
  } else if (res && res.status === 409) {
24222
24408
  paused = true;
24223
24409
  tell({ step: "pause" });
24410
+ // The newest progress this device has; a save waiting behind this one is from before whatever happens next.
24411
+ const newest = queued ?? text;
24412
+ queued = null;
24224
24413
  // The developer's change always wins, and so does another device's progress played after this device's:
24225
24414
  // reload the game with it, keeping this device's progress. Otherwise this device's, played just now, replaces it.
24226
24415
  // A developer's change is never a conflict, and a game that listens for it takes it in place.
24227
24416
  if (out.changed_by_kind === "developer" && listening.has("developerChange")) {
24228
- takeDeveloperChange(text);
24417
+ takeDeveloperChange(newest);
24229
24418
  } else if (out.changed_by_kind === "developer" || !deviceIsNewer(out.saved_at)) {
24230
- const keeping = keepDevice(text);
24231
- keptBeforeReload = text;
24419
+ const keeping = keepDevice(newest);
24420
+ keptBeforeReload = newest;
24232
24421
  forceCloud = true;
24233
24422
  openThroughRestore();
24234
24423
  if (out.changed_by_kind !== "developer") conflicted("account", keeping);
@@ -24243,14 +24432,27 @@ var SAVES_SCRIPT = pageScript("saves", `
24243
24432
  overLimit();
24244
24433
  return;
24245
24434
  } else if (res && res.status === 401) {
24435
+ return awaitSignIn(false);
24436
+ } else if (res && res.status === 404) {
24437
+ // The game stopped saving to accounts (or isn't live): nothing more goes for this visit.
24246
24438
  paused = true;
24439
+ queued = null;
24247
24440
  tell({ step: "pause" });
24248
- say("Sign in again to keep saving to your account.");
24441
+ say("This game doesn't save to your account right now, so its progress stays on this device.");
24249
24442
  return;
24250
24443
  } else {
24251
- // Let the game send this save again on its next check.
24444
+ // Offline, too often or Zaisma had a problem: send the newest save again later, waiting longer each time.
24445
+ queued = queued ?? text;
24446
+ retryDelayMs = Math.min(retryDelayMs ? retryDelayMs * 2 : 30000, 300000);
24447
+ const retryAfterMs = res && res.status === 429 ? Number((res.headers && res.headers.get && res.headers.get("retry-after")) || 0) * 1000 : 0;
24252
24448
  say("Couldn't save to your account. Trying again soon.");
24253
- tell({ step: "sync", text: synced });
24449
+ retryTimer = setTimeout(() => {
24450
+ retryTimer = null;
24451
+ const next = queued;
24452
+ queued = null;
24453
+ if (next !== null && opened === openCount) store(next);
24454
+ }, Math.max(retryDelayMs, retryAfterMs || 0));
24455
+ return;
24254
24456
  }
24255
24457
  if (queued !== null) { const next = queued; queued = null; store(next); }
24256
24458
  }
@@ -24260,7 +24462,7 @@ var SAVES_SCRIPT = pageScript("saves", `
24260
24462
  // asked for, so this costs one small read.
24261
24463
  document.addEventListener("visibilitychange", async () => {
24262
24464
  if (guest || paused || saving || document.visibilityState !== "visible" || !cloud || cloud instanceof Promise) return;
24263
- const res = await fetch(SAVES.api + "?version", { credentials: "same-origin" }).catch(() => null);
24465
+ const res = await ask(SAVES.api + "?version").catch(() => null);
24264
24466
  const out = res && res.ok ? await res.json().catch(() => null) : null;
24265
24467
  if (!out || out.version_number === cloud.version_number || paused || saving) return;
24266
24468
  if (out.changed_by_kind !== "developer" && deviceIsNewer(out.saved_at)) return;
@@ -24277,6 +24479,7 @@ var SAVES_SCRIPT = pageScript("saves", `
24277
24479
  // text is this device's progress since its last save, kept as an earlier version (null: nothing to keep).
24278
24480
  async function takeDeveloperChange(text) {
24279
24481
  const opened = openCount;
24482
+ queued = null;
24280
24483
  const keeping = keepDevice(text);
24281
24484
  const reload = () => {
24282
24485
  keptBeforeReload = text;
@@ -24293,6 +24496,7 @@ var SAVES_SCRIPT = pageScript("saves", `
24293
24496
  const taking = applying;
24294
24497
  if (!taking) return;
24295
24498
  applying = null;
24499
+ queued = null; // From the game before the change: the game sends its own after it.
24296
24500
  clearTimeout(taking.timer);
24297
24501
  if (!ok) return taking.reload();
24298
24502
  const c = taking.c;
@@ -24330,7 +24534,7 @@ var SAVES_SCRIPT = pageScript("saves", `
24330
24534
  reply({ status: "asking" });
24331
24535
  if (guest || !signedIn()) return fail("account_needed", "Earlier versions are kept for players signed in to Zaisma: a guest's progress stays in this browser. Ask the player to sign in first (Zaisma.player.signIn).");
24332
24536
  if (m.request === "list_copies") {
24333
- const res = await fetch(SAVES.api + "/copies", { credentials: "same-origin" }).catch(() => null);
24537
+ const res = await ask(SAVES.api + "/copies").catch(() => null);
24334
24538
  const out = res && res.ok ? await res.json().catch(() => null) : null;
24335
24539
  if (!out || !Array.isArray(out.save_copies)) return failed();
24336
24540
  return reply({ answer: out.save_copies.map(copyFields) });
@@ -24339,7 +24543,7 @@ var SAVES_SCRIPT = pageScript("saves", `
24339
24543
  const copyPath = SAVES.api + "/copies/" + m.saveCopyId;
24340
24544
  const noCopy = () => fail("no_save_copy", "The player has no earlier version " + m.saveCopyId + " in this game: Zaisma.saves.listCopies() lists the ones kept.");
24341
24545
  if (m.request === "get_copy") {
24342
- const res = await fetch(copyPath, { credentials: "same-origin" }).catch(() => null);
24546
+ const res = await ask(copyPath).catch(() => null);
24343
24547
  if (res && res.status === 404) return noCopy();
24344
24548
  const out = res && res.ok ? await res.json().catch(() => null) : null;
24345
24549
  if (!out || !out.save_copy) return failed();
@@ -24351,7 +24555,7 @@ var SAVES_SCRIPT = pageScript("saves", `
24351
24555
  isRestoringCopy = true;
24352
24556
  queued = null;
24353
24557
  tell({ step: "pause" });
24354
- const res = await fetch(copyPath + "/restore", { method: "POST", credentials: "same-origin" }).catch(() => null);
24558
+ const res = await ask(copyPath + "/restore", { method: "POST", body: JSON.stringify({ tag: cloud && !(cloud instanceof Promise) ? cloud.tag : null }) }).catch(() => null);
24355
24559
  isRestoringCopy = false;
24356
24560
  if (res && res.ok) {
24357
24561
  reply({ answer: null });
@@ -24360,6 +24564,11 @@ var SAVES_SCRIPT = pageScript("saves", `
24360
24564
  openThroughRestore();
24361
24565
  return;
24362
24566
  }
24567
+ // Another account signed in on this browser: the game reopens as that account.
24568
+ if (res && res.status === 409) {
24569
+ awaitSignIn(true);
24570
+ return failed();
24571
+ }
24363
24572
  paused = wasPaused;
24364
24573
  if (!wasPaused) tell({ step: "sync", text: synced });
24365
24574
  if (res && res.status === 404) return noCopy();
@@ -43438,7 +43647,7 @@ var PICTURE_FIELDS = {
43438
43647
  },
43439
43648
  overlay: {
43440
43649
  type: "string",
43441
- description: `Covers only ("" for screenshots): what is laid over the picture instead of being part of the scene: a button, menu, shop panel, row of rewards, badge, sticker, starburst, icon bubble, or an arrow or circle pointing at something. A few words naming each. Lighting, sparks and effects in the scene aren't overlays. "" when there's nothing.`
43650
+ description: `Covers and backgrounds ("" for screenshots): what is laid over the picture instead of being part of the scene: a button, menu, shop panel, row of rewards, badge, sticker, starburst, icon bubble, or an arrow or circle pointing at something. A few words naming each. Lighting, sparks and effects in the scene aren't overlays. "" when there's nothing.`
43442
43651
  },
43443
43652
  fake_reward: {
43444
43653
  type: "string",
@@ -43446,7 +43655,7 @@ var PICTURE_FIELDS = {
43446
43655
  },
43447
43656
  famous_name: {
43448
43657
  type: "string",
43449
- description: `Covers only ("" for screenshots): a famous character, logo or name from a film, show, game, brand or sports club that a typical teenager would name at a glance, like Mario, Naruto, Goku, Lightning McQueen, the Batmobile, Pikachu, a football club's crest, the Roblox logo or Robux. Name it. Generic blocky, pixel or cartoon figures, and lookalikes nobody would name for sure, are not famous: when unsure, "". "" when there's none.`
43658
+ description: `Covers and backgrounds ("" for screenshots): a famous character, logo or name from a film, show, game, brand or sports club that a typical teenager would name at a glance, like Mario, Naruto, Goku, Lightning McQueen, the Batmobile, Pikachu, a football club's crest, the Roblox logo or Robux. Name it. Generic blocky, pixel or cartoon figures, and lookalikes nobody would name for sure, are not famous: when unsure, "". "" when there's none.`
43450
43659
  },
43451
43660
  main_subject: {
43452
43661
  type: "string",
@@ -43462,7 +43671,7 @@ var PICTURE_FIELDS = {
43462
43671
  },
43463
43672
  background_text: {
43464
43673
  type: "string",
43465
- description: `Backgrounds only ("" for covers and screenshots): every word, number, symbol or logo written on the picture, the game's own name and logo included, quoted exactly or named; anything laid over it instead of being part of the scene, like a button, badge or sticker; and any famous character, logo or name a typical teenager would name at a glance. Separated by " / ". Words that are part of the scene (a sign on a building, a neon shop sign, writing on a shirt) don't count. "" when there's none.`
43674
+ description: `Backgrounds only ("" for covers and screenshots): every word, number, symbol or logo written on the picture, the game's own name and logo included, quoted exactly or named, separated by " / ". Overlays go in overlay and famous characters, logos and names in famous_name. Words that are part of the scene (a sign on a building, a neon shop sign, writing on a shirt) don't count. "" when there's none.`
43466
43675
  },
43467
43676
  has_safety_concern: {
43468
43677
  type: "boolean",
@@ -43523,6 +43732,9 @@ function isLocalPreview(env2) {
43523
43732
  return siteHost === "localhost" || siteHost === "127.0.0.1" || siteHost.endsWith(".ts.net");
43524
43733
  }
43525
43734
 
43735
+ // ../../apps/site/src/credit-api.ts
43736
+ var PENDING_REFUND_FIRST_RETRY_MS = 5 * 6e4;
43737
+
43526
43738
  // ../../apps/site/src/stripe-account-api.ts
43527
43739
  var CONNECT_STATE_DURATION_MS = 10 * 6e4;
43528
43740
 
@@ -44077,6 +44289,13 @@ function playersSheet(options) {
44077
44289
  const words3 = (markup) => ({ kind: "words", markup });
44078
44290
  const body = stack({
44079
44291
  items: [
44292
+ // Friend requests waiting for the player, with Accept and Turn down (friend-requests.ts brings them).
44293
+ {
44294
+ kind: "block",
44295
+ id: "players-requests",
44296
+ isHidden: true,
44297
+ markup: stack({ items: [words3(html`<h3>Friend requests</h3>`), { kind: "block", markup: listRows({ whenEmpty: html`<p hidden></p>`, rows: [], id: "players-requests-list", label: "Friend requests" }) }] })
44298
+ },
44080
44299
  words3(html`<h3 id="players-room-title">In this room<span id="players-count"></span></h3>`),
44081
44300
  // While nobody's listed, the note says why (the page's script writes it): the list's empty state.
44082
44301
  { kind: "block", markup: listRows({ whenEmpty: html`<p id="players-note" hidden></p>`, rows: [], id: "players-list", label: "In this room" }) },
@@ -44093,6 +44312,10 @@ function playersSheet(options) {
44093
44312
  const playerBody = stack({
44094
44313
  items: [
44095
44314
  { kind: "control", markup: cutButton("Profile", { href: "/", id: "players-player-profile", isFullWidth: true, isHidden: true }) },
44315
+ // Only when they take friend requests from this player (friend-requests.ts asks), with no hint why otherwise.
44316
+ { kind: "control", markup: cutButton("Add friend", { type: "button", id: "players-player-friend", isFullWidth: true, isHidden: true }) },
44317
+ words3(html`<p id="players-player-sent" hidden>Friend request sent.</p>`),
44318
+ words3(html`<p id="players-player-sign-in" hidden>${textLink("Sign in", options.signInPath)} to add friends.</p>`),
44096
44319
  { kind: "control", markup: cutButton("Block", { type: "button", id: "players-player-block", isFullWidth: true }) },
44097
44320
  { kind: "control", markup: cutButton("Report", { type: "button", id: "players-player-report", isFullWidth: true }) }
44098
44321
  ]
@@ -44102,6 +44325,7 @@ function playersSheet(options) {
44102
44325
  return html`${sheet({ id: "players", title: "Players", body, closeLabel: "Close", placement: "foot" })}
44103
44326
  ${sheet({ id: "players-player", title: "Player", body: playerBody, closeLabel: "Close", placement: "foot" })}
44104
44327
  <template id="players-row">${rowTemplate}</template>
44328
+ <template id="players-request-row">${listRows({ whenEmpty: html`<p hidden></p>`, rows: [{ lead: pictureFrame("?", "md"), title: "?", action: [runButton("Accept", { ...rowButton, dataAttributes: { "request-action": "accept" } }), cutButton("Turn down", { ...rowButton, dataAttributes: { "request-action": "turn-down" } })] }] })}</template>
44105
44329
  ${pageData("players-data", { gameOrigin: options.gameOrigin, slug: options.slug, hasZaismaRooms: options.hasZaismaRooms, canSignIn: options.canSignIn })}
44106
44330
  ${scriptTags(PLAYERS_SHEET_SCRIPT)}`;
44107
44331
  }
@@ -44140,6 +44364,33 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44140
44364
  }
44141
44365
  // A button's words: the last span in its face (cutButton).
44142
44366
  const wordsOf = (button) => button.querySelector(".face > span:last-child");
44367
+ // Friend requests waiting for this player, and the ones they sent from here this session.
44368
+ let requests = [];
44369
+ const sentTo = new Set();
44370
+ addEventListener("zaisma:friend-requests-waiting", (e) => { requests = e.detail && Array.isArray(e.detail.requests) ? e.detail.requests : []; if (sheet.open) render(); });
44371
+ addEventListener("zaisma:friend-request-sent", (e) => { if (e.detail) sentTo.add(e.detail.id); });
44372
+ // Friends now (an accept either way): the row says Friend, and the menu has nothing to add.
44373
+ addEventListener("zaisma:friend-added", (e) => {
44374
+ if (!e.detail) return;
44375
+ sentTo.delete(e.detail.id);
44376
+ const known = accounts.get(e.detail.id);
44377
+ if (known && !known.isFriend) { known.isFriend = true; if (sheet.open) render(); }
44378
+ });
44379
+ function renderRequests() {
44380
+ const list = $("players-requests-list");
44381
+ list.textContent = "";
44382
+ $("players-requests").hidden = !requests.length;
44383
+ list.hidden = !requests.length;
44384
+ requests.slice(0, 20).forEach((waiting) => {
44385
+ const item = $("players-request-row").content.querySelector("li").cloneNode(true);
44386
+ paint(item.querySelector(".av"), waiting.name);
44387
+ item.querySelector(".list-row-title").textContent = waiting.name;
44388
+ item.querySelectorAll("[data-request-action]").forEach((button) => button.addEventListener("click", () => {
44389
+ dispatchEvent(new CustomEvent("zaisma:answer-friend-request", { detail: { id: waiting.player_id, verb: button.dataset.requestAction } }));
44390
+ }));
44391
+ list.appendChild(item);
44392
+ });
44393
+ }
44143
44394
 
44144
44395
  addEventListener("message", (e) => {
44145
44396
  const f = frame();
@@ -44182,6 +44433,7 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44182
44433
  }
44183
44434
 
44184
44435
  function render() {
44436
+ renderRequests();
44185
44437
  $("players-count").textContent = room ? " · " + room.players.length : "";
44186
44438
  const list = $("players-list");
44187
44439
  list.textContent = "";
@@ -44230,11 +44482,43 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44230
44482
  profile.rel = "noopener";
44231
44483
  }
44232
44484
  $("players-player-block").hidden = isBlocked;
44485
+ showFriendChoice(other, known, isBlocked);
44233
44486
  if (!playerSheet.open) playerSheet.showModal();
44234
44487
  });
44235
44488
  return item;
44236
44489
  }
44237
44490
 
44491
+ // Add friend, Friend request sent, or Sign in to add friends: asked of Zaisma as the player's sheet opens.
44492
+ function showFriendChoice(other, known, isBlocked) {
44493
+ const add = $("players-player-friend"), sent = $("players-player-sent"), signIn = $("players-player-sign-in");
44494
+ add.hidden = true;
44495
+ sent.hidden = true;
44496
+ signIn.hidden = !(other.kind === "account" && !isSignedIn() && CAN_SIGN_IN);
44497
+ if (!known || known.isFriend || isBlocked || !isSignedIn()) return;
44498
+ // Sent from here: say so at once, then ask Zaisma, which knows when it was accepted since.
44499
+ sent.hidden = !sentTo.has(other.id);
44500
+ const asking = other.id;
44501
+ fetch("/friends/requests/about/" + other.id)
44502
+ .then((res) => (res.ok ? res.json() : null))
44503
+ .then((about) => {
44504
+ if (!about || !chosen || chosen.id !== asking) return;
44505
+ if (about.is_friend) {
44506
+ sentTo.delete(asking);
44507
+ known.isFriend = true;
44508
+ render();
44509
+ }
44510
+ add.hidden = !about.can_send;
44511
+ sent.hidden = !about.has_sent;
44512
+ })
44513
+ .catch(() => {});
44514
+ }
44515
+ $("players-player-friend").addEventListener("click", () => {
44516
+ if (!chosen) return;
44517
+ const playerId = chosen.id;
44518
+ closeBoth();
44519
+ dispatchEvent(new CustomEvent("zaisma:ask-friend-request", { detail: { id: playerId } }));
44520
+ });
44521
+
44238
44522
  function closeBoth() {
44239
44523
  if (playerSheet.open) playerSheet.close();
44240
44524
  if (sheet.open) sheet.close();
@@ -44267,7 +44551,7 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44267
44551
 
44268
44552
  // The bar's button opens it (SHEET_SCRIPT does too, by its aria-controls; this runs first and fills it in).
44269
44553
  opener.addEventListener("click", () => {
44270
- ["report", "block"].forEach((id) => { const other = $(id); if (other && other.open) other.close(); });
44554
+ ["report", "block", "friend-request"].forEach((id) => { const other = $(id); if (other && other.open) other.close(); });
44271
44555
  render();
44272
44556
  loadFriends();
44273
44557
  if (!sheet.open) sheet.showModal();
@@ -44277,6 +44561,238 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44277
44561
  update();
44278
44562
  })();`);
44279
44563
 
44564
+ // ../../apps/site/src/friend-requests.ts
44565
+ function friendRequestParts(options) {
44566
+ const body = stack({
44567
+ items: [
44568
+ { kind: "block", markup: listRows({ whenEmpty: emptyState({ title: "Nobody picked", text: "Who you're adding shows here." }), rows: [{ lead: pictureFrame("?", "md"), title: "?" }], id: "friend-request-who" }) },
44569
+ { kind: "words", markup: html`<p><span id="friend-request-told"></span> can accept it from their game or their friends page. Once you're friends, you can chat and join each other in games that allow it.</p>` },
44570
+ { kind: "block", markup: html`<div id="friend-request-error" role="alert" hidden>${banner({ text: "Couldn't send it. Check your connection and try again.", icon: "warning", tone: "warning" })}</div>` }
44571
+ ]
44572
+ });
44573
+ const card = playingCard({
44574
+ id: "friend-request-card",
44575
+ title: "Friend request",
44576
+ body: stack({
44577
+ items: [
44578
+ { kind: "words", markup: html`<p><strong id="friend-request-card-name"></strong> wants to be friends.</p>` },
44579
+ {
44580
+ kind: "block",
44581
+ markup: row({ widths: "fit", label: "Answer", items: [{ kind: "control", markup: runButton("Accept", { type: "button", id: "friend-request-accept" }) }, { kind: "control", markup: cutButton("Not now", { type: "button", id: "friend-request-later" }) }] })
44582
+ }
44583
+ ]
44584
+ })
44585
+ });
44586
+ const acceptedCard = playingCard({ id: "friend-accepted-card", body: stack({ items: [{ kind: "words", markup: html`<p>You and <strong id="friend-accepted-name"></strong> are friends now.</p>` }] }) });
44587
+ return html`${sheet({
44588
+ id: "friend-request",
44589
+ title: "Send a friend request?",
44590
+ body,
44591
+ closeLabel: "Close",
44592
+ placement: "foot",
44593
+ buttons: [cutButton("Cancel", { type: "submit", formMethod: "dialog" }), runButton("Send request", { type: "button", id: "friend-request-yes" })]
44594
+ })}
44595
+ ${card}
44596
+ ${acceptedCard}
44597
+ ${pageData("friend-request-data", { gameOrigin: options.gameOrigin, slug: options.slug })}
44598
+ ${scriptTags(FRIEND_REQUEST_SCRIPT)}`;
44599
+ }
44600
+ var CARD_SHOWN_MS = 8e3;
44601
+ var NEWS_SHOWN_MS = 5e3;
44602
+ var FRIEND_REQUEST_SCRIPT = pageScript("friend-requests", String.raw`(() => {
44603
+ const sheet = document.getElementById("friend-request");
44604
+ const card = document.getElementById("friend-request-card");
44605
+ const data = document.getElementById("friend-request-data");
44606
+ if (!sheet || !card || !data) return;
44607
+ const { gameOrigin: GAME_ORIGIN, slug: SLUG } = JSON.parse(data.textContent);
44608
+ const PLAYER_ID = /^player_[a-z0-9]{1,40}$/;
44609
+ const $ = (id) => document.getElementById(id);
44610
+ const yes = $("friend-request-yes");
44611
+ const frame = () => document.getElementById("game");
44612
+ const tell = (message) => { const f = frame(); if (f && f.contentWindow) f.contentWindow.postMessage(message, GAME_ORIGIN); };
44613
+ const isSignedIn = () => /(^|; )(__Host-)?zaisma_in=1/.test(document.cookie);
44614
+ const post = (path, body) => fetch(path, { method: "POST", headers: body ? { "Content-Type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined }).catch(() => null);
44615
+
44616
+ // The request the sheet is asking about: { id, name }.
44617
+ let asking = null;
44618
+ function finish(status) {
44619
+ if (!asking) return;
44620
+ tell({ type: "zaisma:friend-request", playerId: asking.id, status });
44621
+ if (status === "sent") dispatchEvent(new CustomEvent("zaisma:friend-request-sent", { detail: { id: asking.id } }));
44622
+ asking = null;
44623
+ if (sheet.open) sheet.close();
44624
+ }
44625
+ // Who it's for, as the page draws an account (accountPicture): initials in its own color, from the name.
44626
+ function showWho(name) {
44627
+ const who = $("friend-request-who");
44628
+ let hue = 7;
44629
+ for (const character of name) hue = (hue * 31 + character.codePointAt(0)) % 360;
44630
+ const words = name.trim().split(/\s+/).filter(Boolean);
44631
+ const letters = words.length > 1 ? [Array.from(words[0])[0], Array.from(words[1])[0]] : Array.from(words[0] || "?").slice(0, 2);
44632
+ const picture = who.querySelector(".av");
44633
+ picture.style.setProperty("--h", String(hue));
44634
+ picture.textContent = letters.join("").toUpperCase();
44635
+ who.querySelector(".list-row-title").textContent = name;
44636
+ }
44637
+ async function ask(playerId) {
44638
+ // One question at a time: another while the sheet is up is a no.
44639
+ if (asking) return tell({ type: "zaisma:friend-request", playerId, status: "cancelled" });
44640
+ if (!isSignedIn()) return tell({ type: "zaisma:friend-request", playerId, status: "cancelled" });
44641
+ tell({ type: "zaisma:friend-request", playerId, status: "asking" });
44642
+ asking = { id: playerId, name: "" };
44643
+ const about = await fetch("/friends/requests/about/" + playerId).then((res) => (res.ok ? res.json() : null)).catch(() => null);
44644
+ if (!about || !about.player) { asking = { id: playerId }; return finish("not_taking_requests"); }
44645
+ if (about.is_friend) return finish("already_friends");
44646
+ // Already waiting looks the same as just sent, whatever became of it.
44647
+ if (about.has_sent) return finish("sent");
44648
+ if (!about.can_send) return finish("not_taking_requests");
44649
+ asking.name = about.player.name;
44650
+ $("friend-request-title").textContent = "Send " + asking.name + " a friend request?";
44651
+ showWho(asking.name);
44652
+ $("friend-request-told").textContent = asking.name;
44653
+ $("friend-request-error").hidden = true;
44654
+ yes.removeAttribute("aria-busy");
44655
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
44656
+ sheet.showModal();
44657
+ yes.focus();
44658
+ }
44659
+ yes.addEventListener("click", async () => {
44660
+ if (!asking || yes.getAttribute("aria-busy") === "true") return;
44661
+ yes.setAttribute("aria-busy", "true");
44662
+ $("friend-request-error").hidden = true;
44663
+ const res = await post("/friends/requests", { player_id: asking.id, game_slug: SLUG });
44664
+ yes.removeAttribute("aria-busy");
44665
+ const answer = res ? await res.json().catch(() => null) : null;
44666
+ // One back to someone whose request waited: friends at once, and they hear of it like an accept.
44667
+ if (res && res.ok && answer && answer.status === "friends") {
44668
+ dispatchEvent(new CustomEvent("zaisma:friend-added", { detail: { id: asking.id } }));
44669
+ tell({ type: "zaisma:friend-request", playerId: asking.id, status: "accepted" });
44670
+ waiting = waiting.filter((each) => each.player_id !== asking.id);
44671
+ dispatchEvent(new CustomEvent("zaisma:friend-requests-waiting", { detail: { requests: waiting } }));
44672
+ }
44673
+ if (res && res.ok) return finish("sent");
44674
+ const code = answer && answer.error ? answer.error.code : "";
44675
+ if (code === "already_friends") return finish("already_friends");
44676
+ if (code === "already_sent") return finish("sent");
44677
+ if (code === "not_taking_requests" || code === "not_an_account") return finish("not_taking_requests");
44678
+ const error = $("friend-request-error");
44679
+ error.querySelector(".banner-text").textContent = answer && answer.error ? answer.error.message : "Couldn't send it. Check your connection and try again.";
44680
+ error.hidden = false;
44681
+ });
44682
+ sheet.addEventListener("close", () => { finish("cancelled"); const f = frame(); if (f) f.focus(); });
44683
+ // The game asks (Zaisma.friends.sendRequest); only its own frame.
44684
+ addEventListener("message", (e) => {
44685
+ const f = frame();
44686
+ const d = e.data;
44687
+ if (!f || e.source !== f.contentWindow || e.origin !== GAME_ORIGIN || !d) return;
44688
+ if (d.type === "zaisma:friend-request" && typeof d.playerId === "string" && PLAYER_ID.test(d.playerId) && !d.status) ask(d.playerId);
44689
+ // Someone in the room sent this player one: look now.
44690
+ if (d.type === "zaisma:friend-requests") look();
44691
+ });
44692
+ // Players' Add friend.
44693
+ addEventListener("zaisma:ask-friend-request", (e) => { if (e.detail && PLAYER_ID.test(e.detail.id)) ask(e.detail.id); });
44694
+
44695
+ // Requests waiting for this player, newest first; the card brings each one once a tab.
44696
+ const SHOWN_KEY = "zaisma:friend-requests-shown";
44697
+ const shown = () => { try { return new Set(JSON.parse(sessionStorage.getItem(SHOWN_KEY) || "[]")); } catch { return new Set(); } };
44698
+ const keepShown = (ids) => { try { sessionStorage.setItem(SHOWN_KEY, JSON.stringify([...ids].slice(-200))); } catch {} };
44699
+ let waiting = [];
44700
+ let showing = null;
44701
+ let cardTimer = null;
44702
+ let isLooking = false;
44703
+ let hasLooked = false;
44704
+ // The game page while its game runs (pages.ts swaps is-leaving for is-playing).
44705
+ const isPlaying = () => document.documentElement.classList.contains("is-playing");
44706
+ async function look() {
44707
+ if (!isSignedIn() || isLooking) return;
44708
+ isLooking = true;
44709
+ hasLooked = true;
44710
+ const answer = await fetch("/friends/requests").then((res) => (res.ok ? res.json() : null)).catch(() => null);
44711
+ isLooking = false;
44712
+ if (!answer || !Array.isArray(answer.requests)) return;
44713
+ waiting = answer.requests;
44714
+ dispatchEvent(new CustomEvent("zaisma:friend-requests-waiting", { detail: { requests: waiting } }));
44715
+ // Requests this player sent that were accepted: Players marks them Friend, and the news shows once a tab.
44716
+ const seen = shown();
44717
+ (Array.isArray(answer.accepted) ? answer.accepted : []).forEach((each) => {
44718
+ dispatchEvent(new CustomEvent("zaisma:friend-added", { detail: { id: each.player_id } }));
44719
+ const key = "accepted:" + each.player_id + ":" + each.answered_at_ms;
44720
+ if (seen.has(key)) return;
44721
+ seen.add(key);
44722
+ news.push(each.name);
44723
+ });
44724
+ keepShown(seen);
44725
+ showNext();
44726
+ }
44727
+ // Names whose accept this player hasn't seen yet, shown one at a time.
44728
+ const news = [];
44729
+ const newsCard = $("friend-accepted-card");
44730
+ let newsTimer = null;
44731
+ function showNews() {
44732
+ if (!newsCard || !news.length || !newsCard.hidden || !isPlaying()) return;
44733
+ $("friend-accepted-name").textContent = news.shift();
44734
+ newsCard.hidden = false;
44735
+ clearTimeout(newsTimer);
44736
+ newsTimer = setTimeout(() => { newsCard.hidden = true; setTimeout(showNews, 600); }, ${NEWS_SHOWN_MS});
44737
+ }
44738
+ function showNext() {
44739
+ showNews();
44740
+ if (showing || !isPlaying()) return;
44741
+ const seen = shown();
44742
+ const next = waiting.find((each) => !seen.has(each.player_id + ":" + each.created_at_ms));
44743
+ if (!next) return;
44744
+ seen.add(next.player_id + ":" + next.created_at_ms);
44745
+ keepShown(seen);
44746
+ showing = next;
44747
+ $("friend-request-card-name").textContent = next.name;
44748
+ card.hidden = false;
44749
+ clearTimeout(cardTimer);
44750
+ cardTimer = setTimeout(hideCard, ${CARD_SHOWN_MS});
44751
+ }
44752
+ function hideCard() {
44753
+ clearTimeout(cardTimer);
44754
+ card.hidden = true;
44755
+ showing = null;
44756
+ setTimeout(showNext, 600);
44757
+ }
44758
+ // A hand on the card keeps it up.
44759
+ card.addEventListener("pointerenter", () => clearTimeout(cardTimer));
44760
+ card.addEventListener("pointerleave", () => { if (showing) cardTimer = setTimeout(hideCard, 3000); });
44761
+ async function answerRequest(playerId, verb) {
44762
+ const res = await post("/friends/requests/" + playerId + "/" + verb);
44763
+ waiting = waiting.filter((each) => each.player_id !== playerId);
44764
+ dispatchEvent(new CustomEvent("zaisma:friend-requests-waiting", { detail: { requests: waiting } }));
44765
+ const isAccepted = Boolean(res && res.ok) && verb === "accept";
44766
+ // Friends now: Players marks them, and the rooms they share tell the sender's game to have their page look.
44767
+ if (isAccepted) {
44768
+ dispatchEvent(new CustomEvent("zaisma:friend-added", { detail: { id: playerId } }));
44769
+ tell({ type: "zaisma:friend-request", playerId, status: "accepted" });
44770
+ }
44771
+ return Boolean(res && res.ok);
44772
+ }
44773
+ $("friend-request-accept").addEventListener("click", async () => {
44774
+ if (!showing) return;
44775
+ const playerId = showing.player_id;
44776
+ hideCard();
44777
+ await answerRequest(playerId, "accept");
44778
+ });
44779
+ $("friend-request-later").addEventListener("click", hideCard);
44780
+ // Players answers from its list too.
44781
+ addEventListener("zaisma:answer-friend-request", (e) => {
44782
+ if (!e.detail || !PLAYER_ID.test(e.detail.id) || (e.detail.verb !== "accept" && e.detail.verb !== "turn-down")) return;
44783
+ if (showing && showing.player_id === e.detail.id) hideCard();
44784
+ answerRequest(e.detail.id, e.detail.verb);
44785
+ });
44786
+ // The presence check-in says how many wait and how many of this player's were accepted; a change means something new to look at.
44787
+ let lastCounts = "";
44788
+ addEventListener("zaisma:presence", (e) => {
44789
+ const counts = e.detail ? [e.detail.waiting_friend_request_count, e.detail.accepted_friend_request_count].map((count) => (typeof count === "number" ? count : 0)) : [0, 0];
44790
+ if (counts.join() !== lastCounts) { lastCounts = counts.join(); if (counts[0] || counts[1] || waiting.length) look(); }
44791
+ });
44792
+ // Once the game is running.
44793
+ new MutationObserver(() => { if (isPlaying()) { showNext(); if (!hasLooked) look(); } }).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
44794
+ })();`);
44795
+
44280
44796
  // ../../apps/site/src/game-updates.ts
44281
44797
  var LIVE_VERSION_CHECK_MS = 2 * 6e4;
44282
44798
  function updateBarButtons() {
@@ -45703,6 +46219,7 @@ ${multiplayer ? playersSheet({ gameOrigin, slug: game.slug, title: game.title, h
45703
46219
  ${keepCard}
45704
46220
  ${saves ? playingCard({ id: "save-toast", body: stack({ items: [{ kind: "words", markup: html`<p></p>` }] }) }) : ""}
45705
46221
  ${signInCard}
46222
+ ${isClosed || !authConfigured(env2) ? "" : friendRequestParts({ gameOrigin, slug: game.slug })}
45706
46223
  ${multiplayer ? stillThereCard() : ""}
45707
46224
  ${leavingCard(game.title)}
45708
46225
  ${isClosed ? "" : buySheet({ joinHref: joinHere, signInHref: signinHere, coverUrl })}
@@ -46363,6 +46880,16 @@ var USERNAME_SCRIPT = pageScript("username-check", `(() => {
46363
46880
  check();
46364
46881
  });
46365
46882
  })();`);
46883
+ var JOIN_TODAY_SCRIPT = pageScript("join-today", `(() => {
46884
+ const form = document.getElementById("age-form");
46885
+ if (!form) return;
46886
+ const setToday = () => {
46887
+ const now = new Date();
46888
+ form.elements.today.value = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0") + "-" + String(now.getDate()).padStart(2, "0");
46889
+ };
46890
+ setToday();
46891
+ form.addEventListener("submit", setToday);
46892
+ })();`);
46366
46893
  var ENTRY_SWITCH_SCRIPT = pageScript("entry-switch", `(() => {
46367
46894
  const sw = document.querySelector(".city-pane .tab-switch");
46368
46895
  const views = [...document.querySelectorAll("[data-entry-view]")];
@@ -46752,6 +47279,7 @@ function devSheetBody(name, view) {
46752
47279
  var AGE_BAND_WORDS = { "13-15": "13\u201315", "16-17": "16\u201317", "18+": "18+" };
46753
47280
  var isSocialModeAllowed = (ageBand2, socialMode) => socialModesFor(ageBand2).includes(socialMode);
46754
47281
  var startingSocialMode = (ageBand2) => defaultSocialMode(ageBand2);
47282
+ var takesFriendRequests = (ageBand2, socialMode) => socialModeSettings(socialMode, ageBand2).who_can_send_friend_requests !== "nobody";
46755
47283
  function chatOf(ageBand2, socialMode) {
46756
47284
  const whoCanChat = socialModeSettings(socialMode, ageBand2).who_can_chat;
46757
47285
  return { whoCanChat, bandGroup: chatBandGroup({ who_can_chat: whoCanChat, age_band: ageBand2 }) };
@@ -46848,5 +47376,6 @@ export {
46848
47376
  playerPanelHtml,
46849
47377
  playersPanelHtml,
46850
47378
  siteAsset,
46851
- startingSocialMode
47379
+ startingSocialMode,
47380
+ takesFriendRequests
46852
47381
  };