@zaisma/game-server 1.9.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;
@@ -14782,8 +14853,10 @@ component({
14782
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; }
14783
14854
  .choice-cards > .choice-card { flex: 1 1 calc((var(--choice-row-least) - 100%) * 999); }
14784
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)); }
14785
- .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;
14786
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; }
14787
14860
  .choice-card > :not(.cut-line):not(.choice-input) { position: relative; z-index: 2; }
14788
14861
  .choice-input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
14789
14862
  .choice-card > .ico { flex: none; margin-top: 1px; color: var(--muted); }
@@ -16779,7 +16852,7 @@ component({
16779
16852
  function notice(options) {
16780
16853
  const isProblem = options.tone === "problem";
16781
16854
  const undo = options.undoAction === void 0 ? "" : html`<form class="notice-undo" method="post" action="${options.undoAction}">${textButton("Undo", { type: "submit" })}</form>`;
16782
- 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>`;
16783
16856
  }
16784
16857
  var STARTED_LOW_SCRIPT = `/* panes started low */(function () {
16785
16858
  function place(pane) {
@@ -16856,7 +16929,7 @@ ${touchReach(".notice")}
16856
16929
  examples: [
16857
16930
  { label: "Done", markup: notice({ text: "Saved", tone: "done", isInPlace: true }) },
16858
16931
  { label: "Done, longer", markup: notice({ text: "Version 14 is live", tone: "done", isInPlace: true }) },
16859
- { 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 }) },
16860
16933
  { label: "Done, with Undo", markup: notice({ text: "Removed Ashen Crown from your games", tone: "done", undoAction: "#notice", isInPlace: true }) },
16861
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>` }
16862
16935
  ]
@@ -23278,7 +23351,7 @@ function securityHeaders(env2, extra = {}, hasTurnstileCheck = false, frames = [
23278
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";
23279
23352
  try{last=Number(sessionStorage.getItem(KEY))||0}catch{}if(last>Date.now())last=0;
23280
23353
  const beat=()=>{if(off||document.visibilityState!=="visible")return;last=Date.now();try{sessionStorage.setItem(KEY,String(last))}catch{}const g=b.dataset.presenceGame;
23281
- 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(()=>{})};
23282
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()});
23283
23356
  new MutationObserver(beat).observe(b,{attributes:true,attributeFilter:["data-presence-game"]});
23284
23357
  addEventListener("pagehide",()=>{const g=b.dataset.presenceGame;if(g&&!off)navigator.sendBeacon("/api/presence",JSON.stringify({left_game_slug:g}))})})();`);
@@ -43574,7 +43647,7 @@ var PICTURE_FIELDS = {
43574
43647
  },
43575
43648
  overlay: {
43576
43649
  type: "string",
43577
- 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.`
43578
43651
  },
43579
43652
  fake_reward: {
43580
43653
  type: "string",
@@ -43582,7 +43655,7 @@ var PICTURE_FIELDS = {
43582
43655
  },
43583
43656
  famous_name: {
43584
43657
  type: "string",
43585
- 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.`
43586
43659
  },
43587
43660
  main_subject: {
43588
43661
  type: "string",
@@ -43598,7 +43671,7 @@ var PICTURE_FIELDS = {
43598
43671
  },
43599
43672
  background_text: {
43600
43673
  type: "string",
43601
- 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.`
43602
43675
  },
43603
43676
  has_safety_concern: {
43604
43677
  type: "boolean",
@@ -44216,6 +44289,13 @@ function playersSheet(options) {
44216
44289
  const words3 = (markup) => ({ kind: "words", markup });
44217
44290
  const body = stack({
44218
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
+ },
44219
44299
  words3(html`<h3 id="players-room-title">In this room<span id="players-count"></span></h3>`),
44220
44300
  // While nobody's listed, the note says why (the page's script writes it): the list's empty state.
44221
44301
  { kind: "block", markup: listRows({ whenEmpty: html`<p id="players-note" hidden></p>`, rows: [], id: "players-list", label: "In this room" }) },
@@ -44232,6 +44312,10 @@ function playersSheet(options) {
44232
44312
  const playerBody = stack({
44233
44313
  items: [
44234
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>`),
44235
44319
  { kind: "control", markup: cutButton("Block", { type: "button", id: "players-player-block", isFullWidth: true }) },
44236
44320
  { kind: "control", markup: cutButton("Report", { type: "button", id: "players-player-report", isFullWidth: true }) }
44237
44321
  ]
@@ -44241,6 +44325,7 @@ function playersSheet(options) {
44241
44325
  return html`${sheet({ id: "players", title: "Players", body, closeLabel: "Close", placement: "foot" })}
44242
44326
  ${sheet({ id: "players-player", title: "Player", body: playerBody, closeLabel: "Close", placement: "foot" })}
44243
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>
44244
44329
  ${pageData("players-data", { gameOrigin: options.gameOrigin, slug: options.slug, hasZaismaRooms: options.hasZaismaRooms, canSignIn: options.canSignIn })}
44245
44330
  ${scriptTags(PLAYERS_SHEET_SCRIPT)}`;
44246
44331
  }
@@ -44279,6 +44364,33 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44279
44364
  }
44280
44365
  // A button's words: the last span in its face (cutButton).
44281
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
+ }
44282
44394
 
44283
44395
  addEventListener("message", (e) => {
44284
44396
  const f = frame();
@@ -44321,6 +44433,7 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44321
44433
  }
44322
44434
 
44323
44435
  function render() {
44436
+ renderRequests();
44324
44437
  $("players-count").textContent = room ? " · " + room.players.length : "";
44325
44438
  const list = $("players-list");
44326
44439
  list.textContent = "";
@@ -44369,11 +44482,43 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44369
44482
  profile.rel = "noopener";
44370
44483
  }
44371
44484
  $("players-player-block").hidden = isBlocked;
44485
+ showFriendChoice(other, known, isBlocked);
44372
44486
  if (!playerSheet.open) playerSheet.showModal();
44373
44487
  });
44374
44488
  return item;
44375
44489
  }
