@zaisma/game-server 1.9.0 → 1.11.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/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.11.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. */
@@ -539,6 +566,32 @@ declare namespace Zaisma {
539
566
  */
540
567
  const versions: Versions;
541
568
 
569
+ /**
570
+ * Whether Zaisma's page covers the game right now, so a game can pause or hold the player's input while it does.
571
+ * coveredBy is "sheet" while one of Zaisma's sheets is open over the game (buying, Players, Report: the player is
572
+ * busy with it), "card" while one of its cards is over the game's corner (Still there?, Keep your progress; the
573
+ * game stays in view and playable), and null otherwise. Always null outside Zaisma's page (your own site, a
574
+ * local test). Listen for "coveredBy" to hear each change.
575
+ * @example
576
+ * Zaisma.page.on("coveredBy", (coveredBy) => {
577
+ * if (coveredBy === "sheet") pauseGame();
578
+ * else resumeGame();
579
+ * });
580
+ */
581
+ const page: Page;
582
+
583
+ /** Whether Zaisma's page covers the game ({@link page}). */
584
+ interface Page {
585
+ /** "sheet", "card" or null: what of Zaisma's is over the game now. Read-only. */
586
+ readonly coveredBy: CoveredBy;
587
+ /** Listen for "coveredBy": called with the new value each time it changes. A wrong name fails with error.code "unknown_event". */
588
+ on(event: "coveredBy", listener: (coveredBy: CoveredBy) => void): Page;
589
+ off(event: "coveredBy", listener: (coveredBy: CoveredBy) => void): Page;
590
+ }
591
+
592
+ /** What of Zaisma's covers the game ({@link page}): one of its sheets, one of its cards, or nothing. */
593
+ type CoveredBy = "sheet" | "card" | null;
594
+
542
595
  /** A version published "now" ({@link versions}). */
543
596
  interface Versions {
544
597
  /** Listen for "update". A wrong name fails with error.code "unknown_event". */
package/sdk/zaisma.js CHANGED
@@ -37,6 +37,7 @@
37
37
  var SAVES_EVENTS = ["conflict", "developerChange"];
38
38
  var ITEMS_EVENTS = ["purchase", "refund", "sheetOpen", "sheetClose"];
39
39
  var VERSIONS_EVENTS = ["update"];
40
+ var PAGE_EVENTS = ["coveredBy"];
40
41
  var BUY_OPTIONS = ["itemCount", "gameAccountId"];
41
42
  /** The room messages this SDK speaks (zaisma.d.ts: the wire protocol), sent in the hello. */
42
43
  var PROTOCOL_VERSION_NUMBER = 1;
@@ -617,6 +618,9 @@
617
618
  } else if (message.type === "blocks") {
618
619
  room._blocks = new Set(Array.isArray(message.playerIds) ? message.playerIds : []);
619
620
  room._mutedChanged();
621
+ } else if (message.type === "friend_request") {
622
+ // Someone here sent this player a friend request, or accepted theirs: the Zaisma page looks now (never who).
623
+ if (onZaismaPage()) window.parent.postMessage({ type: "zaisma:friend-requests" }, config.site);
620
624
  } else if (message.type === "message") {
621
625
  room._emit("message", message.data, typeof message.fromPlayerId === "string" ? message.fromPlayerId : null);
622
626
  } else if (message.type === "chat" || message.type === "chat_sent" || message.type === "chat_stopped" || (message.type === "error" && typeof message.chatNumber === "number")) {
@@ -2020,6 +2024,34 @@
2020
2024
  });
2021
2025
  }
2022
2026
 
