@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/index.js CHANGED
@@ -7,6 +7,6 @@ export { WEBHOOK_LIMITS, verifyWebhook } from "./webhooks.js";
7
7
  export { DATABASE_LIMITS, DatabaseError } from "./database.js";
8
8
  export { OUTSIDE_CALL_LIMITS, OutsideCallError } from "./outside-calls.js";
9
9
  export { PASS_ISSUER_URL, PASS_TOKEN_TYPE, PASS_VERSION_NUMBER, PassError, RECEIPT_ISSUER_URL, RECEIPT_TOKEN_TYPE, RECEIPT_VERSION_NUMBER, ReceiptError, canChat, passesAreFriends, passesHaveBlockBetween, verifyPass, verifyReceipt } from "./pass.js";
10
- export { CHAT_CHECK_LIMITS, ChatCheckError, checkChat } from "./chat.js";
10
+ export { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS, ChatCheckError, checkChat } from "./chat.js";
11
11
  export { guestName, isRoomCode } from "./names.js";
12
12
  export { relay } from "./relay.js";
package/src/pass.d.ts CHANGED
@@ -28,9 +28,9 @@ export interface PassClaims {
28
28
  */
29
29
  who_can_chat?: WhoCanChat | (string & {});
30
30
  /**
31
- * For accounts that chat with anyone, the group they can chat in (chat_group_…): two such players
32
- * chat when their groups match (canChat). Opaque and different in every game, so it never says an
33
- * age; a game could guess which group is the younger one from its size.
31
+ * For accounts that chat at all (friends or anyone), the group they can chat in (chat_group_…): two
32
+ * players chat only when their groups match (canChat), friends included. Opaque and different in every
33
+ * game, so it never says an age; a game could guess which group is the younger one from its size.
34
34
  */
35
35
  chat_group_id?: string;
36
36
  /**
@@ -88,11 +88,12 @@ export declare function passesHaveBlockBetween(first: PassClaims, second: PassCl
88
88
  */
89
89
  export declare function passesAreFriends(first: PassClaims, second: PassClaims): boolean;
90
90
  /**
91
- * Whether two players may chat, by the passes your server checked (verifyPass): both accounts, no block
92
- * between them, neither letting nobody chat, and either friends or both letting anyone chat, in the same
93
- * chat group. Guests and accounts set to nobody never chat; accounts set to friends chat only with
94
- * friends (passesAreFriends). Use it on every message your own server delivers, text or voice; Zaisma's
95
- * chat check (checkChat) applies the same rule, and Zaisma rooms will do the same for you.
91
+ * Whether two players may chat, by the passes your server checked (verifyPass): both accounts in the same
92
+ * chat group, no block between them, neither letting nobody chat, and either friends or both letting
93
+ * anyone chat. Guests and accounts set to nobody never chat; accounts set to friends chat only with
94
+ * friends (passesAreFriends); friends in different chat groups don't chat. Use it on every message your
95
+ * own server delivers, text or voice; Zaisma's chat check (checkChat) applies the same rule, and so do
96
+ * rooms (room.chat), on Zaisma and in the runner.
96
97
  */
97
98
  export declare function canChat(first: PassClaims, second: PassClaims): boolean;
98
99
  export type PublicKey = JsonWebKey & {
package/src/pass.js CHANGED
@@ -44,11 +44,12 @@ export function passesAreFriends(first, second) {
44
44
  return lists(first, second) && lists(second, first);
45
45
  }
46
46
  /**
47
- * Whether two players may chat, by the passes your server checked (verifyPass): both accounts, no block
48
- * between them, neither letting nobody chat, and either friends or both letting anyone chat, in the same
49
- * chat group. Guests and accounts set to nobody never chat; accounts set to friends chat only with
50
- * friends (passesAreFriends). Use it on every message your own server delivers, text or voice; Zaisma's
51
- * chat check (checkChat) applies the same rule, and Zaisma rooms will do the same for you.
47
+ * Whether two players may chat, by the passes your server checked (verifyPass): both accounts in the same
48
+ * chat group, no block between them, neither letting nobody chat, and either friends or both letting
49
+ * anyone chat. Guests and accounts set to nobody never chat; accounts set to friends chat only with
50
+ * friends (passesAreFriends); friends in different chat groups don't chat. Use it on every message your
51
+ * own server delivers, text or voice; Zaisma's chat check (checkChat) applies the same rule, and so do
52
+ * rooms (room.chat), on Zaisma and in the runner.
52
53
  */
53
54
  export function canChat(first, second) {
54
55
  mustBePasses(first, second, "canChat");
@@ -59,9 +60,10 @@ export function canChat(first, second) {
59
60
  const isOpenTo = (whoCanChat) => whoCanChat === "friends" || whoCanChat === "anyone";
60
61
  if (!isOpenTo(first.who_can_chat) || !isOpenTo(second.who_can_chat))
61
62
  return false;
62
- if (passesAreFriends(first, second))
63
- return true;
64
- return first.who_can_chat === "anyone" && second.who_can_chat === "anyone" && typeof first.chat_group_id === "string" && first.chat_group_id === second.chat_group_id;
63
+ // Chat never leaves a chat group, friends included.
64
+ if (typeof first.chat_group_id !== "string" || first.chat_group_id !== second.chat_group_id)
65
+ return false;
66
+ return passesAreFriends(first, second) || (first.who_can_chat === "anyone" && second.who_can_chat === "anyone");
65
67
  }
66
68
  // Plain class fields (no TypeScript-only syntax) so Node can run this file directly.
67
69
  export class PassError extends Error {
package/src/room.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * running one copy of the game server. Names follow docs/NAMING.md.
8
8
  */
9
9
  import { type GameDatabase } from "./database.ts";
10
- import type { PlayerKind, ReceiptClaims } from "./pass.ts";
10
+ import type { PassClaims, PlayerKind, ReceiptClaims } from "./pass.ts";
11
11
  import type { Server } from "./requests.ts";
12
12
  import { type Schedule, type ScheduledRun } from "./schedules.ts";
13
13
  import { type StateChange } from "./state-sync.ts";
@@ -409,6 +409,13 @@ export interface GameServer<S = any> {
409
409
  * Off by default. The state goes as JSON, so keep it plain; cut what each player sees with stateView.
410
410
  */
411
411
  stateSyncEnabled?: boolean;
412
+ /**
413
+ * Check chat between adults (room.chat) with Zaisma's filter too: contact details, moving apps or
414
+ * meeting up, personal details and sexual content are stopped, as they always are for under-18s. Off by
415
+ * default, when chat between adults is only flagged (hasSwearing, hasSlur) and your game decides. Each
416
+ * message the filter's AI looks at counts on your hosting meter (adult_chat_checks).
417
+ */
418
+ adultChatFilterEnabled?: boolean;
412
419
  /**
413
420
  * How often a real-time room saves its state, in seconds: 1 to 60, default 5. A restarted room comes
414
421
  * back with the last saved state (onRestored). Turn-based rooms save after every change and ignore it.
@@ -473,6 +480,16 @@ export interface GameServer<S = any> {
473
480
  * joinInfo.fromRoomCode, and not again when they arrive.
474
481
  */
475
482
  canJoin?(state: S, player: Player, room: Room, joinData: unknown, joinInfo: JoinInfo): JoinAnswer | Promise<JoinAnswer>;
483
+ /**
484
+ * A player sent a chat message (room.chat in their game), before Zaisma's filter checks it: answer true
485
+ * to send it, false to stop it (the sender's room.chat fails with chat_stopped, reason stopped_by_game),
486
+ * or { toPlayerIds } to send it to only some of chatInfo.toPlayerIds, like a team chat or a player your
487
+ * moderators muted. chatInfo.toPlayerIds are the players the sender's game sent it to; whatever you
488
+ * answer, it reaches only those of them who may chat with the sender, never anyone else. Only decide
489
+ * here, as in canJoin: room can be read, not changed. Without onChat every message goes on to the
490
+ * filter. If it throws or answers anything else, the message is stopped and the mistake is logged.
491
+ */
492
+ onChat?(state: S, player: Player, room: Room, chatInfo: ChatInfo): ChatAnswer | Promise<ChatAnswer>;
476
493
  /** After room.moveTo: who the other room took, and who it turned away and why. */
477
494
  onMoved?(state: S, moveResult: MoveResult, room: Room): HandlerResult<S>;
478
495
  /**
@@ -581,6 +598,29 @@ export declare function restoreInfoOf(savedAtMs: number, nowMs?: number, savedVe
581
598
  export type JoinAnswer = boolean | {
582
599
  refusalMessage: string;
583
600
  };
601
+ /** A chat message as onChat sees it: what the player typed, and the players their game sent it to. */
602
+ export interface ChatInfo {
603
+ text: string;
604
+ toPlayerIds: string[];
605
+ }
606
+ /**
607
+ * What onChat answers: true sends the message on to Zaisma's filter; false stops it; { toPlayerIds }
608
+ * sends it to only those of chatInfo.toPlayerIds (it can narrow who reads it, never widen it).
609
+ */
610
+ export type ChatAnswer = boolean | {
611
+ toPlayerIds: string[];
612
+ };
613
+ /** What the sender hears when the game server's onChat stopped their message. */
614
+ export declare const STOPPED_BY_GAME_MESSAGE = "Your message wasn't sent.";
615
+ /** 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. */
616
+ export declare function readChatAnswer(answer: unknown): {
617
+ isAllowed: true;
618
+ toPlayerIds: string[] | null;
619
+ } | {
620
+ isAllowed: false;
621
+ } | {
622
+ mistake: GameServerError;
623
+ };
584
624
  /**
585
625
  * canJoin's answer as the room acts on it: let in, or turn away with a refusalMessage (null for the
586
626
  * usual one). Answering nothing or anything else is a mistake (bad_handler_answer), described for the game server's errors.
@@ -594,7 +634,7 @@ export declare function readJoinAnswer(answer: unknown): {
594
634
  mistake: GameServerError;
595
635
  };
596
636
  /** Everything a game server object may have, in the order the docs give them. Anything else is refused. */
597
- export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
637
+ export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "adultChatFilterEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onChat", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
598
638
  export declare const ROOM_LIMITS: {
599
639
  /** Players in one room: what one room carries at 60 ticks a second (test/load/room-load.test.ts). */
600
640
  readonly maximumPlayerCount: 100;
@@ -774,6 +814,13 @@ export interface RoomSettings {
774
814
  * isSpectator: true, and they get the players, the state and every message sent to everyone, but they send
775
815
  * nothing: only probe answers, and anything else gets spectator_cannot_send. welcome and every "room"
776
816
  * message carry spectatorCount, and a "room" message goes out whenever it changes.
817
+ *
818
+ * Chat (room.chat): a client sends {type: "chat", chatNumber, text, toPlayerIds?}, which the room's host
819
+ * handles before the game's messages (readChatMessage, then startChat, Zaisma's filter, and deliverChat
820
+ * or stopChat). Each player it reaches gets {type: "chat", fromPlayerId, text, hasSwearing, hasSlur,
821
+ * sentAt}; the sender gets {type: "chat_sent", chatNumber, sentAt}, {type: "chat_stopped", chatNumber,
822
+ * reason, message}, or an error with its chatNumber (chat_not_allowed, chat_empty, chat_too_long,
823
+ * chat_too_fast). chat_sent never says who read it. welcome says canChat: whether this player can chat at all.
777
824
  */
778
825
  export type ServerMessage = {
779
826
  type: "welcome";
@@ -787,6 +834,7 @@ export type ServerMessage = {
787
834
  endsAt: string | null;
788
835
  probeIntervalSec: number | null;
789
836
  protocolVersionNumber: number;
837
+ canChat: boolean;
790
838
  } | {
791
839
  type: "probe";
792
840
  probeNumber: number;
@@ -835,11 +883,28 @@ export type ServerMessage = {
835
883
  } | {
836
884
  type: "blocks";
837
885
  playerIds: string[];
886
+ } | {
887
+ type: "chat";
888
+ fromPlayerId: string;
889
+ text: string;
890
+ hasSwearing: boolean;
891
+ hasSlur: boolean;
892
+ sentAt: string;
893
+ } | {
894
+ type: "chat_sent";
895
+ chatNumber: number;
896
+ sentAt: string;
897
+ } | {
898
+ type: "chat_stopped";
899
+ chatNumber: number;
900
+ reason: string;
901
+ message: string;
838
902
  } | {
839
903
  type: "error";
840
904
  code: RoomErrorCode;
841
905
  message: string;
842
906
  versionId?: string;
907
+ chatNumber?: number;
843
908
  } | {
844
909
  type: "ended";
845
910
  message: string;
@@ -863,6 +928,11 @@ export type ClientMessage = {
863
928
  } | {
864
929
  type: "message";
865
930
  data: unknown;
931
+ } | {
932
+ type: "chat";
933
+ chatNumber: number;
934
+ text: string;
935
+ toPlayerIds?: string[];
866
936
  } | {
867
937
  type: "zaisma:purchase";
868
938
  purchaseId: string;
@@ -902,7 +972,7 @@ export type ClientMessage = {
902
972
  * the game became unavailable, its kept room was deleted, or no players were left for its spectators.
903
973
  */
904
974
  export type EndReason = "game" | "hosting_paused" | "game_server_stopped" | "game_unavailable" | "kept_room_deleted" | "no_players";
905
- export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full";
975
+ export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full" | "chat_not_here" | "chat_not_allowed" | "chat_empty" | "chat_too_long" | "chat_too_fast";
906
976
  export declare const ERROR_TEXT: Record<RoomErrorCode, string>;
907
977
  /** What players are told when Zaisma ends a match at room.endsAt. */
908
978
  export declare 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.";
@@ -928,6 +998,8 @@ export interface SavedRoom extends RoomSettings {
928
998
  sessionBlocks?: [string, string][];
929
999
  /** Friendships between the room's players, as pairs of player IDs. */
930
1000
  friendships?: [string, string][];
1001
+ /** Who each account player the room remembers may chat with, by player ID. */
1002
+ chatSettings?: Record<string, PlayerChatSettings>;
931
1003
  /** When Zaisma will end the match (room.endsAt), once it's set. */
932
1004
  endsAt?: string;
933
1005
  /** Kept (room.setKept): it sleeps with its state when everyone leaves, instead of going a day later. */
@@ -1011,7 +1083,33 @@ export declare function checkRoomData(given: unknown, maximumSizeBytes: number):
1011
1083
  */
1012
1084
  export declare function errorMessage(code: RoomErrorCode, message?: string, details?: {
1013
1085
  versionId: string;
1086
+ } | {
1087
+ chatNumber: number;
1014
1088
  }): string;
1089
+ /**
1090
+ * Who an account player may chat with, from their pass (who_can_chat and chat_group_id): kept by the
1091
+ * room's host, never shown to the game. A guest has none and chats with nobody.
1092
+ */
1093
+ export interface PlayerChatSettings {
1094
+ whoCanChat: "nobody" | "friends" | "anyone";
1095
+ chatGroupId: string | null;
1096
+ }
1097
+ /** A checked pass's chat settings, for RoomCore.setChatSettings: null for a guest. A setting this version doesn't know chats with nobody. */
1098
+ export declare function chatSettingsOf(claims: PassClaims): PlayerChatSettings | null;
1099
+ /** A chat message from a client as readChatMessage found it, for startChat to check. */
1100
+ export interface ChatClientMessage {
1101
+ chatNumber: number;
1102
+ text: unknown;
1103
+ toPlayerIds: unknown;
1104
+ sizeBytes: number;
1105
+ }
1106
+ /** Room traffic of chat, in the room's message types. */
1107
+ export declare const CHAT_MESSAGE_TYPE = "(chat)";
1108
+ /**
1109
+ * A client's chat message ({type: "chat"}), or null for any other text, which goes to receive. The
1110
+ * room's host handles chat before receive, since Zaisma's filter runs between the room and the players.
1111
+ */
1112
+ export declare function readChatMessage(text: string): ChatClientMessage | null;
1015
1113
  /** Whether a player may come in, and if not, why. */
1016
1114
  export type Admission = {
1017
1115
  isAllowed: true;
@@ -1163,6 +1261,12 @@ export declare class RoomCore<S = any> {
1163
1261
  private readonly sessionBlocks;
1164
1262
  /** Friendships between players from their accounts (Zaisma's own record, or passes on your own server): pairKey(first, second). */
1165
1263
  private readonly friendships;
1264
+ /** Who each account player may chat with, from their pass: never in Player, so games never see it. */
1265
+ private readonly chatSettings;
1266
+ /** When each player sent their chat messages in the last CHAT_ROOM_LIMITS.chatWindowDurationMs. */
1267
+ private readonly chatSentAtMs;
1268
+ /** Each player's last chat messages, for Zaisma's filter to read pieces together: in memory only. */
1269
+ private readonly recentChats;
1166
1270
  /** The blocks each player was last told of, so they're told again only when theirs change. */
1167
1271
  private readonly toldBlocks;
1168
1272
  /** When each player in the match was last heard from, when their game was hidden, and the idle check their game asked for. */
@@ -1190,6 +1294,8 @@ export declare class RoomCore<S = any> {
1190
1294
  private readonly api;
1191
1295
  /** The room as canJoin and stateView see it: reading works, and any call that changes or sends throws room_call_unavailable. */
1192
1296
  private readonly canJoinRoom;
1297
+ /** The room as onChat sees it: reading only, like canJoin. */
1298
+ private readonly chatRoom;
1193
1299
  private readonly stateViewRoom;
1194
1300
  readonly server: GameServer<S>;
1195
1301
  private readonly transport;
@@ -1259,6 +1365,45 @@ export declare class RoomCore<S = any> {
1259
1365
  * room knew of that player's, so a friend removed since is gone. Call before join.
1260
1366
  */
1261
1367
  setFriends(playerId: string, friendPlayerIds: readonly string[]): void;
1368
+ /** A player's chat settings from their pass, before join: null for a guest, who chats with nobody. */
1369
+ setChatSettings(playerId: string, settings: PlayerChatSettings | null): void;
1370
+ /** Whether a player can chat at all: an account whose setting isn't nobody, with a chat group. */
1371
+ canChat(playerId: string): boolean;
1372
+ /**
1373
+ * Whether one player's chat may reach another, canChat's rule on passes (pass.ts): two accounts in the
1374
+ * same chat group with no block either way, friends unless both chat with anyone.
1375
+ */
1376
+ private mayChat;
1377
+ /** 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. */
1378
+ chatReceivers(senderPlayerId: string, toPlayerIds?: readonly string[]): string[];
1379
+ /**
1380
+ * A chat message from a player (readChatMessage), checked by the room: the sender, the text, their pace,
1381
+ * then the game server's onChat with the players their game sent it to. When it goes no further the room
1382
+ * answers the sender itself (an error, or chat_stopped with stopped_by_game) and this is null. Otherwise
1383
+ * it's the text, who may read it (chatReceivers, which may be nobody) and the sender's last messages, for
1384
+ * the host to run Zaisma's filter and then deliverChat or stopChat.
1385
+ */
1386
+ startChat(playerId: string, chat: ChatClientMessage): Promise<{
1387
+ text: string;
1388
+ receiverPlayerIds: string[];
1389
+ recentTexts: string[];
1390
+ } | null>;
1391
+ /**
1392
+ * Send a chat message Zaisma's filter let through to its receivers, and tell the sender it went
1393
+ * (chat_sent), even when it reached nobody: the sender never learns who can't read them.
1394
+ */
1395
+ deliverChat(senderPlayerId: string, chatNumber: number, receiverPlayerIds: readonly string[], chat: {
1396
+ text: string;
1397
+ hasSwearing: boolean;
1398
+ hasSlur: boolean;
1399
+ }): void;
1400
+ /** Tell the sender their chat message was stopped: reason is the filter's, or stopped_by_game; message is what they see. */
1401
+ stopChat(senderPlayerId: string, chatNumber: number, reason: string, message: string): void;
1402
+ /** A player's chat messages from the last CHAT_CHECK_LIMITS.recentTextDurationMs, oldest first, up to recentTextCount. */
1403
+ recentChatTexts(playerId: string): string[];
1404
+ private rememberChat;
1405
+ /** Whether a player may send another chat message: CHAT_ROOM_LIMITS.maximumChatMessageCount in any chatWindowDurationMs. */
1406
+ private takeChat;
1262
1407
  /** Whether two players have a block between them, either way: from their accounts, or one a player's game said it made. */
1263
1408
  hasBlockBetween(firstPlayerId: string, secondPlayerId: string): boolean;
1264
1409
  /**