44376
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
+
44377
44522
  function closeBoth() {
44378
44523
  if (playerSheet.open) playerSheet.close();
44379
44524
  if (sheet.open) sheet.close();
@@ -44406,7 +44551,7 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44406
44551
 
44407
44552
  // The bar's button opens it (SHEET_SCRIPT does too, by its aria-controls; this runs first and fills it in).
44408
44553
  opener.addEventListener("click", () => {
44409
- ["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(); });
44410
44555
  render();
44411
44556
  loadFriends();
44412
44557
  if (!sheet.open) sheet.showModal();
@@ -44416,6 +44561,238 @@ var PLAYERS_SHEET_SCRIPT = pageScript("players-sheet", String.raw`(() => {
44416
44561
  update();
44417
44562
  })();`);
44418
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
+
44419
44796
  // ../../apps/site/src/game-updates.ts
44420
44797
  var LIVE_VERSION_CHECK_MS = 2 * 6e4;
44421
44798
  function updateBarButtons() {
@@ -45842,6 +46219,7 @@ ${multiplayer ? playersSheet({ gameOrigin, slug: game.slug, title: game.title, h
45842
46219
  ${keepCard}
45843
46220
  ${saves ? playingCard({ id: "save-toast", body: stack({ items: [{ kind: "words", markup: html`<p></p>` }] }) }) : ""}
45844
46221
  ${signInCard}
46222
+ ${isClosed || !authConfigured(env2) ? "" : friendRequestParts({ gameOrigin, slug: game.slug })}
45845
46223
  ${multiplayer ? stillThereCard() : ""}
45846
46224
  ${leavingCard(game.title)}
45847
46225
  ${isClosed ? "" : buySheet({ joinHref: joinHere, signInHref: signinHere, coverUrl })}
@@ -46502,6 +46880,16 @@ var USERNAME_SCRIPT = pageScript("username-check", `(() => {
46502
46880
  check();
46503
46881
  });
46504
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
+ })();`);
46505
46893
  var ENTRY_SWITCH_SCRIPT = pageScript("entry-switch", `(() => {
46506
46894
  const sw = document.querySelector(".city-pane .tab-switch");
46507
46895
  const views = [...document.querySelectorAll("[data-entry-view]")];
@@ -46891,6 +47279,7 @@ function devSheetBody(name, view) {
46891
47279
  var AGE_BAND_WORDS = { "13-15": "13\u201315", "16-17": "16\u201317", "18+": "18+" };
46892
47280
  var isSocialModeAllowed = (ageBand2, socialMode) => socialModesFor(ageBand2).includes(socialMode);
46893
47281
  var startingSocialMode = (ageBand2) => defaultSocialMode(ageBand2);
47282
+ var takesFriendRequests = (ageBand2, socialMode) => socialModeSettings(socialMode, ageBand2).who_can_send_friend_requests !== "nobody";
46894
47283
  function chatOf(ageBand2, socialMode) {
46895
47284
  const whoCanChat = socialModeSettings(socialMode, ageBand2).who_can_chat;
46896
47285
  return { whoCanChat, bandGroup: chatBandGroup({ who_can_chat: whoCanChat, age_band: ageBand2 }) };
@@ -46987,5 +47376,6 @@ export {
46987
47376
  playerPanelHtml,
46988
47377
  playersPanelHtml,
46989
47378
  siteAsset,
46990
- startingSocialMode
47379
+ startingSocialMode,
47380
+ takesFriendRequests
46991
47381
  };
package/bin/local-play.js CHANGED
@@ -145,6 +145,8 @@ function defaultState() {
145
145
  // Each player's guest, while they play as one: { id, name }.
146
146
  guests: {},
147
147
  friends: [],
148
+ // Friend requests waiting between bench accounts: { from, to, createdAtMs }.
149
+ friendRequests: [],
148
150
  blocks: [],
149
151
  ownedItems: {},
150
152
  items: [],
@@ -629,6 +631,8 @@ export async function startLocalPlay(givenOptions) {
629
631
  if (rest === "" && method === "DELETE") {
630
632
  state.accounts = state.accounts.filter((each) => each.id !== account.id);
631
633
  state.friends = state.friends.filter((pair) => !pair.includes(account.id));
634
+ state.friendRequests = (state.friendRequests ?? []).filter((each) => each.from !== account.id && each.to !== account.id);
635
+ state.acceptedFriendRequests = (state.acceptedFriendRequests ?? []).filter((each) => each.from !== account.id && each.to !== account.id);
632
636
  state.blocks = state.blocks.filter((block) => block.blocker !== account.id && block.blocked !== account.id);
633
637
  state.purchases = state.purchases.filter((purchase) => purchase.accountId !== account.id);
634
638
  delete state.ownedItems[account.id];
@@ -951,6 +955,67 @@ export async function startLocalPlay(givenOptions) {
951
955
  }
952
956
  return reply(200, { player: { player_id: target.playerId, name: target.username }, is_friend: friendIdsOf(account.id).includes(target.id), is_blocked: state.blocks.some((block) => block.blocker === account.id && block.blocked === target.id) });
953
957
  }
958
+ // Friend requests between bench accounts, as Zaisma's game page sends and answers them (apps/site/src/friend-requests.ts).
959
+ if (path === "/friends/requests" || path.startsWith("/friends/requests/")) {
960
+ if (!account) return reply(401, { error: { code: "not_signed_in", message: "Sign in to add friends." } });
961
+ const requests = (state.friendRequests ??= []);
962
+ const isBlockedPair = (other) => state.blocks.some((block) => isPair([block.blocker, block.blocked], account.id, other.id));
963
+ const isFriend = (other) => friendIdsOf(account.id).includes(other.id);
964
+ const waitingFrom = (from, to) => requests.find((each) => each.from === from && each.to === to);
965
+ const makeFriends = (other) => {
966
+ // The sender hears of the accept on their next look, for a day, as on Zaisma.
967
+ (state.acceptedFriendRequests ??= []).push({ from: other.id, to: account.id, answeredAtMs: Date.now() });
968
+ state.friendRequests = requests.filter((each) => !isPair([each.from, each.to], account.id, other.id));
969
+ if (!isFriend(other)) state.friends.push([account.id, other.id]);
970
+ saveState();
971
+ log(`${account.username} and ${other.username} are friends now.`);
972
+ };
973
+ if (request.method === "GET" && path === "/friends/requests") {
974
+ const accepted = (state.acceptedFriendRequests ?? []).filter((each) => each.from === account.id && each.answeredAtMs > Date.now() - 86400000 && isFriend({ id: each.to })).map((each) => ({ to: accountOf(each.to), answeredAtMs: each.answeredAtMs })).filter((each) => each.to);
975
+ return reply(200, {
976
+ requests: requests.filter((each) => each.to === account.id).map((each) => ({ from: accountOf(each.from), createdAtMs: each.createdAtMs })).filter((each) => each.from).map((each) => ({ player_id: each.from.playerId, name: each.from.username, created_at_ms: each.createdAtMs, game_slug: gameSlug })).reverse(),
977
+ accepted: accepted.map((each) => ({ player_id: each.to.playerId, name: each.to.username, answered_at_ms: each.answeredAtMs })).reverse(),
978
+ });
979
+ }
980
+ const aboutMatch = /^\/friends\/requests\/about\/(player_[a-z0-9]{1,40})$/.exec(path);
981
+ if (request.method === "GET" && aboutMatch) {
982
+ const other = accountOfPlayer(aboutMatch[1]);
983
+ if (!other || other.id === account.id) return reply(200, { player: null, can_send: false, has_sent: false, is_friend: false });
984
+ const hasSent = Boolean(waitingFrom(account.id, other.id));
985
+ const canSend = !isFriend(other) && !hasSent && !isBlockedPair(other) && other.ageBand === account.ageBand && (Boolean(waitingFrom(other.id, account.id)) || glass.takesFriendRequests(other.ageBand, other.socialMode));
986
+ return reply(200, { player: { player_id: other.playerId, name: other.username }, can_send: canSend, has_sent: hasSent, is_friend: isFriend(other) });
987
+ }
988
+ if (isPost && path === "/friends/requests") {
989
+ const body = await readJson(request);
990
+ const other = typeof body.player_id === "string" ? accountOfPlayer(body.player_id) : null;
991
+ if (!other || other.id === account.id) return reply(409, { error: { code: "not_an_account", message: "Only players signed in to Zaisma take friend requests." } });
992
+ if (isFriend(other)) return reply(409, { error: { code: "already_friends", message: "You're friends already." } });
993
+ if (waitingFrom(other.id, account.id)) {
994
+ makeFriends(other);
995
+ return reply(200, { status: "friends" });
996
+ }
997
+ if (waitingFrom(account.id, other.id)) return reply(409, { error: { code: "already_sent", message: "Your request is waiting for them." } });
998
+ if (isBlockedPair(other) || other.ageBand !== account.ageBand || !glass.takesFriendRequests(other.ageBand, other.socialMode)) return reply(409, { error: { code: "not_taking_requests", message: "They aren't taking friend requests from you." } });
999
+ requests.push({ from: account.id, to: other.id, createdAtMs: Date.now() });
1000
+ saveState();
1001
+ log(`${account.username} sent ${other.username} a friend request.`);
1002
+ return reply(200, { status: "sent" });
1003
+ }
1004
+ const answerMatch = /^\/friends\/requests\/(player_[a-z0-9]{1,40})\/(accept|turn-down)$/.exec(path);
1005
+ if (isPost && answerMatch) {
1006
+ await readText(request);
1007
+ const other = accountOfPlayer(answerMatch[1]);
1008
+ if (!other || !waitingFrom(other.id, account.id)) return reply(404, { error: { code: "request_not_found", message: "That request isn't waiting any more." } });
1009
+ if (answerMatch[2] === "accept") makeFriends(other);
1010
+ else {
1011
+ state.friendRequests = requests.filter((each) => !(each.from === other.id && each.to === account.id));
1012
+ saveState();
1013
+ log(`${account.username} turned down ${other.username}'s friend request.`);
1014
+ }
1015
+ return reply(200, { answer: answerMatch[2] === "accept" ? "accepted" : "turned_down" });
1016
+ }
1017
+ return reply(404, { error: { code: "not_found", message: "Nothing here." } });
1018
+ }
954
1019
  // A report goes to Zaisma's moderators, or the game's developer: here the terminal says what it would send.
955
1020
  if (isPost && path === `${game}/report`) {
956
1021
  const body = await readJson(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/sdk/zaisma.d.ts CHANGED
@@ -240,8 +240,35 @@ declare namespace Zaisma {
240
240
  * await Zaisma.friends.setRoom(null); // left the match
241
241
  */
242
242
  setRoom(room: OwnServerRoom | null): Promise<void>;
243
+ /**
244
+ * Offer the player to send another player a friend request, from your own scoreboard, lobby or menu.
245
+ * Zaisma asks the player first on its page ("Send Mia a friend request?", with the name Zaisma knows
246
+ * them by), so nothing is sent without them, and the other player gets it over their game's corner and
247
+ * on zaisma.com/friends. Players can also send one from Players in Zaisma's bar, so a button of your
248
+ * own is optional. Resolves with what happened: "sent"; "cancelled" when the player said no or another
249
+ * question was up; "already_friends"; or "not_taking_requests" when the other player doesn't take them
250
+ * from this one (their settings, their age group, a block, or a guest), which never says which. Your game
251
+ * never hears of requests themselves, only that two players are friends (room.areFriends on your game server, and the
252
+ * passes' tags, from their next join). Fails with error.code "account_needed" for a guest (ask them to
253
+ * sign in with {@link player.signIn}), "bad_player_id" for something that isn't a player's id, and
254
+ * "not_on_zaisma" when the game isn't played on Zaisma.
255
+ *
256
+ * @example
257
+ * // A button beside each player on your end-of-match screen.
258
+ * const { status } = await Zaisma.friends.sendRequest(player.id);
259
+ * if (status === "sent") button.textContent = "Request sent";
260
+ */
261
+ sendRequest(playerId: string): Promise<FriendRequestAnswer>;
243
262
  }
244
263
 
264
+ /** What became of a friend request the game offered ({@link Friends.sendRequest}). */
265
+ interface FriendRequestAnswer {
266
+ status: FriendRequestStatus;
267
+ }
268
+
269
+ /** "sent", "cancelled" (the player said no), "already_friends", or "not_taking_requests" (never why). */
270
+ type FriendRequestStatus = "sent" | "cancelled" | "already_friends" | "not_taking_requests";
271
+
245
272
  /** The player's friends playing this game ({@link Friends.list}). */
246
273
  interface FriendList {
247
274
  /** By name. Empty when none of them are playing it. */
package/sdk/zaisma.js CHANGED
@@ -617,6 +617,9 @@
617
617
  } else if (message.type === "blocks") {
618
618
  room._blocks = new Set(Array.isArray(message.playerIds) ? message.playerIds : []);
619
619
  room._mutedChanged();
620
+ } else if (message.type === "friend_request") {
621
+ // Someone here sent this player a friend request, or accepted theirs: the Zaisma page looks now (never who).
622
+ if (onZaismaPage()) window.parent.postMessage({ type: "zaisma:friend-requests" }, config.site);
620
623
  } else if (message.type === "message") {
621
624
  room._emit("message", message.data, typeof message.fromPlayerId === "string" ? message.fromPlayerId : null);
622
625
  } else if (message.type === "chat" || message.type === "chat_sent" || message.type === "chat_stopped" || (message.type === "error" && typeof message.chatNumber === "number")) {
@@ -2457,6 +2460,51 @@
2457
2460
  }
2458
2461
 
2459
2462
  var rooms = { list: listRooms, quickJoin: quickJoin, linkedRoomCode: roomCodeFromPage(), linkedServerUrl: roomCodeFromPage() ? serverUrlFromPage() : null, isLinkedAsSpectator: watchFromPage() };
2460
- var friends = { list: listFriends, setRoom: setOwnRoom };
2463
+ /*
2464
+ * Friend requests (zaisma.d.ts: Friends.sendRequest): the Zaisma page asks the player before anything is
2465
+ * sent, with the name Zaisma knows the other player by, so no game sends one on a player's behalf. Once one
2466
+ * is sent, from here or the page's Players, the rooms the other player is in tell their game to have their
2467
+ * page look now; Zaisma's record is the request itself.
2468
+ */
2469
+ var friendRequestAsk = null;
2470
+ var FRIEND_REQUEST_STATUSES = ["sent", "cancelled", "already_friends", "not_taking_requests"];
2471
+ function sendFriendRequest(playerId) {
2472
+ if (!onZaismaPage() || !config.rooms) return Promise.reject(notOnZaisma("Friend requests come from Zaisma, so they work only in games played on Zaisma."));
2473
+ if (typeof playerId !== "string" || !/^player_[a-z0-9]{1,40}$/.test(playerId)) return Promise.reject(codedError("bad_player_id", "Zaisma.friends.sendRequest takes a player's id from room.players or Zaisma.friends.list."));
2474
+ return getPass().then(function (mine) {
2475
+ if (mine.kind !== "account") throw codedError("account_needed", "Friend requests are for players signed in to Zaisma: ask the player to sign in first (Zaisma.player.signIn).");
2476
+ // One question at a time: another request while the page is asking is a no.
2477
+ if (friendRequestAsk) return { status: "cancelled" };
2478
+ friendRequestAsk = new Promise(function (resolve) {
2479
+ var isAsking = false;
2480
+ var timer = setTimeout(function () { if (!isAsking) settle("cancelled"); }, 3000);
2481
+ function settle(status) {
2482
+ removeEventListener("message", onMessage);
2483
+ clearTimeout(timer);
2484
+ friendRequestAsk = null;
2485
+ resolve({ status: status });
2486
+ }
2487
+ function onMessage(event) {
2488
+ var told = event.data;
2489
+ if (event.origin !== config.site || event.source !== window.parent || !told || told.type !== "zaisma:friend-request" || told.playerId !== playerId) return;
2490
+ if (told.status === "asking") isAsking = true;
2491
+ else if (FRIEND_REQUEST_STATUSES.indexOf(told.status) !== -1) settle(told.status);
2492
+ }
2493
+ addEventListener("message", onMessage);
2494
+ window.parent.postMessage({ type: "zaisma:friend-request", playerId: playerId }, config.site);
2495
+ });
2496
+ return friendRequestAsk;
2497
+ });
2498
+ }
2499
+ // A request sent (by the game's own button or the page's Players), or one accepted: the rooms the other player is in nudge their game.
2500
+ window.addEventListener("message", function (event) {
2501
+ var told = event.data;
2502
+ if (!config.site || event.origin !== config.site || event.source !== window.parent || !told || told.type !== "zaisma:friend-request" || (told.status !== "sent" && told.status !== "accepted") || typeof told.playerId !== "string") return;
2503
+ joined.forEach(function (room) {
2504
+ if (!room._done && !room.isSpectator && room.players.some(function (each) { return each.id === told.playerId; })) room._say({ type: "friend_request", playerId: told.playerId });
2505
+ });
2506
+ });
2507
+
2508
+ var friends = { list: listFriends, setRoom: setOwnRoom, sendRequest: sendFriendRequest };
2461
2509
  window.Zaisma = { sdkVersionNumber: 1, gameSlug: config.game || null, player: player, joinRoom: joinRoom, rooms: rooms, server: server, invite: invite, checkChat: checkChat, openPlayersPanel: openPlayersPanel, links: links, items: items, friends: friends, saves: saves, versions: versions };
2462
2510
  })();
package/src/room.d.ts CHANGED
@@ -892,6 +892,8 @@ export type ServerMessage = {
892
892
  } | {
893
893
  type: "blocks";
894
894
  playerIds: string[];
895
+ } | {
896
+ type: "friend_request";
895
897
  } | {
896
898
  type: "chat";
897
899
  fromPlayerId: string;
@@ -959,6 +961,9 @@ export type ClientMessage = {
959
961
  } | {
960
962
  type: "unblock";
961
963
  playerId: string;
964
+ } | {
965
+ type: "friend_request";
966
+ playerId: string;
962
967
  } | {
963
968
  type: "probe_answer";
964
969
  probeNumber: number;
@@ -1427,6 +1432,12 @@ export declare class RoomCore<S = any> {
1427
1432
  private tellBlocks;
1428
1433
  /** A player's game blocked a player in the room (a guest's block, or one just made), or took one back. */
1429
1434
  private sessionBlock;
1435
+ /**
1436
+ * A player's game page just sent another player here a friend request, or accepted theirs (docs/DECISIONS.md,
1437
+ * "Friend requests in games"): the room tells that player's game to have their page look now, rather than at its next check-in.
1438
+ * Zaisma's record is the request; this says only "look", never who it's from, so a lying client gains nothing.
1439
+ */
1440
+ private friendRequestTold;
1430
1441
  /** Players in the match who haven't walked away: what room lists and "playing now" count. */
1431
1442
  get playingCount(): number;
1432
1443
  /**
package/src/room.js CHANGED
@@ -1071,6 +1071,20 @@ export class RoomCore {
1071
1071
  this.tellBlocks();
1072
1072
  return null;
1073
1073
  }
1074
+ /**
1075
+ * A player's game page just sent another player here a friend request, or accepted theirs (docs/DECISIONS.md,
1076
+ * "Friend requests in games"): the room tells that player's game to have their page look now, rather than at its next check-in.
1077
+ * Zaisma's record is the request; this says only "look", never who it's from, so a lying client gains nothing.
1078
+ */
1079
+ friendRequestTold(playerId, message) {
1080
+ const otherPlayerId = message.playerId;
1081
+ if (typeof otherPlayerId !== "string" || otherPlayerId === playerId || !this.players.has(otherPlayerId))
1082
+ return "bad_message";
1083
+ if (this.hasBlockBetween(playerId, otherPlayerId))
1084
+ return null;
1085
+ this.sendText(otherPlayerId, JSON.stringify({ type: "friend_request" }));
1086
+ return null;
1087
+ }
1074
1088
  /** Players in the match who haven't walked away: what room lists and "playing now" count. */
1075
1089
  get playingCount() {
1076
1090
  return this.players.size - this.kept.size;
@@ -1691,6 +1705,8 @@ export class RoomCore {
1691
1705
  return this.sessionBlock(playerId, message, true);
1692
1706
  case "unblock":
1693
1707
  return this.sessionBlock(playerId, message, false);
1708
+ case "friend_request":
1709
+ return this.friendRequestTold(playerId, message);
1694
1710
  default:
1695
1711
  // A type this room doesn't know, maybe from a newer SDK: counted and limited above, then ignored.
1696
1712
  return typeof message.type === "string" ? null : "bad_message";