2027
+ /*
2028
+ * Whether Zaisma's page covers the game: "sheet" while one of its sheets is open over it (buying, Players, Report), "card" while one of
2029
+ * its cards is over the game's corner, null otherwise, and always null outside Zaisma's page. The page says so whenever it changes
2030
+ * (zaisma:covered), and only the page is heard. A game can pause while a sheet covers it.
2031
+ */
2032
+ var pageEvents = new Emitter(PAGE_EVENTS, "Zaisma.page has");
2033
+ var coveredBy = null;
2034
+ var zaismaPage = {
2035
+ on: function (event, listener) {
2036
+ pageEvents.on(event, listener);
2037
+ return zaismaPage;
2038
+ },
2039
+ off: function (event, listener) {
2040
+ pageEvents.off(event, listener);
2041
+ return zaismaPage;
2042
+ },
2043
+ };
2044
+ Object.defineProperty(zaismaPage, "coveredBy", { enumerable: true, get: function () { return coveredBy; } });
2045
+ if (config.site && window.parent !== window) {
2046
+ window.addEventListener("message", function (event) {
2047
+ if (event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:covered") return;
2048
+ var now = event.data.coveredBy === "sheet" || event.data.coveredBy === "card" ? event.data.coveredBy : null;
2049
+ if (now === coveredBy) return;
2050
+ coveredBy = now;
2051
+ pageEvents._emit("coveredBy", now);
2052
+ });
2053
+ }
2054
+
2023
2055
  /*
2024
2056
  * The player's cloud saves, for games that draw their own save screens. The game page on Zaisma holds
2025
2057
  * the player's session and answers (apps/site/src/saves-client.ts); the game never talks to a server.
@@ -2457,6 +2489,51 @@
2457
2489
  }
2458
2490
 
2459
2491
  var rooms = { list: listRooms, quickJoin: quickJoin, linkedRoomCode: roomCodeFromPage(), linkedServerUrl: roomCodeFromPage() ? serverUrlFromPage() : null, isLinkedAsSpectator: watchFromPage() };
2460
- var friends = { list: listFriends, setRoom: setOwnRoom };
2461
- 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 };
2492
+ /*
2493
+ * Friend requests (zaisma.d.ts: Friends.sendRequest): the Zaisma page asks the player before anything is
2494
+ * sent, with the name Zaisma knows the other player by, so no game sends one on a player's behalf. Once one
2495
+ * is sent, from here or the page's Players, the rooms the other player is in tell their game to have their
2496
+ * page look now; Zaisma's record is the request itself.
2497
+ */
2498
+ var friendRequestAsk = null;
2499
+ var FRIEND_REQUEST_STATUSES = ["sent", "cancelled", "already_friends", "not_taking_requests"];
2500
+ function sendFriendRequest(playerId) {
2501
+ if (!onZaismaPage() || !config.rooms) return Promise.reject(notOnZaisma("Friend requests come from Zaisma, so they work only in games played on Zaisma."));
2502
+ 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."));
2503
+ return getPass().then(function (mine) {
2504
+ 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).");
2505
+ // One question at a time: another request while the page is asking is a no.
2506
+ if (friendRequestAsk) return { status: "cancelled" };
2507
+ friendRequestAsk = new Promise(function (resolve) {
2508
+ var isAsking = false;
2509
+ var timer = setTimeout(function () { if (!isAsking) settle("cancelled"); }, 3000);
2510
+ function settle(status) {
2511
+ removeEventListener("message", onMessage);
2512
+ clearTimeout(timer);
2513
+ friendRequestAsk = null;
2514
+ resolve({ status: status });
2515
+ }
2516
+ function onMessage(event) {
2517
+ var told = event.data;
2518
+ if (event.origin !== config.site || event.source !== window.parent || !told || told.type !== "zaisma:friend-request" || told.playerId !== playerId) return;
2519
+ if (told.status === "asking") isAsking = true;
2520
+ else if (FRIEND_REQUEST_STATUSES.indexOf(told.status) !== -1) settle(told.status);
2521
+ }
2522
+ addEventListener("message", onMessage);
2523
+ window.parent.postMessage({ type: "zaisma:friend-request", playerId: playerId }, config.site);
2524
+ });
2525
+ return friendRequestAsk;
2526
+ });
2527
+ }
2528
+ // 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.
2529
+ window.addEventListener("message", function (event) {
2530
+ var told = event.data;
2531
+ 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;
2532
+ joined.forEach(function (room) {
2533
+ if (!room._done && !room.isSpectator && room.players.some(function (each) { return each.id === told.playerId; })) room._say({ type: "friend_request", playerId: told.playerId });
2534
+ });
2535
+ });
2536
+
2537
+ var friends = { list: listFriends, setRoom: setOwnRoom, sendRequest: sendFriendRequest };
2538
+ 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, page: zaismaPage };
2462
2539
  })();
