@zaisma/game-server 1.12.0 → 1.14.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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Players from outside Zaisma, for a game server on your own server: a game played anywhere but its Zaisma
3
+ * page (exported, on another site, wrapped for a store) has no Zaisma pass, so its game sends a playerToken
4
+ * instead (Zaisma.setUpOffZaisma) and your server says who that is with startServer's identifyPlayer. Such a
5
+ * player joins with kind "outside_zaisma", in the same rooms as players from the Zaisma page.
6
+ *
7
+ * signInWithZaismaPlayers makes an identifyPlayer for players signed in with Zaisma (Sign in with Zaisma's ID
8
+ * tokens): the same player ID and name as on Zaisma. guestPlayer is who a player with no token is, when
9
+ * startServer's outsideGuestsEnabled lets them in: a temporary ID and a generated name, like Zaisma's guests.
10
+ */
11
+ import { guestName } from "./names.js";
12
+ import { fromBase64Url } from "./pass.js";
13
+ import { GameServerError, ROOM_LIMITS, tidyRoomInfo } from "./room.js";
14
+ export const OUTSIDE_PLAYER_LIMITS = {
15
+ maximumIdCharacterCount: 100,
16
+ maximumSignInAppCount: 20,
17
+ /** How far an ID token's exp may be past, for clocks that differ a little. */
18
+ clockLeewaySec: 60,
19
+ /** How long the keys and profiles are kept before they're fetched again. */
20
+ keysKeptMs: 3600_000,
21
+ profileKeptMs: 300_000,
22
+ /** A refresh for a key ID it doesn't know waits this long after the last fetch, so made-up key IDs can't make it fetch on every token. */
23
+ minimumRefreshIntervalMs: 30_000,
24
+ maximumProfileCount: 10_000,
25
+ };
26
+ const RS256 = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
27
+ const SIGN_IN_APP_ID = /^sign_in_app_[a-z0-9]+$/;
28
+ const decoder = new TextDecoder();
29
+ const encoder = new TextEncoder();
30
+ /** The ID token keys at <siteUrl>/.well-known/jwks.json, kept an hour; a fetch that fails keeps the keys already held. */
31
+ function keysAt(jwksUrl) {
32
+ let kept = null;
33
+ let fetchedAtMs = 0;
34
+ return async (isRefreshing) => {
35
+ const nowMs = Date.now();
36
+ if (kept && !isRefreshing && nowMs - kept.fetchedAtMs < OUTSIDE_PLAYER_LIMITS.keysKeptMs)
37
+ return kept.keys;
38
+ if (kept && isRefreshing && nowMs - fetchedAtMs < OUTSIDE_PLAYER_LIMITS.minimumRefreshIntervalMs)
39
+ return kept.keys;
40
+ fetchedAtMs = nowMs;
41
+ try {
42
+ const response = await fetch(jwksUrl);
43
+ if (!response.ok)
44
+ throw new Error(`Couldn't load Zaisma's sign-in keys from ${jwksUrl}`);
45
+ kept = { keys: (await response.json()).keys, fetchedAtMs: nowMs };
46
+ }
47
+ catch (error) {
48
+ if (kept)
49
+ return kept.keys;
50
+ throw error;
51
+ }
52
+ return kept.keys;
53
+ };
54
+ }
55
+ /** Accounts' public profiles at Zaisma (https://rooms.zaisma.com/v1/players/<id>), each kept 5 minutes; null when no account has that ID. */
56
+ function profilesAt(profilesUrl) {
57
+ const kept = new Map();
58
+ return async (playerId) => {
59
+ const found = kept.get(playerId);
60
+ if (found && Date.now() - found.fetchedAtMs < OUTSIDE_PLAYER_LIMITS.profileKeptMs)
61
+ return found.profile;
62
+ const response = await fetch(`${profilesUrl}/${encodeURIComponent(playerId)}`);
63
+ if (response.status !== 200 && response.status !== 404)
64
+ throw new Error(`Couldn't look up player ${playerId} at ${profilesUrl}`);
65
+ const profile = response.status === 200 ? (await response.json()).player : null;
66
+ if (kept.size > OUTSIDE_PLAYER_LIMITS.maximumProfileCount)
67
+ kept.clear();
68
+ kept.set(playerId, { profile, fetchedAtMs: Date.now() });
69
+ return profile;
70
+ };
71
+ }
72
+ /** A token's header and claims, unchecked, or null when it isn't a JWT. */
73
+ function tokenParts(token) {
74
+ const parts = token.split(".");
75
+ if (parts.length !== 3)
76
+ return null;
77
+ try {
78
+ const header = JSON.parse(decoder.decode(fromBase64Url(parts[0])));
79
+ const claims = JSON.parse(decoder.decode(fromBase64Url(parts[1])));
80
+ if (!header || typeof header !== "object" || !claims || typeof claims !== "object" || Array.isArray(claims))
81
+ return null;
82
+ return { header, claims, signature: fromBase64Url(parts[2]) };
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * An identifyPlayer for players signed in with Zaisma: the game signs them in with Sign in with Zaisma and
90
+ * sends the ID token as its playerToken. The token is checked like any OpenID Connect ID token: RS256 only,
91
+ * signed by Zaisma's published key, its iss the siteUrl, its aud one of signInAppIds, not expired. The player
92
+ * is { id, name }: the account's Zaisma player ID, the same as on Zaisma, and its name from the token (or its
93
+ * public profile when the token has none). Answers null for any token that doesn't check out, and for an
94
+ * account deleted since; throws only when Zaisma's keys or profiles can't be reached (the runner answers busy).
95
+ *
96
+ * startServer({ gameServer, gameSlug, identifyPlayer: signInWithZaismaPlayers({ signInAppIds: ["sign_in_app_8f3k2m"] }) });
97
+ */
98
+ export function signInWithZaismaPlayers(options) {
99
+ const fields = ["signInAppIds", "siteUrl", "loadKeys", "loadProfile"];
100
+ if (!options || typeof options !== "object")
101
+ throw new GameServerError("bad_option", "options", "signInWithZaismaPlayers takes one object of options.", 'Call signInWithZaismaPlayers({ signInAppIds: ["sign_in_app_…"] }).');
102
+ for (const field of Object.keys(options)) {
103
+ if (!fields.includes(field))
104
+ throw new GameServerError("unknown_option", field, `${field} isn't a signInWithZaismaPlayers option.`, `signInWithZaismaPlayers takes: ${fields.join(", ")}.`);
105
+ }
106
+ const { signInAppIds, siteUrl = "https://zaisma.com" } = options;
107
+ if (!Array.isArray(signInAppIds) || signInAppIds.length < 1 || signInAppIds.length > OUTSIDE_PLAYER_LIMITS.maximumSignInAppCount || !signInAppIds.every((id) => typeof id === "string" && SIGN_IN_APP_ID.test(id))) {
108
+ throw new GameServerError("bad_option", "signInAppIds", "signInAppIds isn't a list of sign-in app IDs.", `Give 1 to ${OUTSIDE_PLAYER_LIMITS.maximumSignInAppCount} of your sign-in apps' IDs, like ["sign_in_app_8f3k2m"] (list_sign_in_apps shows them).`);
109
+ }
110
+ if (typeof siteUrl !== "string" || !URL.canParse(siteUrl))
111
+ throw new GameServerError("bad_option", "siteUrl", "siteUrl isn't a URL.", 'Give where Sign in with Zaisma runs, like "https://zaisma.com".');
112
+ for (const field of ["loadKeys", "loadProfile"]) {
113
+ if (options[field] !== undefined && typeof options[field] !== "function")
114
+ throw new GameServerError("bad_option", field, `${field} isn't a function.`, field === "loadKeys" ? "Give async (isRefreshing) => the keys, as a list." : "Give async (playerId) => { name } or null.");
115
+ }
116
+ const loadKeys = options.loadKeys ?? keysAt(`${siteUrl}/.well-known/jwks.json`);
117
+ const loadProfile = options.loadProfile ?? profilesAt("https://rooms.zaisma.com/v1/players");
118
+ return async (playerToken) => {
119
+ const parts = typeof playerToken === "string" ? tokenParts(playerToken) : null;
120
+ // An ID token, never an access token (at+jwt) or a token signed any other way.
121
+ if (!parts || parts.header.alg !== "RS256" || parts.header.typ !== "JWT" || typeof parts.header.kid !== "string")
122
+ return null;
123
+ const { header, claims } = parts;
124
+ const nowSec = Math.floor(Date.now() / 1000);
125
+ if (claims.iss !== siteUrl || typeof claims.aud !== "string" || !signInAppIds.includes(claims.aud))
126
+ return null;
127
+ if (typeof claims.exp !== "number" || claims.exp + OUTSIDE_PLAYER_LIMITS.clockLeewaySec <= nowSec)
128
+ return null;
129
+ if (typeof claims.sub !== "string" || !claims.sub || claims.sub.length > OUTSIDE_PLAYER_LIMITS.maximumIdCharacterCount)
130
+ return null;
131
+ // A key published since the keys were fetched: fetched again once.
132
+ let jwk = (await loadKeys(false)).find((key) => key.kid === header.kid);
133
+ if (!jwk)
134
+ jwk = (await loadKeys(true)).find((key) => key.kid === header.kid);
135
+ if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e)
136
+ return null;
137
+ const key = await crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e }, RS256, false, ["verify"]);
138
+ const [signedHeader, signedClaims] = playerToken.split(".");
139
+ if (!(await crypto.subtle.verify(RS256, key, parts.signature, encoder.encode(`${signedHeader}.${signedClaims}`))))
140
+ return null;
141
+ // A token without the profile scope carries no name: the account's public one. A deleted account has none, and plays no more.
142
+ const name = typeof claims.name === "string" && claims.name ? claims.name : ((await loadProfile(claims.sub))?.name ?? null);
143
+ return name ? { id: claims.sub, name } : null;
144
+ };
145
+ }
146
+ /** A player with no token, when startServer's outsideGuestsEnabled lets them in: a temporary player ID and a generated name, as Zaisma's guests have. */
147
+ export function guestPlayer() {
148
+ const randomPart = Array.from(crypto.getRandomValues(new Uint8Array(12)), (byte) => byte.toString(36).padStart(2, "0")).join("").slice(0, 20);
149
+ return { id: `player_${randomPart}`, name: guestName() };
150
+ }
151
+ /** What identifyPlayer answered, checked for the runner: the player, with their name tidied, or what was wrong with it. */
152
+ export function checkedOutsidePlayer(given) {
153
+ if (!given || typeof given !== "object" || Array.isArray(given))
154
+ return { mistake: "identifyPlayer answered something other than { id, name } or null." };
155
+ const { id, name } = given;
156
+ if (typeof id !== "string" || !id || id.length > OUTSIDE_PLAYER_LIMITS.maximumIdCharacterCount)
157
+ return { mistake: `identifyPlayer's id isn't text of 1 to ${OUTSIDE_PLAYER_LIMITS.maximumIdCharacterCount} characters.` };
158
+ const tidied = typeof name === "string" ? tidyRoomInfo({ name }).name : null;
159
+ if (typeof name !== "string" || !tidied || name.trim().length > ROOM_LIMITS.maximumNameCharacterCount)
160
+ return { mistake: `identifyPlayer's name isn't text of 1 to ${ROOM_LIMITS.maximumNameCharacterCount} characters.` };
161
+ return { player: { id, name: tidied } };
162
+ }
package/src/pass.d.ts CHANGED
@@ -62,8 +62,12 @@ export interface PassClaims {
62
62
  iat: number;
63
63
  exp: number;
64
64
  }
