@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/room.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS } from "./chat.js";
10
10
  import { DatabaseError } from "./database.js";
11
+ import { answersJson, checkDoor, fieldsOf, gameData, listOf, orNothing, realNumber, schemaProblems, text as textUpTo, yesOrNo } from "./doors.js";
11
12
  import { isRoomCode, roomCode } from "./names.js";
12
13
  import { readSchedules } from "./schedules.js";
13
14
  import { roomCallChecks } from "./room-calls.js";
@@ -321,6 +322,7 @@ export const ERROR_TEXT = {
321
322
  refused: "The game didn't let you into this room.",
322
323
  room_not_running: "That room isn't running.",
323
324
  hosting_paused: "This game's hosting is paused. It starts again when the month resets or its developer adds credit.",
325
+ rented_share_used: "This network looks like a server. Turn off your VPN or sign in with an account that has played before.",
324
326
  account_needed: "Playing online needs a Zaisma account. Sign in on the game's page to play.",
325
327
  game_not_open_to_player: "This game isn't open to this account.",
326
328
  blocked_player_here: "Someone you blocked is in this room.",
@@ -444,6 +446,9 @@ export function readChatMessage(text) {
444
446
  }
445
447
  if (!parsed || typeof parsed !== "object" || parsed.type !== "chat")
446
448
  return null;
449
+ // One of the wrong shape isn't chat: receive refuses it.
450
+ if (roomMessageProblem("chat", parsed))
451
+ return null;
447
452
  const chat = parsed;
448
453
  return { chatNumber: Number.isSafeInteger(chat.chatNumber) ? chat.chatNumber : 0, text: chat.text, toPlayerIds: chat.toPlayerIds, sizeBytes: utf8ByteCount(text) };
449
454
  }
@@ -502,6 +507,153 @@ export const STATE_SYNC = Symbol("stateSync");
502
507
  export const NO_MESSAGE_TYPE = "(none)";
503
508
  export const BYTES_MESSAGE_TYPE = "(bytes)";
504
509
  export const ZAISMA_MESSAGE_TYPE = "(zaisma)";
