@zaisma/game-server 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bench/glass.js +594 -65
- package/bin/local-play.js +65 -0
- package/package.json +1 -1
- package/sdk/saves.js +137 -57
- package/sdk/zaisma.d.ts +34 -1
- package/sdk/zaisma.js +53 -2
- package/src/room.d.ts +27 -2
- package/src/room.js +30 -0
package/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
|
-
|
|
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: "
|
|
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
|
-
|
|
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:
|
|
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((
|
|
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
|
|
23915
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
24130
|
-
|
|
24131
|
-
|
|
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
|
-
|
|
24147
|
-
const
|
|
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 &&
|
|
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) &&
|
|
24325
|
+
if (!empty && localHash !== c.sha256 && !(mark && mark.sha256 === localHash) && deviceText !== keptBeforeReload) keepDevice(deviceText, devicePlayedAtMs);
|
|
24158
24326
|
}
|
|
24159
|
-
else if (!
|
|
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(
|
|
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
|
-
|
|
24172
|
-
|
|
24173
|
-
|
|
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 && !
|
|
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
|
|
24353
|
+
// The game domain put a save in place (or couldn't). Only then does saving start.
|
|
24184
24354
|
function restored(ok) {
|
|
24185
|
-
const
|
|
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
|
-
|
|
24192
|
-
|
|
24193
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
24212
|
-
|
|
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(
|
|
24417
|
+
takeDeveloperChange(newest);
|
|
24229
24418
|
} else if (out.changed_by_kind === "developer" || !deviceIsNewer(out.saved_at)) {
|
|
24230
|
-
const keeping = keepDevice(
|
|
24231
|
-
keptBeforeReload =
|
|
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("
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|