65
- /** A signed-in Zaisma account, or a guest. */
66
- export type PlayerKind = "guest" | "account";
65
+ /**
66
+ * A signed-in Zaisma account, a guest, or a player from outside Zaisma (outside_zaisma): one your own server let
67
+ * in without a Zaisma pass, by its identifyPlayer (signed in with Zaisma, or your game's own accounts) or as a
68
+ * guest from elsewhere (outsideGuestsEnabled). Zaisma's rooms and passes never give outside_zaisma.
69
+ */
70
+ export type PlayerKind = "guest" | "account" | "outside_zaisma";
67
71
  /** No chat, friends only, or anyone in the same chat group: the player's "Who can chat with you" setting (docs/DECISIONS.md, "Social settings, with the social modes as presets"). */
68
72
  export type WhoCanChat = "nobody" | "friends" | "anyone";
69
73
  /** A pass's JWS header typ (RFC 8725 explicit typing): verifyPass refuses any other token (wrong_kind). */
@@ -117,6 +121,7 @@ export declare const RECEIPT_TOKEN_TYPE = "receipt+jwt";
117
121
  export declare const RECEIPT_ISSUER_URL = "https://rooms.zaisma.com/receipts";
118
122
  /** The receipt contract this copy reads: verifyReceipt refuses any other receipt_version_number (wrong_version). */