package/src/qr-code.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * QR codes for game and room links (docs/DECISIONS.md, "QR codes"): players get one behind Share,
3
- * developers on the dashboard and through the API and MCP. Byte mode, error correction level M,
4
- * versions 1 to 10 (up to 213 bytes, far more than a link needs). Written here rather than taken from a
3
+ * developers on the dashboard and through the API and MCP. Byte mode, error correction level H, so a
4
+ * small picture over its middle (a game's icon) still leaves it readable, versions 1 to 10 (up to 119
5
+ * bytes, far more than a link needs). Written here rather than taken from a
5
6
  * package so the site's page and the API draw the same code with no dependency; it follows ISO/IEC
6
7
  * 18004 the way Project Nayuki's reference encoder does.
7
8
  */
8
9
  export declare const QR_CODE_LIMITS: {
9
- /** Version 10 at level M holds 213 bytes. */
10
- readonly maximumTextBytes: 213;
10
+ /** Version 10 at level H holds 119 bytes. */
11
+ readonly maximumTextBytes: 119;
11
12
  /** Light modules around the code, as the standard asks. */
12
13
  readonly quietZoneModuleCount: 4;
13
14
  };
package/src/qr-code.js CHANGED
@@ -1,21 +1,22 @@
1
1
  /**
2
2
  * QR codes for game and room links (docs/DECISIONS.md, "QR codes"): players get one behind Share,
3
- * developers on the dashboard and through the API and MCP. Byte mode, error correction level M,
4
- * versions 1 to 10 (up to 213 bytes, far more than a link needs). Written here rather than taken from a
3
+ * developers on the dashboard and through the API and MCP. Byte mode, error correction level H, so a
4
+ * small picture over its middle (a game's icon) still leaves it readable, versions 1 to 10 (up to 119
5
+ * bytes, far more than a link needs). Written here rather than taken from a
5
6
  * package so the site's page and the API draw the same code with no dependency; it follows ISO/IEC
6
7
  * 18004 the way Project Nayuki's reference encoder does.
7
8
  */
8
9
  export const QR_CODE_LIMITS = {
9
- /** Version 10 at level M holds 213 bytes. */
10
- maximumTextBytes: 213,
10
+ /** Version 10 at level H holds 119 bytes. */
11
+ maximumTextBytes: 119,
11
12
  /** Light modules around the code, as the standard asks. */
12
13
  quietZoneModuleCount: 4,
13
14
  };
14
- // Level M: error correction codewords per block and blocks, by version (index 0 unused).
15
- const ERROR_CORRECTION_CODEWORD_COUNTS_PER_BLOCK = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26];
16
- const ERROR_CORRECTION_BLOCK_COUNTS = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5];
17
- /** Error correction level M (medium)'s two bits in the format information. */
18
- const MEDIUM_CORRECTION_FORMAT_NUMBER = 0;
15
+ // Level H: error correction codewords per block and blocks, by version (index 0 unused).
16
+ const ERROR_CORRECTION_CODEWORD_COUNTS_PER_BLOCK = [0, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28];
17
+ const ERROR_CORRECTION_BLOCK_COUNTS = [0, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8];
18
+ /** Error correction level H (high)'s two bits in the format information. */
19
+ const HIGH_CORRECTION_FORMAT_NUMBER = 2;
19
20
  const HIGHEST_VERSION_NUMBER = 10;
20
21
  const isBitSet = (bitsNumber, bitIndex) => ((bitsNumber >>> bitIndex) & 1) !== 0;
21
22
  function rawDataModuleCount(versionNumber) {
@@ -226,7 +227,7 @@ export function qrCodeModules(text) {
226
227
  }
227
228
  }));
228
229
  const drawFormat = (maskIndex) => {
229
- const formatNumber = (MEDIUM_CORRECTION_FORMAT_NUMBER << 3) | maskIndex;
230
+ const formatNumber = (HIGH_CORRECTION_FORMAT_NUMBER << 3) | maskIndex;
230
231
  let remainderNumber = formatNumber;
231
232
  for (let stepIndex = 0; stepIndex < 10; stepIndex++)
232
233
  remainderNumber = (remainderNumber << 1) ^ ((remainderNumber >>> 9) * 0x537);
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";