@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/bench/glass.js +25098 -24583
- package/bin/node.js +2 -2
- package/package.json +1 -1
- package/sdk/zaisma.d.ts +4 -0
- package/sdk/zaisma.js +64 -4
- package/src/doors.d.ts +210 -0
- package/src/doors.js +281 -0
- package/src/internal.d.ts +1 -0
- package/src/internal.js +1 -0
- package/src/requests.d.ts +1 -1
- package/src/requests.js +2 -0
- package/src/room.d.ts +101 -1
- package/src/room.js +159 -6
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
|
-
|
|
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 === "
|
|
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";
|