119
123
  export declare const RECEIPT_VERSION_NUMBER = 1;
124
+ export declare function fromBase64Url(text: string): Uint8Array<ArrayBuffer>;
120
125
  /** Make a new signing key, as a JWK string for a signing secret (the rooms Worker's PASS_SIGNING_KEY and RECEIPT_SIGNING_KEY). */
121
126
  export declare function generateSigningKey(): Promise<string>;
122
127
  export interface SigningKey {
package/src/pass.js CHANGED
@@ -96,7 +96,7 @@ function toBase64Url(bytes) {
96
96
  binary += String.fromCharCode(byte);
97
97
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
98
98
  }
99
- function fromBase64Url(text) {
99
+ export function fromBase64Url(text) {
100
100
  const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
101
101
  return Uint8Array.from(binary, (character) => character.charCodeAt(0));
102
102
  }
package/src/requests.d.ts CHANGED
@@ -11,10 +11,11 @@
11
11
  */
12
12
  import type { GameDatabase } from "./database.ts";
13
13
  import type { ReceiptClaims } from "./pass.ts";
14
- import type { Player } from "./room.ts";
14
+ import type { KickOptions, LiveRoom, Player, RoomPlayer, RoomSelection } from "./room.ts";
15
15
  /**
16
16
  * What onRequest, onSchedule and onWebhook reach besides what they're given: the game's own database, the
17
- * same one its rooms use, its secrets, and receipt checks.
17
+ * same one its rooms use, its secrets, receipt checks, and the game's live rooms. Room messages sent from here
18
+ * reach rooms with senderKind "server"; on your own server they reach the Node runner's own rooms.
18
19
  */
19
20
  export interface Server {
20
21
  database: GameDatabase;
@@ -25,6 +26,31 @@ export interface Server {
25
26
  secrets: Readonly<Record<string, string>>;
26
27
  /** Check a purchase's receipt, as room.verifyReceipt does: the runtime fills in Zaisma's keys and this game. */
27
28
  verifyReceipt(receipt: string): Promise<ReceiptClaims>;
29
+ /** Send a message to one of the game's live rooms by its code, as room.sendToRoom does. isDelivered is false when no such room is running or it's busy. */
30
+ sendToRoom(roomCode: string, message: unknown): Promise<{
31
+ isDelivered: boolean;
32
+ }>;
33
+ /** Send a message to every live room of the game with someone in it, or those holding every label given, as room.sendToEveryRoom does. */
34
+ sendToEveryRoom(message: unknown, roomSelection?: RoomSelection): Promise<{
35
+ roomCount: number;
36
+ }>;
37
+ /** The game's live rooms, or those holding every label given, newest first, up to 500. */
38
+ listRooms(roomSelection?: RoomSelection): Promise<{
39
+ rooms: LiveRoom[];
40
+ }>;
41
+ /** One live room and who's in it, players then spectators, or null when no such room is running. */
42
+ getRoom(roomCode: string): Promise<{
43
+ room: LiveRoom;
44
+ players: RoomPlayer[];
45
+ } | null>;
46
+ /** Remove a player from a live room, as room.kick does there: its onLeave hears reason "kicked". isDone is false when the room or the player isn't there. */
47
+ kickFromRoom(roomCode: string, playerId: string, kickOptions?: KickOptions): Promise<{
48
+ isDone: boolean;
49
+ }>;
50
+ /** End a live room's match, as room.end does there: its players hear message. isDone is false when no such room is running. */
51
+ endRoom(roomCode: string, message?: string): Promise<{
52
+ isDone: boolean;
53
+ }>;
28
54
  }
29
55
  export declare const REQUEST_LIMITS: {
30
56
  /** A request, as JSON. */
@@ -34,12 +60,14 @@ export declare const REQUEST_LIMITS: {
34
60
  * items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
35
61
  */
36
62
  readonly maximumPassSizeBytes: number;
63
+ /** A game played off Zaisma sends a playerToken in place of a pass (Zaisma.setUpOffZaisma), to a game server on your own server. */
64
+ readonly maximumPlayerTokenSizeBytes: number;
37
65
  /** What onRequest answers, as JSON. */
38
66
  readonly maximumAnswerSizeBytes: number;
39
67
  /** How long onRequest may take, awaiting the database included, before the request fails. */
40
68
  readonly answerTimeoutSec: 10;
41
69
  };
42
- export type RequestErrorCode = "bad_request" | "too_big" | "bad_pass" | "account_needed" | "game_not_open_to_player" | "game_unavailable" | "no_request_handler" | "game_server_error" | "hosting_paused" | "slow_down" | "bad_origin" | "busy";
70
+ export type RequestErrorCode = "bad_request" | "too_big" | "bad_pass" | "bad_player_token" | "account_needed" | "game_not_open_to_player" | "game_unavailable" | "no_request_handler" | "game_server_error" | "hosting_paused" | "slow_down" | "bad_origin" | "busy";
43
71
  export declare const REQUEST_ERROR_TEXT: Record<RequestErrorCode, string>;
44
72
  /** The HTTP status each request error answers with. */
45
73
  export declare const REQUEST_ERROR_HTTP_STATUS: Record<RequestErrorCode, number>;
@@ -52,7 +80,9 @@ export declare function requestError(code: RequestErrorCode, message?: string):
52
80
  };
53
81
  /**
54
82
  * Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
55
- * (nothing is null). The pass's own size doesn't count.
83
+ * (nothing is null). The pass's own size doesn't count. A game server on its developer's own server
84
+ * (isPlayerTokenTaken) also takes { playerToken, request } from a game played off Zaisma: its token as text,
85
+ * or null for a guest. Zaisma's rooms never do.
56
86
  */
57
87
  export declare function readRequestBody(text: string): {
58
88
  pass: string;
@@ -60,6 +90,17 @@ export declare function readRequestBody(text: string): {
60
90
  } | {
61
91
  code: "bad_request" | "too_big";
62
92
  };
93
+ export declare function readRequestBody(text: string, isPlayerTokenTaken: boolean): {
94
+ pass: string;
95
+ request: unknown;
96
+ } | {
97
+ playerToken: string | null;
98
+ request: unknown;
99
+ } | {
100
+ code: "bad_request" | "too_big";
101
+ };
102
+ /** A playerToken as a game off Zaisma sends it: text up to REQUEST_LIMITS.maximumPlayerTokenSizeBytes, or null for a guest. */
103
+ export declare function isPlayerToken(given: unknown): given is string | null;
63
104
  /**
64
105
  * What onRequest answered, as the game gets it: plain JSON (nothing is null), within
65
106
  * REQUEST_LIMITS.maximumAnswerSizeBytes, or a mistake for the game server's errors.
package/src/requests.js CHANGED
@@ -17,6 +17,8 @@ export const REQUEST_LIMITS = {
17
17
  * items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
18
18
  */
19
19
  maximumPassSizeBytes: 96 * 1024,
20
+ /** A game played off Zaisma sends a playerToken in place of a pass (Zaisma.setUpOffZaisma), to a game server on your own server. */
21
+ maximumPlayerTokenSizeBytes: 8 * 1024,
20
22
  /** What onRequest answers, as JSON. */
21
23
  maximumAnswerSizeBytes: 1024 * 1024,
22
24
  /** How long onRequest may take, awaiting the database included, before the request fails. */
@@ -26,6 +28,7 @@ export const REQUEST_ERROR_TEXT = {
26
28
  bad_request: "Requests are JSON with the player's pass and the game's request: { pass, request }.",
27
29
  too_big: `Requests are up to ${REQUEST_LIMITS.maximumRequestSizeBytes / 1024} KB.`,
28
30
  bad_pass: "Your player pass isn't valid for this game.",
31
+ bad_player_token: "This server doesn't know this player. Sign in again.",
29
32
  account_needed: "Playing online needs a Zaisma account. Sign in on the game's page to play.",
30
33
  game_not_open_to_player: "This game isn't open to this account.",
31
34
  game_unavailable: "This game isn't available right now.",
@@ -41,6 +44,7 @@ export const REQUEST_ERROR_HTTP_STATUS = {
41
44
  bad_request: 400,
42
45
  too_big: 413,
43
46
  bad_pass: 401,
47
+ bad_player_token: 401,
44
48
  account_needed: 403,
45
49
  game_not_open_to_player: 403,
46
50
  game_unavailable: 404,
@@ -55,11 +59,7 @@ export const REQUEST_ERROR_HTTP_STATUS = {
55
59
  export function requestError(code, message) {
56
60
  return { error: { code, message: message || REQUEST_ERROR_TEXT[code] } };
57
61
  }
58
- /**
59
- * Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
60
- * (nothing is null). The pass's own size doesn't count.
61
- */
62
- export function readRequestBody(text) {
62
+ export function readRequestBody(text, isPlayerTokenTaken = false) {
63
63
  let body;
64
64
  try {
65
65
  body = JSON.parse(text);
@@ -69,13 +69,20 @@ export function readRequestBody(text) {
69
69
  }
70
70
  if (!body || typeof body !== "object" || Array.isArray(body))
71
71
  return { code: "bad_request" };
72
- const { pass, request } = body;
73
- if (typeof pass !== "string" || pass.length > REQUEST_LIMITS.maximumPassSizeBytes)
72
+ const { pass, playerToken, request } = body;
73
+ const isOutside = isPlayerTokenTaken && pass === undefined && isPlayerToken(playerToken);
74
+ if (!isOutside && (typeof pass !== "string" || pass.length > REQUEST_LIMITS.maximumPassSizeBytes))
74
75
  return { code: "bad_request" };
75
76
  const requestText = request === undefined ? "null" : JSON.stringify(request);
76
77
  if (new TextEncoder().encode(requestText).length > REQUEST_LIMITS.maximumRequestSizeBytes)
77
78
  return { code: "too_big" };
78
- return { pass, request: request === undefined ? null : request };
79
+ if (isOutside)
80
+ return { playerToken: playerToken, request: request === undefined ? null : request };
81
+ return { pass: pass, request: request === undefined ? null : request };
82
+ }
83
+ /** A playerToken as a game off Zaisma sends it: text up to REQUEST_LIMITS.maximumPlayerTokenSizeBytes, or null for a guest. */
84
+ export function isPlayerToken(given) {
85
+ return given === null || (typeof given === "string" && given.length > 0 && new TextEncoder().encode(given).length <= REQUEST_LIMITS.maximumPlayerTokenSizeBytes);
79
86
  }
80
87
  /**
81
88
  * What onRequest answered, as the game gets it: plain JSON (nothing is null), within
@@ -8,6 +8,8 @@ export interface RoomCallLimits {
8
8
  maximumLabelNameCharacterCount: number;
9
9
  maximumLabelValueCharacterCount: number;
10
10
  maximumLabelCount: number;
11
+ /** A room message (room.sendToRoom, room.sendToEveryRoom) as JSON: ROOM_MESSAGE_LIMITS has it. */
12
+ maximumRoomMessageSizeBytes: number;
11
13
  }
12
14
  /** The error a mistake in a room call throws: GameServerError, or the sandbox's copy of it. */
13
15
  export type RoomCallErrorClass = new (code: "bad_room_call" | "room_call_unavailable", field: string, message: string, fix: string) => Error;
@@ -65,6 +67,20 @@ export interface RoomCallChecks {
65
67
  labels?: Record<string, string>;
66
68
  };
67
69
  checkEnd(message: unknown): string | undefined;
70
+ /** room.sendToRoom: another room's code (never this room's) and the message as plain JSON within the size, copied. */
71
+ checkSendToRoom(toRoomCode: unknown, message: unknown, ownRoomCode: string | null, call: string): {
72
+ toRoomCode: string;
73
+ message: unknown;
74
+ };
75
+ /** room.sendToEveryRoom: the message as plain JSON within the size, copied, and which rooms (checkRoomSelection). */
76
+ checkSendToEveryRoom(message: unknown, roomSelection: unknown, call: string): {
77
+ message: unknown;
78
+ labels: Record<string, string> | null;
79
+ };
80
+ /** Which rooms a call means: those holding every label given (tidied as room.setInfo tidies them), or null for every room. */
81
+ checkRoomSelection(roomSelection: unknown, call: string): {
82
+ labels: Record<string, string> | null;
83
+ };
68
84
  /** Text with hidden characters dropped, spaces collapsed, cut to the length. */
69
85
  tidy(given: unknown, maximumCharacterCount: number): string;
70
86
  /** A name and labels within the limits, with hidden characters dropped. Fields left out stay out. */
package/src/room-calls.js CHANGED
@@ -177,7 +177,37 @@ function roomCallChecksParts() {
177
177
  throw mistake("room.end", "room.end's message must be text.", "Give the text players see, or leave it out.");
178
178
  return message.slice(0, limits.maximumKickMessageCharacterCount);
179
179
  };
180
- return { checkBroadcast, checkSend, checkBroadcastBytes, checkSendBytes, checkKick, checkReserve, checkMoveTo, checkSetInfo, checkEnd, tidy, tidyRoomInfo, checkRoomData };
180
+ const readRoomMessage = (message, call) => {
181
+ if (message === undefined || message === null)
182
+ throw mistake(call, `${call} needs a message.`, "Give the message as plain JSON, like { kind: \"blood_moon\" }.");
183
+ const checked = checkRoomData(message, limits.maximumRoomMessageSizeBytes);
184
+ if (!checked.isValid)
185
+ throw mistake(call, `${call}'s message isn't plain JSON up to ${limits.maximumRoomMessageSizeBytes / 1024} KB.`, "Give numbers, text, true or false, lists and plain objects, and keep it small.");
186
+ return checked.checkedData;
187
+ };
188
+ const checkRoomSelection = (roomSelection, call) => {
189
+ const given = closed(roomSelection, ["labels"], call);
190
+ if (given.labels === undefined || given.labels === null)
191
+ return { labels: null };
192
+ if (typeof given.labels !== "object" || Array.isArray(given.labels) || Object.keys(given.labels).length > limits.maximumLabelCount) {
193
+ throw mistake(call, `${call}'s labels must be an object of up to ${limits.maximumLabelCount} labels.`, 'Give the labels the rooms hold, like { Map: "Docks" }, or leave labels out for every room.');
194
+ }
195
+ for (const labelValue of Object.values(given.labels)) {
196
+ if (typeof labelValue !== "string")
197
+ throw mistake(call, `${call}'s labels must each be text.`, 'Give each label as text, like { Map: "Docks" }.');
198
+ }
199
+ const labels = tidyRoomInfo({ labels: given.labels }).labels ?? {};
200
+ return { labels: Object.keys(labels).length ? labels : null };
201
+ };
202
+ const checkSendToRoom = (toRoomCode, message, ownRoomCode, call) => {
203
+ if (typeof toRoomCode !== "string" || !ROOM_CODE.test(toRoomCode))
204
+ throw mistake(call, `${call} needs a room code.`, "Give the code of a live room of this game, like one listRooms answered.");
205
+ if (toRoomCode === ownRoomCode)
206
+ throw mistake(call, `${call} was given this room's own code.`, "Handle the message here, in this room's own handler; send to other rooms by their codes.");
207
+ return { toRoomCode, message: readRoomMessage(message, call) };
208
+ };
209
+ const checkSendToEveryRoom = (message, roomSelection, call) => ({ message: readRoomMessage(message, call), ...checkRoomSelection(roomSelection, call) });
210
+ return { checkBroadcast, checkSend, checkBroadcastBytes, checkSendBytes, checkKick, checkReserve, checkMoveTo, checkSetInfo, checkEnd, checkSendToRoom, checkSendToEveryRoom, checkRoomSelection, tidy, tidyRoomInfo, checkRoomData };
181
211
  }
182
212
  return { checksFor };
183
213
  }