@zaisma/game-server 1.5.0 → 1.6.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/src/room.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * developer's code that holds the official game state. A *room* is one live match,
7
7
  * running one copy of the game server. Names follow docs/NAMING.md.
8
8
  */
9
+ import { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS } from "./chat.js";
9
10
  import { DatabaseError } from "./database.js";
10
11
  import { isRoomCode, roomCode } from "./names.js";
11
12
  import { readSchedules } from "./schedules.js";
@@ -22,6 +23,20 @@ function shownPlayer(player) {
22
23
  export function restoreInfoOf(savedAtMs, nowMs = Date.now(), savedVersionId = null, isSavedEmpty = false) {
23
24
  return { savedAt: new Date(savedAtMs).toISOString(), lostDurationSec: isSavedEmpty ? 0 : Math.max(0, Math.round((nowMs - savedAtMs) / 1000)), savedVersionId };
24
25
  }
26
+ /** What the sender hears when the game server's onChat stopped their message. */
27
+ export const STOPPED_BY_GAME_MESSAGE = "Your message wasn't sent.";
28
+ /** onChat's answer as the room acts on it: send (to toPlayerIds, or all it was sent to when null), or stop. Anything else is a mistake, and stops it. */
29
+ export function readChatAnswer(answer) {
30
+ if (answer === true)
31
+ return { isAllowed: true, toPlayerIds: null };
32
+ if (answer === false)
33
+ return { isAllowed: false };
34
+ const toPlayerIds = typeof answer === "object" && answer !== null && !Array.isArray(answer) ? answer.toPlayerIds : undefined;
35
+ if (Array.isArray(toPlayerIds) && toPlayerIds.every((id) => typeof id === "string") && Object.keys(answer).length === 1)
36
+ return { isAllowed: true, toPlayerIds };
37
+ const given = answer === undefined || answer === null ? "nothing" : typeof answer === "string" ? `the text ${JSON.stringify(answer.slice(0, 60))}` : typeof answer === "object" ? describeObject(answer) : String(answer);
38
+ return { mistake: new GameServerError("bad_handler_answer", "onChat", `onChat answered ${given}, so the message was stopped.`, 'Answer true to send it, false to stop it, or { toPlayerIds: ["player_..."] } to send it to only those players.') };
39
+ }
25
40
  function describeObject(given) {
26
41
  try {
27
42
  return (JSON.stringify(given) ?? "an object").slice(0, 80);
@@ -59,12 +74,14 @@ export const GAME_SERVER_OPTIONS = [
59
74
  "awayMode",
60
75
  "seatHoldDurationSec",
61
76
  "stateSyncEnabled",
77
+ "adultChatFilterEnabled",
62
78
  "reconnectTimeoutSec",
63
79
  "maximumRoomCountPerPlayer",
64
80
  "stateSaveIntervalSec",
65
81
  "setup",
66
82
  "onJoin",
67
83
  "canJoin",
84
+ "onChat",
68
85
  "onMoved",
69
86
  "onLeave",
70
87
  "onSpectatorJoin",
@@ -240,6 +257,8 @@ export function defineGameServer(server) {
240
257
  throw bad("hostControlsEnabled", "hostControlsEnabled isn't true or false.", "Give true or false.");
241
258
  if (server.stateSyncEnabled !== undefined && typeof server.stateSyncEnabled !== "boolean")
242
259
  throw bad("stateSyncEnabled", "stateSyncEnabled isn't true or false.", "Give true to send each player the state and then only what changes, or leave it out.");
260
+ if (server.adultChatFilterEnabled !== undefined && typeof server.adultChatFilterEnabled !== "boolean")
261
+ throw bad("adultChatFilterEnabled", "adultChatFilterEnabled isn't true or false.", "Give true to check chat between adults with Zaisma's filter too, or leave it out.");
243
262
  if (server.stateView !== undefined && !server.stateSyncEnabled)
244
263
  throw bad("stateView", "stateView only works with stateSyncEnabled.", "Add stateSyncEnabled: true, or leave stateView out.");
245
264
  if (idleCheck !== undefined && idleCheck !== "server" && idleCheck !== "client" && idleCheck !== "off") {
@@ -290,6 +309,11 @@ export const ERROR_TEXT = {
290
309
  bad_origin: "Rooms take players from the game's own page.",
291
310
  // The SDK's own: sends waiting while it reconnects ran past its queue.
292
311
  send_queue_full: "Up to 60 sends wait while reconnecting; older ones were dropped.",
312
+ chat_not_here: "This room's server doesn't take chat.",
313
+ chat_not_allowed: "You can't chat here.",
314
+ chat_empty: "Type a message first.",
315
+ chat_too_long: "That message is too long to send.",
316
+ chat_too_fast: "Too many messages at once. Wait a moment.",
293
317
  };
294
318
  /** What players are told when Zaisma ends a match at room.endsAt. */
295
319
  export const HOSTING_PAUSED_END_MESSAGE = "This game's hosting is paused, so this match has ended. New rooms open again when the month resets or its developer adds credit.";
@@ -323,6 +347,35 @@ export function checkRoomData(given, maximumSizeBytes) {
323
347
  export function errorMessage(code, message, details) {
324
348
  return JSON.stringify({ type: "error", code, message: message || ERROR_TEXT[code], ...details });
325
349
  }
350
+ /** A checked pass's chat settings, for RoomCore.setChatSettings: null for a guest. A setting this version doesn't know chats with nobody. */
351
+ export function chatSettingsOf(claims) {
352
+ if (claims.kind !== "account")
353
+ return null;
354
+ const whoCanChat = claims.who_can_chat === "friends" ? "friends" : claims.who_can_chat === "anyone" ? "anyone" : "nobody";
355
+ return { whoCanChat, chatGroupId: typeof claims.chat_group_id === "string" ? claims.chat_group_id : null };
356
+ }
357
+ /** Room traffic of chat, in the room's message types. */
358
+ export const CHAT_MESSAGE_TYPE = "(chat)";
359
+ /**
360
+ * A client's chat message ({type: "chat"}), or null for any other text, which goes to receive. The
361
+ * room's host handles chat before receive, since Zaisma's filter runs between the room and the players.
362
+ */
363
+ export function readChatMessage(text) {
364
+ // Most messages aren't chat: only text that could be is parsed here, and one too big is receive's to refuse.
365
+ if (!text.includes('"chat"') || text.length > ROOM_LIMITS.maximumGameMessageSizeBytes)
366
+ return null;
367
+ let parsed;
368
+ try {
369
+ parsed = JSON.parse(text);
370
+ }
371
+ catch {
372
+ return null;
373
+ }
374
+ if (!parsed || typeof parsed !== "object" || parsed.type !== "chat")
375
+ return null;
376
+ const chat = parsed;
377
+ return { chatNumber: Number.isSafeInteger(chat.chatNumber) ? chat.chatNumber : 0, text: chat.text, toPlayerIds: chat.toPlayerIds, sizeBytes: utf8ByteCount(text) };
378
+ }
326
379
  /** WebSocket close code for a match that ended normally. */
327
380
  export const ENDED_CLOSE_CODE = 4000;
328
381
  /** Close code for a player refused or removed by the room: the SDK doesn't reconnect. */
@@ -482,6 +535,12 @@ export class RoomCore {
482
535
  sessionBlocks = new Set();
483
536
  /** Friendships between players from their accounts (Zaisma's own record, or passes on your own server): pairKey(first, second). */
484
537
  friendships = new Set();
538
+ /** Who each account player may chat with, from their pass: never in Player, so games never see it. */
539
+ chatSettings = new Map();
540
+ /** When each player sent their chat messages in the last CHAT_ROOM_LIMITS.chatWindowDurationMs. */
541
+ chatSentAtMs = new Map();
542
+ /** Each player's last chat messages, for Zaisma's filter to read pieces together: in memory only. */
543
+ recentChats = new Map();
485
544
  /** The blocks each player was last told of, so they're told again only when theirs change. */
486
545
  toldBlocks = new Map();
487
546
  /** When each player in the match was last heard from, when their game was hidden, and the idle check their game asked for. */
@@ -509,6 +568,8 @@ export class RoomCore {
509
568
  api;
510
569
  /** The room as canJoin and stateView see it: reading works, and any call that changes or sends throws room_call_unavailable. */
511
570
  canJoinRoom;
571
+ /** The room as onChat sees it: reading only, like canJoin. */
572
+ chatRoom;
512
573
  stateViewRoom;
513
574
  server;
514
575
  transport;
@@ -604,6 +665,8 @@ export class RoomCore {
604
665
  this.sessionBlocks.add(blockKey(blocker, blocked));
605
666
  for (const [first, second] of saved.friendships ?? [])
606
667
  this.friendships.add(pairKey(first, second));
668
+ for (const [id, settings] of Object.entries(saved.chatSettings ?? {}))
669
+ this.chatSettings.set(id, settings);
607
670
  }
608
671
  else if (startOptions.start) {
609
672
  const start = startOptions.start;
@@ -727,6 +790,7 @@ export class RoomCore {
727
790
  Object.defineProperty(this.api, PAIR_CHANGE_COUNT, { get: () => this.pairChangeCount });
728
791
  Object.defineProperty(this.api, STATE_SYNC, { value: { viewers: () => this.stateViewers(), send: (playerIds, frame) => this.sendStateFrom(playerIds, frame) } });
729
792
  this.canJoinRoom = readOnlyRoom(this.api, "canJoin");
793
+ this.chatRoom = readOnlyRoom(this.api, "onChat");
730
794
  this.stateViewRoom = readOnlyRoom(this.api, "stateView");
731
795
  if (restoreInfo)
732
796
  this.restored(restoreInfo, lostPlayers);
@@ -772,6 +836,7 @@ export class RoomCore {
772
836
  ...(this.endsAt ? { endsAt: this.endsAt } : {}),
773
837
  ...(this.isKept ? { isKept: true } : {}),
774
838
  ...this.savedBlocks(),
839
+ ...(this.chatSettings.size ? { chatSettings: Object.fromEntries([...this.chatSettings].filter(([id]) => this.known.has(id))) } : {}),
775
840
  };
776
841
  }
777
842
  /** The blocks worth keeping while the room sleeps: those between players it still remembers. */
@@ -810,6 +875,137 @@ export class RoomCore {
810
875
  this.dirty = true;
811
876
  this.pairChangeCount++;
812
877
  }
878
+ /** A player's chat settings from their pass, before join: null for a guest, who chats with nobody. */
879
+ setChatSettings(playerId, settings) {
880
+ if (settings)
881
+ this.chatSettings.set(playerId, { whoCanChat: settings.whoCanChat, chatGroupId: settings.chatGroupId });
882
+ else if (!this.chatSettings.delete(playerId))
883
+ return;
884
+ this.dirty = true;
885
+ }
886
+ /** Whether a player can chat at all: an account whose setting isn't nobody, with a chat group. */
887
+ canChat(playerId) {
888
+ const settings = this.chatSettings.get(playerId);
889
+ return settings !== undefined && settings.whoCanChat !== "nobody" && settings.chatGroupId !== null;
890
+ }
891
+ /**
892
+ * Whether one player's chat may reach another, canChat's rule on passes (pass.ts): two accounts in the
893
+ * same chat group with no block either way, friends unless both chat with anyone.
894
+ */
895
+ mayChat(senderPlayerId, receiverPlayerId) {
896
+ if (senderPlayerId === receiverPlayerId || !this.canChat(senderPlayerId) || !this.canChat(receiverPlayerId) || this.hasBlockBetween(senderPlayerId, receiverPlayerId))
897
+ return false;
898
+ const sender = this.chatSettings.get(senderPlayerId);
899
+ const receiver = this.chatSettings.get(receiverPlayerId);
900
+ if (sender.chatGroupId !== receiver.chatGroupId)
901
+ return false;
902
+ return (sender.whoCanChat === "anyone" && receiver.whoCanChat === "anyone") || this.areFriends(senderPlayerId, receiverPlayerId);
903
+ }
904
+ /** Who a player's chat may reach: the players in the match (or those of toPlayerIds in it), never spectators, kept only where they may chat. */
905
+ chatReceivers(senderPlayerId, toPlayerIds) {
906
+ const asked = toPlayerIds ? [...new Set(toPlayerIds)].filter((id) => this.players.has(id)) : [...this.players.keys()];
907
+ return asked.filter((id) => this.mayChat(senderPlayerId, id));
908
+ }
909
+ /**
910
+ * A chat message from a player (readChatMessage), checked by the room: the sender, the text, their pace,
911
+ * then the game server's onChat with the players their game sent it to. When it goes no further the room
912
+ * answers the sender itself (an error, or chat_stopped with stopped_by_game) and this is null. Otherwise
913
+ * it's the text, who may read it (chatReceivers, which may be nobody) and the sender's last messages, for
914
+ * the host to run Zaisma's filter and then deliverChat or stopChat.
915
+ */
916
+ async startChat(playerId, chat) {
917
+ const refuse = (code) => {
918
+ this.sendText(playerId, errorMessage(code, undefined, { chatNumber: chat.chatNumber }));
919
+ return null;
920
+ };
921
+ // Spectators send nothing, chat included; limited like anyone's messages, so refusing them costs little.
922
+ if (this.spectators.has(playerId))
923
+ return refuse(this.ended ? "ended" : this.take(playerId) ? "spectator_cannot_send" : "too_fast");
924
+ const player = this.players.get(playerId);
925
+ if (!player || this.ended)
926
+ return refuse("ended");
927
+ this.messageCount++;
928
+ this.countTraffic("inbound", CHAT_MESSAGE_TYPE, 1, chat.sizeBytes);
929
+ // Counted against the room's 30 a second like any message, then chat's own pace below.
930
+ if (!this.take(playerId))
931
+ return refuse("too_fast");
932
+ const isPlayerIdList = chat.toPlayerIds === undefined || (Array.isArray(chat.toPlayerIds) && chat.toPlayerIds.length <= this.maximumPlayerCount && chat.toPlayerIds.every((id) => typeof id === "string"));
933
+ if (typeof chat.text !== "string" || !isPlayerIdList)
934
+ return refuse("bad_message");
935
+ if (!this.canChat(playerId))
936
+ return refuse("chat_not_allowed");
937
+ if (!chat.text.trim())
938
+ return refuse("chat_empty");
939
+ if (chat.text.length > CHAT_CHECK_LIMITS.maximumMessageCharacterCount)
940
+ return refuse("chat_too_long");
941
+ if (!this.takeChat(playerId))
942
+ return refuse("chat_too_fast");
943
+ const presence = this.presence.get(playerId);
944
+ if (presence)
945
+ presence.seenAtMs = this.now();
946
+ const toPlayerIds = (chat.toPlayerIds === undefined ? [...this.players.keys()] : [...new Set(chat.toPlayerIds)]).filter((id) => id !== playerId && this.players.has(id));
947
+ let askedPlayerIds = toPlayerIds;
948
+ if (this.server.onChat) {
949
+ await this.settled();
950
+ let answer;
951
+ try {
952
+ answer = await this.server.onChat(this.state, player, this.chatRoom, { text: chat.text, toPlayerIds: [...toPlayerIds] });
953
+ }
954
+ catch (error) {
955
+ console.error("Game server error:", error);
956
+ answer = false;
957
+ }
958
+ const read = readChatAnswer(answer);
959
+ if ("mistake" in read)
960
+ console.error("Game server error:", read.mistake);
961
+ if ("mistake" in read || !read.isAllowed) {
962
+ this.stopChat(playerId, chat.chatNumber, "stopped_by_game", STOPPED_BY_GAME_MESSAGE);
963
+ return null;
964
+ }
965
+ if (read.toPlayerIds)
966
+ askedPlayerIds = toPlayerIds.filter((id) => read.toPlayerIds.includes(id));
967
+ }
968
+ const recentTexts = this.recentChatTexts(playerId);
969
+ this.rememberChat(playerId, chat.text);
970
+ return { text: chat.text, receiverPlayerIds: this.chatReceivers(playerId, askedPlayerIds), recentTexts };
971
+ }
972
+ /**
973
+ * Send a chat message Zaisma's filter let through to its receivers, and tell the sender it went
974
+ * (chat_sent), even when it reached nobody: the sender never learns who can't read them.
975
+ */
976
+ deliverChat(senderPlayerId, chatNumber, receiverPlayerIds, chat) {
977
+ const sentAt = new Date(this.now()).toISOString();
978
+ // Players who left, or blocked the sender, while the filter looked get nothing.
979
+ const receivers = receiverPlayerIds.filter((id) => this.players.has(id) && this.mayChat(senderPlayerId, id));
980
+ if (receivers.length)
981
+ this.sendEach(receivers, JSON.stringify({ type: "chat", fromPlayerId: senderPlayerId, text: chat.text, hasSwearing: chat.hasSwearing, hasSlur: chat.hasSlur, sentAt }), CHAT_MESSAGE_TYPE);
982
+ this.sendText(senderPlayerId, JSON.stringify({ type: "chat_sent", chatNumber, sentAt }));
983
+ }
984
+ /** Tell the sender their chat message was stopped: reason is the filter's, or stopped_by_game; message is what they see. */
985
+ stopChat(senderPlayerId, chatNumber, reason, message) {
986
+ this.sendText(senderPlayerId, JSON.stringify({ type: "chat_stopped", chatNumber, reason, message }));
987
+ }
988
+ /** A player's chat messages from the last CHAT_CHECK_LIMITS.recentTextDurationMs, oldest first, up to recentTextCount. */
989
+ recentChatTexts(playerId) {
990
+ const nowMs = this.now();
991
+ return (this.recentChats.get(playerId) ?? []).filter((sent) => nowMs - sent.sentAtMs < CHAT_CHECK_LIMITS.recentTextDurationMs).map((sent) => sent.text);
992
+ }
993
+ rememberChat(playerId, text) {
994
+ const nowMs = this.now();
995
+ const recent = (this.recentChats.get(playerId) ?? []).filter((sent) => nowMs - sent.sentAtMs < CHAT_CHECK_LIMITS.recentTextDurationMs);
996
+ recent.push({ text, sentAtMs: nowMs });
997
+ this.recentChats.set(playerId, recent.slice(-CHAT_CHECK_LIMITS.recentTextCount));
998
+ }
999
+ /** Whether a player may send another chat message: CHAT_ROOM_LIMITS.maximumChatMessageCount in any chatWindowDurationMs. */
1000
+ takeChat(playerId) {
1001
+ const nowMs = this.now();
1002
+ const sentAtMs = (this.chatSentAtMs.get(playerId) ?? []).filter((atMs) => nowMs - atMs < CHAT_ROOM_LIMITS.chatWindowDurationMs);
1003
+ const isTaken = sentAtMs.length < CHAT_ROOM_LIMITS.maximumChatMessageCount;
1004
+ if (isTaken)
1005
+ sentAtMs.push(nowMs);
1006
+ this.chatSentAtMs.set(playerId, sentAtMs);
1007
+ return isTaken;
1008
+ }
813
1009
  /** Whether two players have a block between them, either way: from their accounts, or one a player's game said it made. */
814
1010
  hasBlockBetween(firstPlayerId, secondPlayerId) {
815
1011
  if (firstPlayerId === secondPlayerId)
@@ -1133,7 +1329,7 @@ export class RoomCore {
1133
1329
  this.remember(player.id);
1134
1330
  this.presence.set(player.id, newPresence(this.now(), joinOptions.clientIdle === undefined ? (this.presence.get(player.id)?.clientIdle ?? {}) : clientIdle(joinOptions.clientIdle)));
1135
1331
  const probeIntervalSec = this.ticking ? ROOM_TUNING.probeIntervalSec : null;
1136
- this.sendText(player.id, JSON.stringify({ type: "welcome", me: shownPlayer(player), isSpectator: false, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: this.checks(player.id), endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER }));
1332
+ this.sendText(player.id, JSON.stringify({ type: "welcome", me: shownPlayer(player), isSpectator: false, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: this.checks(player.id), endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER, canChat: this.canChat(player.id) }));
1137
1333
  // Every room gets a first ping and clock sample right away, ticking or not.
1138
1334
  this.probe(player.id);
1139
1335
  // A new connection starts with nothing of the state: a full copy comes with the next round.
@@ -1172,7 +1368,7 @@ export class RoomCore {
1172
1368
  this.spectators.set(spectator.id, spectator);
1173
1369
  this.presence.set(spectator.id, newPresence(this.now(), {}));
1174
1370
  const probeIntervalSec = this.ticking ? ROOM_TUNING.probeIntervalSec : null;
1175
- this.sendText(spectator.id, JSON.stringify({ type: "welcome", me: shownPlayer(spectator), isSpectator: true, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: { idleTimeoutSec: null, hiddenTimeoutSec: null }, endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER }));
1371
+ this.sendText(spectator.id, JSON.stringify({ type: "welcome", me: shownPlayer(spectator), isSpectator: true, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: { idleTimeoutSec: null, hiddenTimeoutSec: null }, endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER, canChat: false }));
1176
1372
  this.probe(spectator.id);
1177
1373
  // A new connection starts with nothing of the state: a full copy comes with the next round.
1178
1374
  if (this.server.stateSyncEnabled) {
@@ -1254,6 +1450,8 @@ export class RoomCore {
1254
1450
  /** Take a player out of the match, forgetting what the room knew about them while they were in. */
1255
1451
  out(playerId) {
1256
1452
  this.players.delete(playerId);
1453
+ this.chatSentAtMs.delete(playerId);
1454
+ this.recentChats.delete(playerId);
1257
1455
  this.latestSlots.delete(playerId);
1258
1456
  this.stateSkippedPlayerIds.delete(playerId);
1259
1457
  this.fullCopyPlayerIds.delete(playerId);
@@ -1482,6 +1680,9 @@ export class RoomCore {
1482
1680
  case "message":
1483
1681
  this.input(() => this.server.onMessage?.(this.state, player, this.api, message.data), "onMessage");
1484
1682
  return null;
1683
+ case "chat":
1684
+ // The room's host takes chat before it gets here (readChatMessage); a host that doesn't, doesn't take chat.
1685
+ return "chat_not_here";
1485
1686
  case "zaisma:purchase":
1486
1687
  this.purchaseTold(playerId, message);
1487
1688
  return null;