510
+ /** A pass or a player token, at most, and a whole hello with the fullest of them. */
511
+ const HELLO_PROOF_MAXIMUM_CHARACTER_COUNT = 96 * 1024;
512
+ const HELLO_MAXIMUM_SIZE_BYTES = HELLO_PROOF_MAXIMUM_CHARACTER_COUNT + 2 * ROOM_LIMITS.maximumJoinDataSizeBytes + 8 * 1024;
513
+ /** What a player is sent, by its type, with each type's fields closed: a game's own data and a room's settings pass as they are. Checked in tests only, since this is the busiest path there is. */
514
+ const looseObject = { type: "object" };
515
+ const frameOf = (messageType, fields = {}, optional = {}) => ({
516
+ type: "object",
517
+ properties: { type: { type: "string", enum: [messageType] }, ...fields, ...optional },
518
+ required: ["type", ...Object.keys(fields)],
519
+ additionalProperties: false,
520
+ });
521
+ const wireText = { type: "string" };
522
+ const wireNumber = { type: "number" };
523
+ const wireYesOrNo = { type: "boolean" };
524
+ const wireNullable = (type) => ({ type: [type, "null"] });
525
+ const wirePlayers = { type: "array", items: looseObject };
526
+ const WIRE_DATA = {};
527
+ export const ROOM_FRAME = {
528
+ anyOf: [
529
+ frameOf("welcome", { me: looseObject, isSpectator: wireYesOrNo, players: wirePlayers, room: looseObject, spectatorCount: wireNumber, hostPlayerId: wireNullable("string"), awayChecks: looseObject, endsAt: wireNullable("string"), probeIntervalSec: wireNullable("number"), protocolVersionNumber: wireNumber, canChat: wireYesOrNo }),
530
+ frameOf("probe", { probeNumber: wireNumber, serverTimeMs: wireNumber, pingDurationMs: wireNullable("number") }),
531
+ frameOf("ending", { endsAt: wireText }),
532
+ frameOf("ending_cancelled"),
533
+ frameOf("version_update", { step: wireText, versionId: wireText, appliesAt: wireText, isReloading: wireYesOrNo }),
534
+ frameOf("players", { players: wirePlayers, hostPlayerId: wireNullable("string") }),
535
+ frameOf("away", { awayReason: wireText, seatHoldDurationSec: wireNumber }),
536
+ frameOf("kicked", { message: wireText, isKeptOut: wireYesOrNo }),
537
+ frameOf("room", { room: looseObject, spectatorCount: wireNumber }),
538
+ frameOf("move", { roomCode: wireText }),
539
+ frameOf("message", { data: WIRE_DATA, fromPlayerId: wireNullable("string") }),
540
+ frameOf("room_message", { roomMessage: looseObject }),
541
+ frameOf("state", { serverTimeMs: wireNumber }, { full: WIRE_DATA, changes: { type: "array" } }),
542
+ frameOf("blocks", { playerIds: { type: "array", items: wireText } }),
543
+ frameOf("friend_request"),
544
+ frameOf("chat", { fromPlayerId: wireText, text: wireText, hasSwearing: wireYesOrNo, hasSlur: wireYesOrNo, sentAt: wireText }),
545
+ frameOf("chat_sent", { chatNumber: wireNumber, sentAt: wireText }),
546
+ frameOf("chat_stopped", { chatNumber: wireNumber, reason: wireText, message: wireText }),
547
+ frameOf("error", { code: wireText, message: wireText }, { versionId: wireText, chatNumber: wireNumber }),
548
+ frameOf("ended", { message: wireText, endReason: wireText }),
549
+ ],
550
+ };
551
+ /** The room's settings as a client may ask for them: tidyRoomInfo cuts a name and labels to their limits, and these are only the outer wall. */
552
+ const ASKED_ROOM = fieldsOf({
553
+ isListed: yesOrNo,
554
+ isOpen: yesOrNo,
555
+ friendJoinsEnabled: yesOrNo,
556
+ name: textUpTo(1000),
557
+ labels: gameData(4 * 1024),
558
+ });
559
+ const PLAYER_ID_SHAPE = textUpTo(80);
560
+ const IDLE_FIELDS = { idleTimeoutSec: realNumber(0, Number.MAX_SAFE_INTEGER), idleCheckEnabled: yesOrNo };
561
+ /** A connection's limit is a room's message bucket, which takes a token per player before any of these runs. */
562
+ const roomMessage = (whoMayCall, fields = {}, required = []) => ({
563
+ whoMayCall,
564
+ reaches: "room",
565
+ trusts: ["player_pass"],
566
+ takes: { query: {}, body: { kind: "json", fields: { type: textUpTo(40), ...fields }, required: ["type", ...required], maximumSizeBytes: ROOM_LIMITS.maximumGameMessageSizeBytes } },
567
+ answers: answersJson(ROOM_FRAME),
568
+ limit: { whyNoLimit: "room_token_bucket" },
569
+ });
570
+ /**
571
+ * Every message a room takes (docs/DOORS.md). receive's switch is over these names, so a new one without a row
572
+ * 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);
573
+ * bytes is every binary message (receiveBytes). A type with no row, maybe from a newer SDK, is counted and ignored.
574
+ */
575
+ export const ROOM_MESSAGE_DOORS = {
576
+ hello: {
577
+ whoMayCall: "pass_holder",
578
+ reaches: "room",
579
+ trusts: ["player_pass"],
580
+ provenBy: { source_path: "packages/game-server/src/pass.ts", symbol_name: "verifyPass" },
581
+ takes: {
582
+ query: {},
583
+ body: {
584
+ kind: "json",
585
+ fields: {
586
+ type: textUpTo(40),
587
+ protocolVersionNumber: realNumber(0, Number.MAX_SAFE_INTEGER),
588
+ pass: textUpTo(HELLO_PROOF_MAXIMUM_CHARACTER_COUNT),
589
+ playerToken: orNothing(textUpTo(HELLO_PROOF_MAXIMUM_CHARACTER_COUNT)),
590
+ joinData: gameData(ROOM_LIMITS.maximumJoinDataSizeBytes * 2),
591
+ room: ASKED_ROOM,
592
+ isSpectator: yesOrNo,
593
+ isQuickJoin: yesOrNo,
594
+ avoidBlockedPlayers: yesOrNo,
595
+ versionId: textUpTo(80),
596
+ playShare: textUpTo(400),
597
+ hasGraphicsChip: yesOrNo,
598
+ isInputSeen: yesOrNo,
599
+ ...IDLE_FIELDS,
600
+ },
601
+ required: ["type"],
602
+ maximumSizeBytes: HELLO_MAXIMUM_SIZE_BYTES,
603
+ },
604
+ },
605
+ answers: answersJson(ROOM_FRAME),
606
+ limit: { level: "steady", counts: "player" },
607
+ },
608
+ probe_answer: roomMessage("room_member", { probeNumber: realNumber(0, Number.MAX_SAFE_INTEGER) }),
609
+ room: roomMessage("room_host", { room: ASKED_ROOM }, ["room"]),
610
+ kick: roomMessage("room_host", { playerId: PLAYER_ID_SHAPE, keepOut: yesOrNo }, ["playerId"]),
611
+ here: roomMessage("room_player"),
612
+ idle: roomMessage("room_player"),
613
+ hidden: roomMessage("room_player"),
614
+ shown: roomMessage("room_player"),
615
+ back: roomMessage("room_player", { joinData: gameData(ROOM_LIMITS.maximumJoinDataSizeBytes * 2), ...IDLE_FIELDS }),
616
+ message: roomMessage("room_player", { data: gameData(ROOM_LIMITS.maximumGameMessageSizeBytes) }),
617
+ bytes: {
618
+ whoMayCall: "room_player",
619
+ reaches: "room",
620
+ trusts: ["player_pass"],
621
+ takes: { query: {}, body: { kind: "bytes", maximumSizeBytes: ROOM_LIMITS.maximumGameMessageSizeBytes } },
622
+ answers: answersJson(ROOM_FRAME),
623
+ limit: { whyNoLimit: "room_token_bucket" },
624
+ },
625
+ chat: roomMessage("room_player", { chatNumber: realNumber(0, Number.MAX_SAFE_INTEGER), text: textUpTo(ROOM_LIMITS.maximumGameMessageSizeBytes), toPlayerIds: listOf(PLAYER_ID_SHAPE, 200) }, ["text"]),
626
+ block: roomMessage("room_player", { playerId: PLAYER_ID_SHAPE }, ["playerId"]),
627
+ unblock: roomMessage("room_player", { playerId: PLAYER_ID_SHAPE }, ["playerId"]),
628
+ friend_request: roomMessage("room_player", { playerId: PLAYER_ID_SHAPE }, ["playerId"]),
629
+ "zaisma:purchase": {
630
+ ...roomMessage("room_player", { purchaseId: gameData(4096), receipt: gameData(HELLO_PROOF_MAXIMUM_CHARACTER_COUNT) }),
631
+ reaches: "purchase",
632
+ trusts: ["player_pass", "purchase_receipt"],
633
+ },
634
+ };
635
+ /**
636
+ * Whether a message breaks its door's shape: a field of the wrong type or past its bound, or one it needs missing. A
637
+ * name the door doesn't know is left alone and never read, unlike at other doors: a game's SDK comes from Zaisma and
638
+ * its server may be the developer's own, older one, so a newer SDK's field has to keep working there.
639
+ */
640
+ export function roomMessageProblem(type, message) {
641
+ const body = ROOM_MESSAGE_DOORS[type].takes.body;
642
+ for (const name of body.required ?? [])
643
+ if (!(name in message))
644
+ return `${name} is missing`;
645
+ for (const [name, shape] of Object.entries(body.fields)) {
646
+ if (!(name in message))
647
+ continue;
648
+ const [problem] = schemaProblems(shape, message[name], name);
649
+ if (problem)
650
+ return problem;
651
+ }
652
+ return null;
653
+ }
654
+ const isRoomMessageType = (type) => typeof type === "string" && Object.hasOwn(ROOM_MESSAGE_DOORS, type);
655
+ const PLAYER_PROVEN = ["room_member", "room_player"];
656
+ const HOST_PROVEN = ["room_member", "room_player", "room_host"];
505
657
  export const OTHER_MESSAGE_TYPE = "(other)";
