@zaisma/game-server 1.22.0 → 1.24.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/doors.js ADDED
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Door contracts (docs/DOORS.md). Every way into Zaisma, of every kind, states who may call it, what it may
3
+ * reach and what it trusts from other doors, and goes through checkDoor before its own code runs. Here, in the
4
+ * game server package's internal part, so a room on a developer's own server goes through the same check;
5
+ * never in the package's published index.
6
+ */
7
+ const shapeOf = (schema) => schema;
8
+ export const text = (maximumCharacterCount, pattern) => shapeOf({ type: "string", maxLength: maximumCharacterCount, ...(pattern ? { pattern } : {}) });
9
+ export const oneOfWords = (...words) => shapeOf({ type: "string", maxLength: Math.max(...words.map((word) => word.length)), enum: words });
10
+ export const integer = (minimumNumber = 0, maximumNumber = Number.MAX_SAFE_INTEGER) => shapeOf({ type: "integer", minimum: minimumNumber, maximum: maximumNumber });
11
+ export const realNumber = (minimumNumber, maximumNumber) => shapeOf({ type: "number", minimum: minimumNumber, maximum: maximumNumber });
12
+ export const yesOrNo = shapeOf({ type: "boolean" });
13
+ export const anyOf = (...shapes) => shapeOf({ anyOf: shapes });
14
+ export const orNothing = (shape) => anyOf(shape, shapeOf({ type: "null" }));
15
+ export const listOf = (items, maximumItemCount, minimumItemCount = 0) => shapeOf({ type: "array", items, maxItems: maximumItemCount, ...(minimumItemCount ? { minItems: minimumItemCount } : {}) });
16
+ /** A closed object: these fields and no others, the required ones named. */
17
+ export const fieldsOf = (properties, required = []) => shapeOf({ type: "object", properties, required, additionalProperties: false });
18
+ /** A game's own data, of any shape: its size only, in characters of its JSON. */
19
+ export const gameData = (maximumSizeBytes) => shapeOf({ isGameData: true, maximumSizeBytes });
20
+ /** Nothing: a door with no query it reads and no body. */
21
+ export const TAKES_NOTHING = { query: {}, body: null };
22
+ export const ANSWERS_PAGE = { kind: "page" };
23
+ export const ANSWERS_EMPTY = { kind: "empty" };
24
+ export const ANSWERS_REDIRECT = { kind: "redirect" };
25
+ export const ANSWERS_FILE = { kind: "file" };
26
+ export const ANSWERS_BYTES = { kind: "bytes" };
27
+ export const answersJson = (schema) => ({ kind: "json", schema });
28
+ /** A door's limit in calls a minute, from this short list. Each is a rate limit binding in the Workers that use it: LIMIT_<LEVEL>. */
29
+ export const DOOR_LIMIT_LEVELS = { very_rare: 2, guess: 5, rare: 10, steady: 30, busy: 120, building: 1000 };
30
+ /** Why a door has no limit of its own: from this list only, and the daily review reads every door that gives one. */
31
+ export const DOOR_NO_LIMIT_REASONS = {
32
+ answers_from_memory: "It answers fixed bytes from memory and reads nothing.",
33
+ cached_file: "It serves a file Cloudflare caches, with no database behind it and no egress fee.",
34
+ admin_only: "Cloudflare Access stands in front of it, and only Zaisma's own people get through.",
35
+ signed_by_sender: "It reads nothing until the sender's signature on the body checks out.",
36
+ counted_by_its_call: "All it does is make calls that have their own limit.",
37
+ room_token_bucket: "A room's message bucket takes a token for each message, per player, before any door runs.",
38
+ };
39
+ /** Whether a call goes past a level's limit: door's own counter, for one subject. Keys never leave Cloudflare's counter. */
40
+ export async function isPastLevel(env, level, counterName, subject) {
41
+ const binding = env[`LIMIT_${level.toUpperCase()}`];
42
+ if (!binding)
43
+ return false;
44
+ const answer = await binding.limit({ key: `${counterName}:${subject}` });
45
+ return !("success" in answer && answer.success === true);
46
+ }
47
+ /** Whether a call goes past a door's limit. doorName names its counter unless its limit shares a group. */
48
+ export async function isPastDoorLimit(env, limit, doorName, subject) {
49
+ return "whyNoLimit" in limit ? false : isPastLevel(env, limit.level, limit.group ?? doorName, subject);
50
+ }
51
+ /** A pattern once compiled: shapes check on every call. */
52
+ const compiledPatterns = new Map();
53
+ function compiled(pattern) {
54
+ let found = compiledPatterns.get(pattern);
55
+ if (!found)
56
+ compiledPatterns.set(pattern, (found = new RegExp(pattern)));
57
+ return found;
58
+ }
59
+ const typesOf = (schema) => {
60
+ const { type } = schema;
61
+ if (Array.isArray(type))
62
+ return type;
63
+ if (typeof type === "string")
64
+ return [type];
65
+ if (Array.isArray(schema.enum))
66
+ return [...new Set(schema.enum.map((allowed) => (allowed === null ? "null" : typeof allowed)))];
67
+ return [];
68
+ };
69
+ /** Where a value doesn't match its shape or schema, as "path: problem" lines; [] when it fits. Answers and inputs both come here. */
70
+ export function schemaProblems(schema, given, path = "answer") {
71
+ // anyOf: it fits when one of them fits; otherwise say how it misses the nearest.
72
+ if (Array.isArray(schema.anyOf)) {
73
+ const misses = schema.anyOf.map((option) => schemaProblems(option, given, path));
74
+ return misses.some((problems) => problems.length === 0) ? [] : misses.sort((first, second) => first.length - second.length)[0];
75
+ }
76
+ if (schema.isGameData === true) {
77
+ const sizeBytes = JSON.stringify(given)?.length ?? 0;
78
+ return sizeBytes > schema.maximumSizeBytes ? [`${path}: over ${schema.maximumSizeBytes} bytes`] : [];
79
+ }
80
+ const out = [];
81
+ const types = typesOf(schema);
82
+ const actual = given === null ? "null" : Array.isArray(given) ? "array" : Number.isInteger(given) ? "integer" : typeof given;
83
+ const fits = types.length === 0 || types.includes(actual) || (actual === "integer" && types.includes("number"));
84
+ if (!fits)
85
+ return [`${path}: is ${actual}, expected ${types.join(" or ")}`];
86
+ if (Array.isArray(schema.enum) && !schema.enum.includes(given))
87
+ out.push(`${path}: ${JSON.stringify(given)} isn't one of ${JSON.stringify(schema.enum)}`);
88
+ if (typeof given === "string") {
89
+ if (typeof schema.maxLength === "number" && given.length > schema.maxLength)
90
+ out.push(`${path}: over ${schema.maxLength} characters`);
91
+ if (schema.format === "date-time" && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(given))
92
+ out.push(`${path}: ${given} isn't an ISO time in UTC`);
93
+ if (schema.format === "date" && !/^\d{4}-\d{2}-\d{2}$/.test(given))
94
+ out.push(`${path}: ${given} isn't YYYY-MM-DD`);
95
+ if (schema.format === "uri" && !/^https?:\/\//.test(given))
96
+ out.push(`${path}: ${given} isn't a link`);
97
+ if (typeof schema.pattern === "string" && !compiled(schema.pattern).test(given))
98
+ out.push(`${path}: ${given} doesn't match ${schema.pattern}`);
99
+ }
100
+ if (typeof given === "number") {
101
+ if (typeof schema.minimum === "number" && given < schema.minimum)
102
+ out.push(`${path}: below ${schema.minimum}`);
103
+ if (typeof schema.maximum === "number" && given > schema.maximum)
104
+ out.push(`${path}: above ${schema.maximum}`);
105
+ }
106
+ if (Array.isArray(given)) {
107
+ if (typeof schema.maxItems === "number" && given.length > schema.maxItems)
108
+ return [...out, `${path}: over ${schema.maxItems} items`];
109
+ if (typeof schema.minItems === "number" && given.length < schema.minItems)
110
+ out.push(`${path}: under ${schema.minItems} items`);
111
+ if (schema.items)
112
+ given.forEach((entry, index) => out.push(...schemaProblems(schema.items, entry, `${path}[${index}]`)));
113
+ }
114
+ if (actual === "object" && schema.properties) {
115
+ const properties = schema.properties;
116
+ const fields = given;
117
+ for (const name of schema.required ?? [])
118
+ if (!(name in fields))
119
+ out.push(`${path}.${name}: missing`);
120
+ if (schema.additionalProperties === false)
121
+ for (const name of Object.keys(fields))
122
+ if (!(name in properties))
123
+ out.push(`${path}.${name}: not in the schema`);
124
+ for (const [name, property] of Object.entries(properties))
125
+ if (name in fields)
126
+ out.push(...schemaProblems(property, fields[name], `${path}.${name}`));
127
+ }
128
+ return out;
129
+ }
130
+ /** Where a value's types don't match a schema; null reads as left out, and what a call behind it allows, names included, stays the call's to say, with its own fix. */
131
+ export function typeProblems(schema, given, path = "answer") {
132
+ if (given === null)
133
+ return [];
134
+ if (Array.isArray(schema.anyOf)) {
135
+ const misses = schema.anyOf.map((option) => typeProblems(option, given, path));
136
+ return misses.some((problems) => problems.length === 0) ? [] : misses.sort((first, second) => first.length - second.length)[0];
137
+ }
138
+ const types = typesOf(schema);
139
+ const actual = Array.isArray(given) ? "array" : Number.isInteger(given) ? "integer" : typeof given;
140
+ if (types.length && !types.includes(actual) && !(actual === "integer" && types.includes("number")))
141
+ return [`${path}: is ${actual}, expected ${types.join(" or ")}`];
142
+ if (actual !== "object" || !schema.properties)
143
+ return [];
144
+ const properties = schema.properties;
145
+ return Object.entries(given).flatMap(([name, fieldValue]) => (properties[name] ? typeProblems(properties[name], fieldValue, `${path}.${name}`) : []));
146
+ }
147
+ /**
148
+ * An answer with every field its schema didn't declare taken out, and the paths it took (a list's index as []): the
149
+ * caller still gets the answer. Nothing else changes, and an answer with nothing to cut comes back as the same value.
150
+ * Only closed objects cut, so a game's own rows and arguments pass as they are.
151
+ */
152
+ export function cutUndeclared(schema, given, path = "answer") {
153
+ if (Array.isArray(schema.anyOf)) {
154
+ const options = schema.anyOf;
155
+ // The option that fits as it is. Or else: an option of the answer's type, one that fits once cut, then the one that takes least,
156
+ // so an answer short of one field isn't emptied to look like a shorter option.
157
+ if (options.some((option) => schemaProblems(option, given, path).length === 0))
158
+ return { kept: given, cutPaths: [] };
159
+ const tried = options.map((option) => {
160
+ const cut = cutUndeclared(option, given, path);
161
+ return { cut, isOtherType: schemaProblems({ type: typesOf(option) }, given, path).length > 0, problemCount: schemaProblems(option, cut.kept, path).length };
162
+ });
163
+ tried.sort((first, second) => Number(first.isOtherType) - Number(second.isOtherType) || Number(first.problemCount > 0) - Number(second.problemCount > 0) || first.cut.cutPaths.length - second.cut.cutPaths.length || first.problemCount - second.problemCount);
164
+ return tried[0]?.cut ?? { kept: given, cutPaths: [] };
165
+ }
166
+ if (Array.isArray(given)) {
167
+ if (!schema.items)
168
+ return { kept: given, cutPaths: [] };
169
+ const cutPaths = [];
170
+ let changed = null;
171
+ given.forEach((entry, index) => {
172
+ const cut = cutUndeclared(schema.items, entry, `${path}[]`);
173
+ if (!cut.cutPaths.length)
174
+ return;
175
+ cutPaths.push(...cut.cutPaths);
176
+ (changed ??= [...given])[index] = cut.kept;
177
+ });
178
+ return { kept: changed ?? given, cutPaths: [...new Set(cutPaths)] };
179
+ }
180
+ if (given === null || typeof given !== "object" || !schema.properties)
181
+ return { kept: given, cutPaths: [] };
182
+ const properties = schema.properties;
183
+ const kept = {};
184
+ const cutPaths = [];
185
+ for (const [name, fieldValue] of Object.entries(given)) {
186
+ const property = properties[name];
187
+ if (!property) {
188
+ if (schema.additionalProperties === false)
189
+ cutPaths.push(`${path}.${name}`);
190
+ else
191
+ kept[name] = fieldValue;
192
+ continue;
193
+ }
194
+ const cut = cutUndeclared(property, fieldValue, `${path}.${name}`);
195
+ kept[name] = cut.kept;
196
+ cutPaths.push(...cut.cutPaths);
197
+ }
198
+ return cutPaths.length ? { kept, cutPaths } : { kept: given, cutPaths: [] };
199
+ }
200
+ /** A query value read as its shape says: a number or a yes or no comes as text. Text that can't be one stays text, so the shape refuses it. */
201
+ function queryValueOf(shape, given) {
202
+ if (Array.isArray(shape.anyOf)) {
203
+ for (const option of shape.anyOf) {
204
+ const read = queryValueOf(option, given);
205
+ if (schemaProblems(option, read).length === 0)
206
+ return read;
207
+ }
208
+ return given;
209
+ }
210
+ if ((shape.type === "integer" || shape.type === "number") && given.trim() !== "" && Number.isFinite(Number(given)))
211
+ return Number(given);
212
+ if (shape.type === "boolean" && (given === "true" || given === "false"))
213
+ return given === "true";
214
+ return given;
215
+ }
216
+ /** The first problem with a query, as [name, what's wrong], or null. isOpenToOtherNames: a page ignores names it doesn't read, since share links pick some up. */
217
+ export function queryProblem(fields, query, isOpenToOtherNames = false) {
218
+ for (const [name, given] of query) {
219
+ const shape = fields[name];
220
+ if (!shape) {
221
+ if (isOpenToOtherNames)
222
+ continue;
223
+ return { field: name, problem: `${name} isn't one of the query's names` };
224
+ }
225
+ const [problem] = schemaProblems(shape, queryValueOf(shape, given), name);
226
+ if (problem)
227
+ return { field: name, problem };
228
+ }
229
+ return null;
230
+ }
231
+ /** The first problem with a JSON body's fields: closed, each field its shape, the required ones there. */
232
+ export function bodyProblem(fields, required, body) {
233
+ for (const name of required)
234
+ if (!(name in body))
235
+ return { field: name, problem: `${name} is missing` };
236
+ for (const [name, given] of Object.entries(body)) {
237
+ const shape = fields[name];
238
+ if (!shape)
239
+ return { field: name, problem: `${name} isn't one of this door's fields` };
240
+ const [problem] = schemaProblems(shape, given, name);
241
+ if (problem)
242
+ return { field: name, problem };
243
+ }
244
+ return null;
245
+ }
246
+ /** The first problem with a form's fields: its names closed, each value text, or a list where it may repeat. A form's text boxes send what they send, so its numbers are the door's to read. */
247
+ export function formProblem(fields, fileNames, form) {
248
+ for (const name of new Set(form.keys())) {
249
+ if (fileNames.includes(name))
250
+ continue;
251
+ const shape = fields[name];
252
+ if (!shape)
253
+ return { field: name, problem: `${name} isn't one of this form's fields` };
254
+ const sent = form.getAll(name);
255
+ if (sent.some((fieldValue) => typeof fieldValue !== "string"))
256
+ return { field: name, problem: `${name} is a file, not text` };
257
+ const read = shape.type === "array" || sent.length !== 1 ? sent : sent[0];
258
+ const [problem] = schemaProblems(shape, typeof read === "string" ? queryValueOf(shape, read) : read, name);
259
+ if (problem)
260
+ return { field: name, problem };
261
+ }
262
+ return null;
263
+ }
264
+ /**
265
+ * The one check every door goes through. `proven` is who the door's guard found the caller to be; a caller a
266
+ * named function proves passes here, and that function refuses them, before anything else of the door runs.
267
+ */
268
+ export function checkDoor(contract, proven) {
269
+ return contract.whoMayCall === "anyone" || "provenBy" in contract || proven.includes(contract.whoMayCall);
270
+ }
271
+ /** Where a door's path parts come from: `:name` parts, each a [^/]+ unless named here, and a final `*` for the rest. */
272
+ export function doorPathPattern(path, partPatterns = {}) {
273
+ const parts = path.split("/").map((part) => {
274
+ if (part === "*")
275
+ return ".*";
276
+ if (part.startsWith(":"))
277
+ return `(?<${part.slice(1)}>${partPatterns[part.slice(1)] ?? "[^/]+"})`;
278
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
279
+ });
280
+ return new RegExp(`^${parts.join("/")}$`);
281
+ }
package/src/internal.d.ts CHANGED
@@ -12,3 +12,4 @@ export * from "./pass.ts";
12
12
  export * from "./names.ts";
13
13
  export * from "./chat.ts";
14
14
  export * from "./relay.ts";
15
+ export * from "./doors.ts";
package/src/internal.js CHANGED
@@ -14,3 +14,4 @@ export * from "./pass.js";
14
14
  export * from "./names.js";
15
15
  export * from "./chat.js";
16
16
  export * from "./relay.js";
17
+ export * from "./doors.js";
package/src/requests.d.ts CHANGED
@@ -67,7 +67,7 @@ export declare const REQUEST_LIMITS: {
67
67
  /** How long onRequest may take, awaiting the database included, before the request fails. */
68
68
  readonly answerTimeoutSec: 10;
69
69
  };
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";
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" | "rented_share_used" | "slow_down" | "bad_origin" | "busy";
71
71
  export declare const REQUEST_ERROR_TEXT: Record<RequestErrorCode, string>;
72
72
  /** The HTTP status each request error answers with. */
73
73
  export declare const REQUEST_ERROR_HTTP_STATUS: Record<RequestErrorCode, number>;
package/src/requests.js CHANGED
@@ -35,6 +35,7 @@ export const REQUEST_ERROR_TEXT = {
35
35
  no_request_handler: "This game's server doesn't take requests.",
36
36
  game_server_error: "The game's server couldn't answer this request.",
37
37
  hosting_paused: "This game's hosting is paused. It starts again when the month resets or its developer adds credit.",
38
+ rented_share_used: "This network looks like a server. Turn off your VPN or sign in with an account that has played before.",
38
39
  slow_down: "Too many requests. Try again in a minute.",
39
40
  bad_origin: "Not allowed from here.",
40
41
  busy: "Zaisma is busy. Try again soon.",
@@ -51,6 +52,7 @@ export const REQUEST_ERROR_HTTP_STATUS = {
51
52
  no_request_handler: 404,
52
53
  game_server_error: 500,
53
54
  hosting_paused: 403,
55
+ rented_share_used: 403,
54
56
  slow_down: 429,
55
57
  bad_origin: 403,
56
58
  busy: 503,
package/src/room.d.ts CHANGED
@@ -7,6 +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 DoorContract, type DoorShape } from "./doors.ts";
10
11
  import type { PassClaims, PlayerKind, ReceiptClaims } from "./pass.ts";
11
12
  import type { Server } from "./requests.ts";
12
13
  import { type Schedule, type ScheduledRun } from "./schedules.ts";
@@ -1117,7 +1118,7 @@ export type ClientMessage = {
1117
1118
  * the game became unavailable, its kept room was deleted, or no players were left for its spectators.
1118
1119
  */
1119
1120
  export type EndReason = "game" | "hosting_paused" | "game_server_stopped" | "game_unavailable" | "kept_room_deleted" | "no_players";
1120
- export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "bad_player_token" | "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";
1121
+ export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "bad_player_token" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "rented_share_used" | "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";
1121
1122
  export declare const ERROR_TEXT: Record<RoomErrorCode, string>;
1122
1123
  /** What players are told when Zaisma ends a match at room.endsAt. */
1123
1124
  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.";
@@ -1334,6 +1335,105 @@ export interface StateSyncHooks {
1334
1335
  export declare const NO_MESSAGE_TYPE = "(none)";
1335
1336
  export declare const BYTES_MESSAGE_TYPE = "(bytes)";
1336
1337
  export declare const ZAISMA_MESSAGE_TYPE = "(zaisma)";
1338
+ export declare const ROOM_FRAME: Readonly<Record<string, unknown>>;
1339
+ /**
1340
+ * Every message a room takes (docs/DOORS.md). receive's switch is over these names, so a new one without a row
1341
+ * doesn't build. A spectator may send only what's for room members. hello comes first and proves the pass; chat is the host's to take before receive (readChatMessage);
1342
+ * bytes is every binary message (receiveBytes). A type with no row, maybe from a newer SDK, is counted and ignored.
1343
+ */
1344
+ export declare const ROOM_MESSAGE_DOORS: {
1345
+ readonly hello: {
1346
+ readonly whoMayCall: "pass_holder";
1347
+ readonly reaches: "room";
1348
+ readonly trusts: readonly ["player_pass"];
1349
+ readonly provenBy: {
1350
+ readonly source_path: "packages/game-server/src/pass.ts";
1351
+ readonly symbol_name: "verifyPass";
1352
+ };
1353
+ readonly takes: {
1354
+ readonly query: {};
1355
+ readonly body: {
1356
+ readonly kind: "json";
1357
+ readonly fields: {
1358
+ readonly idleTimeoutSec: DoorShape;
1359
+ readonly idleCheckEnabled: DoorShape;
1360
+ readonly type: DoorShape;
1361
+ readonly protocolVersionNumber: DoorShape;
1362
+ readonly pass: DoorShape;
1363
+ readonly playerToken: DoorShape;
1364
+ readonly joinData: DoorShape;
1365
+ readonly room: DoorShape;
1366
+ readonly isSpectator: DoorShape;
1367
+ readonly isQuickJoin: DoorShape;
1368
+ readonly avoidBlockedPlayers: DoorShape;
1369
+ readonly versionId: DoorShape;
1370
+ readonly playShare: DoorShape;
1371
+ readonly hasGraphicsChip: DoorShape;
1372
+ readonly isInputSeen: DoorShape;
1373
+ };
1374
+ readonly required: readonly ["type"];
1375
+ readonly maximumSizeBytes: number;
1376
+ };
1377
+ };
1378
+ readonly answers: import("./doors.ts").DoorAnswer;
1379
+ readonly limit: {
1380
+ readonly level: "steady";
1381
+ readonly counts: "player";
1382
+ };
1383
+ };
1384
+ readonly probe_answer: DoorContract;
1385
+ readonly room: DoorContract;
1386
+ readonly kick: DoorContract;
1387
+ readonly here: DoorContract;
1388
+ readonly idle: DoorContract;
1389
+ readonly hidden: DoorContract;
1390
+ readonly shown: DoorContract;
1391
+ readonly back: DoorContract;
1392
+ readonly message: DoorContract;
1393
+ readonly bytes: {
1394
+ readonly whoMayCall: "room_player";
1395
+ readonly reaches: "room";
1396
+ readonly trusts: readonly ["player_pass"];
1397
+ readonly takes: {
1398
+ readonly query: {};
1399
+ readonly body: {
1400
+ readonly kind: "bytes";
1401
+ readonly maximumSizeBytes: number;
1402
+ };
1403
+ };
1404
+ readonly answers: import("./doors.ts").DoorAnswer;
1405
+ readonly limit: {
1406
+ readonly whyNoLimit: "room_token_bucket";
1407
+ };
1408
+ };
1409
+ readonly chat: DoorContract;
1410
+ readonly block: DoorContract;
1411
+ readonly unblock: DoorContract;
1412
+ readonly friend_request: DoorContract;
1413
+ readonly "zaisma:purchase": {
1414
+ readonly reaches: "purchase";
1415
+ readonly trusts: readonly ["player_pass", "purchase_receipt"];
1416
+ readonly takes: import("./doors.ts").DoorTakes;
1417
+ readonly answers: import("./doors.ts").DoorAnswer;
1418
+ readonly limit: import("./doors.ts").DoorLimit;
1419
+ readonly whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1420
+ } | {
1421
+ readonly reaches: "purchase";
1422
+ readonly trusts: readonly ["player_pass", "purchase_receipt"];
1423
+ readonly takes: import("./doors.ts").DoorTakes;
1424
+ readonly answers: import("./doors.ts").DoorAnswer;
1425
+ readonly limit: import("./doors.ts").DoorLimit;
1426
+ readonly whoMayCall: import("./doors.ts").DoorCallerProvenByFunction;
1427
+ readonly provenBy: import("./doors.ts").DoorProof;
1428
+ };
1429
+ };
1430
+ /**
1431
+ * Whether a message breaks its door's shape: a field of the wrong type or past its bound, or one it needs missing. A
1432
+ * name the door doesn't know is left alone and never read, unlike at other doors: a game's SDK comes from Zaisma and
1433
+ * its server may be the developer's own, older one, so a newer SDK's field has to keep working there.
1434
+ */
1435
+ export declare function roomMessageProblem(type: Exclude<RoomMessageType, "bytes">, message: Record<string, unknown>): string | null;
1436
+ export type RoomMessageType = keyof typeof ROOM_MESSAGE_DOORS;
1337
1437
  export declare const OTHER_MESSAGE_TYPE = "(other)";
1338
1438
  /** State sync's updates (stateSyncEnabled). */
1339
1439
  export declare const STATE_MESSAGE_TYPE = "(state)";