506
658
  /** State sync's updates (stateSyncEnabled). */
507
659
  export const STATE_MESSAGE_TYPE = "(state)";
@@ -1759,7 +1911,12 @@ export class RoomCore {
1759
1911
  const presence = this.presence.get(playerId);
1760
1912
  if (presence)
1761
1913
  presence.seenAtMs = this.now();
1762
- switch (message.type) {
1914
+ const type = isRoomMessageType(message.type) ? message.type : null;
1915
+ if (type && !checkDoor(ROOM_MESSAGE_DOORS[type], this.hostPlayerId === playerId ? HOST_PROVEN : PLAYER_PROVEN))
1916
+ return "not_allowed";
1917
+ if (type && type !== "bytes" && roomMessageProblem(type, message))
1918
+ return "bad_message";
1919
+ switch (type) {
1763
1920
  case "room":
1764
1921
  return this.hostChange(playerId, message);
1765
1922
  case "kick":
@@ -1838,7 +1995,7 @@ export class RoomCore {
1838
1995
  if (text.length <= ROOM_LIMITS.maximumGameMessageSizeBytes) {
1839
1996
  try {
1840
1997
  const message = JSON.parse(text);
1841
- if (message?.type === "probe_answer" && this.answer(spectatorId, message.probeNumber))
1998
+ if (isRoomMessageType(message?.type) && ROOM_MESSAGE_DOORS[message.type].whoMayCall === "room_member" && this.answer(spectatorId, message.probeNumber))
1842
1999
  return null;
1843
2000
  }
1844
2001
  catch {
@@ -1910,8 +2067,6 @@ export class RoomCore {
1910
2067
  * text between strangers), so the kicked player is told the host removed them.
1911
2068
  */
1912
2069
  hostKick(playerId, message) {
1913
- if (this.hostPlayerId !== playerId)
1914
- return "not_allowed";
1915
2070
  const kickedId = message.playerId;
1916
2071
  if (typeof kickedId !== "string" || kickedId === playerId || !this.players.has(kickedId))
1917
2072
  return "bad_message";
@@ -1920,8 +2075,6 @@ export class RoomCore {
1920
2075
  }
1921
2076
  /** The host hides, shows, opens, closes, turns friends' Join on or off, names or labels the room, when the game server allows it. */
1922
2077
  hostChange(playerId, message) {
1923
- if (this.hostPlayerId !== playerId)
1924
- return "not_allowed";
1925
2078
  const asked = message.room;
1926
2079
  if (!asked || typeof asked !== "object" || Array.isArray(asked))
1927
2080
  return "bad_message";