@jgengine/ws 0.9.0 → 0.11.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/CHANGELOG.md +34 -3
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/commandMiddleware.d.ts +62 -0
- package/dist/commandMiddleware.js +66 -0
- package/dist/createWsBackend.js +13 -1
- package/dist/host.d.ts +8 -0
- package/dist/host.js +170 -93
- package/dist/hostRouter.d.ts +10 -0
- package/dist/hostRouter.js +128 -38
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pipe.js +2 -1
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.js +51 -5
- package/dist/readsHandler.js +2 -1
- package/dist/worldHost.d.ts +26 -0
- package/dist/worldHost.js +119 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +51 -0
- package/skills/jgengine/api.md +434 -0
- package/skills/jgengine/capabilities.md +29 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine-multiplayer/SKILL.md +36 -0
- package/skills/jgengine-multiplayer/api.md +611 -0
- package/skills/jgengine-multiplayer/capabilities.md +9 -0
- package/skills/jgengine-multiplayer/reference.md +69 -0
- package/llms.txt +0 -1048
package/dist/hostRouter.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { decidePoseSync, spawnPresenceState } from "@jgengine/core/multiplayer/presenceModel";
|
|
2
2
|
import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
|
|
3
3
|
import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
|
|
4
|
+
import { createCommandMiddleware, } from "./commandMiddleware.js";
|
|
4
5
|
import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
|
|
5
6
|
export const DEFAULT_POSE_RULES = {
|
|
6
7
|
maxSpeed: 12,
|
|
@@ -9,6 +10,8 @@ export const DEFAULT_POSE_RULES = {
|
|
|
9
10
|
maxElapsedSec: 0.5,
|
|
10
11
|
keepAliveRefreshMs: 10_000,
|
|
11
12
|
};
|
|
13
|
+
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
|
14
|
+
export const MAX_QUEUED_MESSAGES = 64;
|
|
12
15
|
function appearanceEqual(a, b) {
|
|
13
16
|
if (a === b)
|
|
14
17
|
return true;
|
|
@@ -30,6 +33,29 @@ export function createHostRouter(options) {
|
|
|
30
33
|
(allowAnonymous ? ({ userId }) => userId : null);
|
|
31
34
|
const connections = new Set();
|
|
32
35
|
const sessionsByUserId = new Map();
|
|
36
|
+
const subscribers = new Map();
|
|
37
|
+
const subscribersOf = (key) => {
|
|
38
|
+
const set = subscribers.get(key);
|
|
39
|
+
return set === undefined ? [] : [...set];
|
|
40
|
+
};
|
|
41
|
+
const addSubscription = (connection, key) => {
|
|
42
|
+
connection.subscriptions.add(key);
|
|
43
|
+
let set = subscribers.get(key);
|
|
44
|
+
if (set === undefined) {
|
|
45
|
+
set = new Set();
|
|
46
|
+
subscribers.set(key, set);
|
|
47
|
+
}
|
|
48
|
+
set.add(connection);
|
|
49
|
+
};
|
|
50
|
+
const removeSubscription = (connection, key) => {
|
|
51
|
+
connection.subscriptions.delete(key);
|
|
52
|
+
const set = subscribers.get(key);
|
|
53
|
+
if (set === undefined)
|
|
54
|
+
return;
|
|
55
|
+
set.delete(connection);
|
|
56
|
+
if (set.size === 0)
|
|
57
|
+
subscribers.delete(key);
|
|
58
|
+
};
|
|
33
59
|
const presence = new Map();
|
|
34
60
|
const positionHistoryMs = options.positionHistoryMs ?? 1_000;
|
|
35
61
|
const histories = new Map();
|
|
@@ -65,13 +91,35 @@ export function createHostRouter(options) {
|
|
|
65
91
|
};
|
|
66
92
|
const broadcastPresence = (serverId) => {
|
|
67
93
|
const key = subscriptionKey("presence", serverId);
|
|
94
|
+
const targets = subscribersOf(key);
|
|
95
|
+
if (targets.length === 0)
|
|
96
|
+
return;
|
|
68
97
|
const data = presenceRows(serverId);
|
|
69
|
-
for (const connection of
|
|
70
|
-
if (!connection.subscriptions.has(key))
|
|
71
|
-
continue;
|
|
98
|
+
for (const connection of targets) {
|
|
72
99
|
send(connection, { v: 1, t: "update", channel: "presence", serverId, data });
|
|
73
100
|
}
|
|
74
101
|
};
|
|
102
|
+
const commandMiddleware = createCommandMiddleware({
|
|
103
|
+
limits: options.limits,
|
|
104
|
+
authorize: options.authorize,
|
|
105
|
+
validate: options.validate,
|
|
106
|
+
});
|
|
107
|
+
const gate = async (connection, id, op, args = {}) => {
|
|
108
|
+
if (connection.userId === null)
|
|
109
|
+
return true;
|
|
110
|
+
const decision = await commandMiddleware.check({
|
|
111
|
+
connection,
|
|
112
|
+
userId: connection.userId,
|
|
113
|
+
op,
|
|
114
|
+
atMs: now(),
|
|
115
|
+
...args,
|
|
116
|
+
});
|
|
117
|
+
if (!decision.allow) {
|
|
118
|
+
replyError(connection, id, decision.reason);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
};
|
|
75
123
|
const chatRings = new Map();
|
|
76
124
|
const chatLimiter = createChatRateLimiter(options.chatRateLimit ?? DEFAULT_CHAT_RATE_LIMIT);
|
|
77
125
|
const chatHistoryLimit = options.chatHistoryLimit ?? DEFAULT_CHAT_HISTORY_LIMIT;
|
|
@@ -88,10 +136,11 @@ export function createHostRouter(options) {
|
|
|
88
136
|
};
|
|
89
137
|
const broadcastChat = (serverId, channelId) => {
|
|
90
138
|
const key = subscriptionKey("chat", serverId, channelId);
|
|
139
|
+
const targets = subscribersOf(key);
|
|
140
|
+
if (targets.length === 0)
|
|
141
|
+
return;
|
|
91
142
|
const data = chatRing(serverId, channelId).slice();
|
|
92
|
-
for (const connection of
|
|
93
|
-
if (!connection.subscriptions.has(key))
|
|
94
|
-
continue;
|
|
143
|
+
for (const connection of targets) {
|
|
95
144
|
send(connection, { v: 1, t: "update", channel: "chat", serverId, action: channelId, data });
|
|
96
145
|
}
|
|
97
146
|
};
|
|
@@ -108,10 +157,11 @@ export function createHostRouter(options) {
|
|
|
108
157
|
const voiceParticipants = (serverId, channelId) => [...voiceRoster(serverId, channelId).values()].map((participant) => ({ ...participant }));
|
|
109
158
|
const broadcastVoice = (serverId, channelId) => {
|
|
110
159
|
const key = subscriptionKey("voice", serverId, channelId);
|
|
160
|
+
const targets = subscribersOf(key);
|
|
161
|
+
if (targets.length === 0)
|
|
162
|
+
return;
|
|
111
163
|
const data = voiceParticipants(serverId, channelId);
|
|
112
|
-
for (const connection of
|
|
113
|
-
if (!connection.subscriptions.has(key))
|
|
114
|
-
continue;
|
|
164
|
+
for (const connection of targets) {
|
|
115
165
|
send(connection, { v: 1, t: "update", channel: "voice", serverId, action: channelId, data });
|
|
116
166
|
}
|
|
117
167
|
};
|
|
@@ -226,24 +276,25 @@ export function createHostRouter(options) {
|
|
|
226
276
|
}
|
|
227
277
|
};
|
|
228
278
|
const onHostEvent = (event) => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
void pushSubscription(connection, "server", event.serverId);
|
|
235
|
-
}
|
|
279
|
+
if (event.type === "server") {
|
|
280
|
+
for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
|
|
281
|
+
if (connection.userId === null)
|
|
282
|
+
continue;
|
|
283
|
+
void pushSubscription(connection, "server", event.serverId);
|
|
236
284
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
285
|
+
}
|
|
286
|
+
else if (event.type === "player") {
|
|
287
|
+
for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
|
|
288
|
+
if (connection.userId === event.userId) {
|
|
240
289
|
void pushSubscription(connection, "player", event.serverId);
|
|
241
290
|
}
|
|
242
291
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
|
|
295
|
+
if (connection.userId === null)
|
|
296
|
+
continue;
|
|
297
|
+
void pushSubscription(connection, "feed", event.serverId, event.action);
|
|
247
298
|
}
|
|
248
299
|
}
|
|
249
300
|
};
|
|
@@ -251,6 +302,15 @@ export function createHostRouter(options) {
|
|
|
251
302
|
const handlePose = async (connection, serverId, pose) => {
|
|
252
303
|
if (connection.userId === null)
|
|
253
304
|
return;
|
|
305
|
+
const gateDecision = await commandMiddleware.check({
|
|
306
|
+
connection,
|
|
307
|
+
userId: connection.userId,
|
|
308
|
+
op: "pose",
|
|
309
|
+
atMs: now(),
|
|
310
|
+
serverId,
|
|
311
|
+
});
|
|
312
|
+
if (!gateDecision.allow)
|
|
313
|
+
return;
|
|
254
314
|
if (!(await host.isMember({ userId: connection.userId, serverId })))
|
|
255
315
|
return;
|
|
256
316
|
const rows = presence.get(serverId) ?? new Map();
|
|
@@ -335,17 +395,22 @@ export function createHostRouter(options) {
|
|
|
335
395
|
try {
|
|
336
396
|
switch (message.t) {
|
|
337
397
|
case "join": {
|
|
398
|
+
if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
|
|
399
|
+
return;
|
|
338
400
|
const result = await host.joinServer({
|
|
339
401
|
userId,
|
|
340
402
|
gameId: message.gameId,
|
|
341
403
|
serverId: message.serverId,
|
|
342
404
|
attributes: message.attributes,
|
|
405
|
+
code: message.code,
|
|
343
406
|
});
|
|
344
407
|
connection.joinedServers.add(result.serverId);
|
|
345
408
|
reply(connection, message.id, result);
|
|
346
409
|
return;
|
|
347
410
|
}
|
|
348
411
|
case "joinByCode": {
|
|
412
|
+
if (!(await gate(connection, message.id, "join", {})))
|
|
413
|
+
return;
|
|
349
414
|
const result = await host.joinByCode({
|
|
350
415
|
userId,
|
|
351
416
|
gameId: message.gameId,
|
|
@@ -357,6 +422,8 @@ export function createHostRouter(options) {
|
|
|
357
422
|
return;
|
|
358
423
|
}
|
|
359
424
|
case "browse": {
|
|
425
|
+
if (!(await gate(connection, message.id, "browse", {})))
|
|
426
|
+
return;
|
|
360
427
|
const result = await host.browseServers({
|
|
361
428
|
gameId: message.gameId,
|
|
362
429
|
filter: message.filter,
|
|
@@ -374,6 +441,12 @@ export function createHostRouter(options) {
|
|
|
374
441
|
return;
|
|
375
442
|
}
|
|
376
443
|
case "runCommand": {
|
|
444
|
+
if (!(await gate(connection, message.id, "runCommand", {
|
|
445
|
+
serverId: message.serverId,
|
|
446
|
+
command: message.command,
|
|
447
|
+
input: message.input,
|
|
448
|
+
})))
|
|
449
|
+
return;
|
|
377
450
|
const result = await host.runCommand({
|
|
378
451
|
userId,
|
|
379
452
|
serverId: message.serverId,
|
|
@@ -398,6 +471,8 @@ export function createHostRouter(options) {
|
|
|
398
471
|
return;
|
|
399
472
|
}
|
|
400
473
|
case "voiceJoin": {
|
|
474
|
+
if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
|
|
475
|
+
return;
|
|
401
476
|
if ((await requireMembership(connection, message.id, message.serverId)) === null)
|
|
402
477
|
return;
|
|
403
478
|
const participant = { userId };
|
|
@@ -409,6 +484,8 @@ export function createHostRouter(options) {
|
|
|
409
484
|
return;
|
|
410
485
|
}
|
|
411
486
|
case "voiceLeave": {
|
|
487
|
+
if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
|
|
488
|
+
return;
|
|
412
489
|
if ((await requireMembership(connection, message.id, message.serverId)) === null)
|
|
413
490
|
return;
|
|
414
491
|
const roster = voiceRoster(message.serverId, message.channelId);
|
|
@@ -419,6 +496,8 @@ export function createHostRouter(options) {
|
|
|
419
496
|
return;
|
|
420
497
|
}
|
|
421
498
|
case "voicePublish": {
|
|
499
|
+
if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
|
|
500
|
+
return;
|
|
422
501
|
if ((await requireMembership(connection, message.id, message.serverId)) === null)
|
|
423
502
|
return;
|
|
424
503
|
const participant = voiceRoster(message.serverId, message.channelId).get(userId);
|
|
@@ -439,13 +518,13 @@ export function createHostRouter(options) {
|
|
|
439
518
|
replyError(connection, message.id, "Not a member of this server");
|
|
440
519
|
return;
|
|
441
520
|
}
|
|
442
|
-
connection
|
|
521
|
+
addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
443
522
|
reply(connection, message.id, null);
|
|
444
523
|
await pushSubscription(connection, message.channel, message.serverId, message.action);
|
|
445
524
|
return;
|
|
446
525
|
}
|
|
447
526
|
case "unsubscribe": {
|
|
448
|
-
connection
|
|
527
|
+
removeSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
449
528
|
reply(connection, message.id, null);
|
|
450
529
|
return;
|
|
451
530
|
}
|
|
@@ -473,30 +552,40 @@ export function createHostRouter(options) {
|
|
|
473
552
|
subscriptions: new Set(),
|
|
474
553
|
joinedServers: new Set(),
|
|
475
554
|
queue: Promise.resolve(),
|
|
555
|
+
queuedMessages: 0,
|
|
476
556
|
};
|
|
477
557
|
connections.add(connection);
|
|
478
558
|
return {
|
|
479
559
|
handleRaw: (raw) => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
if (
|
|
485
|
-
|
|
486
|
-
if (failure.id !== undefined) {
|
|
487
|
-
replyError(connection, failure.id, failure.reason);
|
|
488
|
-
}
|
|
489
|
-
return;
|
|
560
|
+
const text = typeof raw === "string" ? raw : String(raw);
|
|
561
|
+
const message = decodeWsClientMessage(text);
|
|
562
|
+
if (message === null) {
|
|
563
|
+
const failure = inspectWsDecodeFailure(text);
|
|
564
|
+
if (failure.id !== undefined) {
|
|
565
|
+
replyError(connection, failure.id, failure.reason);
|
|
490
566
|
}
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (connection.queuedMessages >= MAX_QUEUED_MESSAGES) {
|
|
570
|
+
if ("id" in message)
|
|
571
|
+
replyError(connection, message.id, "Too many pending requests");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
connection.queuedMessages += 1;
|
|
575
|
+
connection.queue = connection.queue
|
|
576
|
+
.then(() => handleMessage(connection, message))
|
|
577
|
+
.catch(() => undefined)
|
|
578
|
+
.finally(() => {
|
|
579
|
+
connection.queuedMessages -= 1;
|
|
580
|
+
});
|
|
494
581
|
},
|
|
495
582
|
close: () => {
|
|
496
583
|
if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
|
|
497
584
|
sessionsByUserId.delete(connection.userId);
|
|
498
585
|
}
|
|
499
586
|
connections.delete(connection);
|
|
587
|
+
for (const key of [...connection.subscriptions])
|
|
588
|
+
removeSubscription(connection, key);
|
|
500
589
|
leaveJoinedServers(connection);
|
|
501
590
|
dropPresence(connection);
|
|
502
591
|
dropVoice(connection);
|
|
@@ -519,6 +608,7 @@ export function createHostRouter(options) {
|
|
|
519
608
|
close: () => {
|
|
520
609
|
unsubscribeHost();
|
|
521
610
|
connections.clear();
|
|
611
|
+
subscribers.clear();
|
|
522
612
|
},
|
|
523
613
|
};
|
|
524
614
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./commandMiddleware.js";
|
|
1
2
|
export * from "./createWsBackend.js";
|
|
2
3
|
export * from "./host.js";
|
|
3
4
|
export * from "./hostRouter.js";
|
|
@@ -8,3 +9,4 @@ export * from "./protocol.js";
|
|
|
8
9
|
export * from "./readsHandler.js";
|
|
9
10
|
export * from "./socketIoPipe.js";
|
|
10
11
|
export * from "./voiceChannel.js";
|
|
12
|
+
export * from "./worldHost.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./commandMiddleware.js";
|
|
1
2
|
export * from "./createWsBackend.js";
|
|
2
3
|
export * from "./host.js";
|
|
3
4
|
export * from "./hostRouter.js";
|
|
@@ -8,3 +9,4 @@ export * from "./protocol.js";
|
|
|
8
9
|
export * from "./readsHandler.js";
|
|
9
10
|
export * from "./socketIoPipe.js";
|
|
10
11
|
export * from "./voiceChannel.js";
|
|
12
|
+
export * from "./worldHost.js";
|
package/dist/pipe.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const OPEN = 1;
|
|
1
2
|
export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(target)) {
|
|
2
3
|
return (handlers) => {
|
|
3
4
|
const socket = webSocketFactory(url);
|
|
@@ -6,7 +7,7 @@ export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(
|
|
|
6
7
|
socket.onclose = () => handlers.onClose();
|
|
7
8
|
return {
|
|
8
9
|
send: (data) => {
|
|
9
|
-
if (socket.readyState !==
|
|
10
|
+
if (socket.readyState !== OPEN)
|
|
10
11
|
return;
|
|
11
12
|
socket.send(data);
|
|
12
13
|
},
|
package/dist/protocol.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ import type { PlayerPose } from "@jgengine/core/multiplayer/poseSyncGate";
|
|
|
3
3
|
import type { SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
|
|
4
4
|
import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
|
|
5
5
|
export declare const WS_PROTOCOL_VERSION = 1;
|
|
6
|
+
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
7
|
+
export declare const MAX_COMMAND_LENGTH = 4096;
|
|
8
|
+
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
9
|
+
export declare const MAX_FEED_ACTION_LENGTH = 256;
|
|
10
|
+
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
11
|
+
export declare const MAX_FEED_ENTRY_BYTES = 65536;
|
|
12
|
+
/** Max number of keys in a pose `appearance` tag map. */
|
|
13
|
+
export declare const MAX_APPEARANCE_ENTRIES = 32;
|
|
14
|
+
/** Max length of a single `appearance` tag string value, in UTF-16 code units. */
|
|
15
|
+
export declare const MAX_APPEARANCE_VALUE_LENGTH = 256;
|
|
6
16
|
export type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice";
|
|
7
17
|
/** Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. */
|
|
8
18
|
export type WsAppearance = Record<string, string | number | boolean>;
|
|
@@ -36,6 +46,7 @@ export type WsClientMessage = {
|
|
|
36
46
|
gameId: string;
|
|
37
47
|
serverId?: string;
|
|
38
48
|
attributes?: SessionAttributes;
|
|
49
|
+
code?: string;
|
|
39
50
|
} | {
|
|
40
51
|
v: 1;
|
|
41
52
|
t: "joinByCode";
|
|
@@ -172,12 +183,17 @@ export type WsJoinResult = JoinServerResult;
|
|
|
172
183
|
export type WsRunCommandResult = TransportRunCommandResult;
|
|
173
184
|
export type WsBrowseResult = SessionListing[];
|
|
174
185
|
export type WsJoinByCodeResult = JoinServerResult | null;
|
|
186
|
+
/** @internal */
|
|
175
187
|
export declare function encodeWsMessage(message: WsClientMessage | WsServerMessage): string;
|
|
176
188
|
export type WsDecodeFailure = {
|
|
177
189
|
reason: string;
|
|
178
190
|
id?: number;
|
|
179
191
|
};
|
|
192
|
+
/** @internal */
|
|
180
193
|
export declare function inspectWsDecodeFailure(raw: unknown): WsDecodeFailure;
|
|
194
|
+
/** @internal */
|
|
181
195
|
export declare function decodeWsClientMessage(raw: unknown): WsClientMessage | null;
|
|
196
|
+
/** @internal */
|
|
182
197
|
export declare function decodeWsServerMessage(raw: unknown): WsServerMessage | null;
|
|
198
|
+
/** @internal */
|
|
183
199
|
export declare function subscriptionKey(channel: WsChannel, serverId: string, action?: string): string;
|
package/dist/protocol.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export const WS_PROTOCOL_VERSION = 1;
|
|
2
|
+
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
3
|
+
export const MAX_COMMAND_LENGTH = 4_096;
|
|
4
|
+
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
5
|
+
export const MAX_FEED_ACTION_LENGTH = 256;
|
|
6
|
+
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
7
|
+
export const MAX_FEED_ENTRY_BYTES = 65_536;
|
|
8
|
+
/** Max number of keys in a pose `appearance` tag map. */
|
|
9
|
+
export const MAX_APPEARANCE_ENTRIES = 32;
|
|
10
|
+
/** Max length of a single `appearance` tag string value, in UTF-16 code units. */
|
|
11
|
+
export const MAX_APPEARANCE_VALUE_LENGTH = 256;
|
|
12
|
+
/** @internal */
|
|
2
13
|
export function encodeWsMessage(message) {
|
|
3
14
|
return JSON.stringify(message);
|
|
4
15
|
}
|
|
@@ -32,8 +43,17 @@ function isWsChannel(value) {
|
|
|
32
43
|
value === "voice");
|
|
33
44
|
}
|
|
34
45
|
function isAppearance(value) {
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
if (!isRecord(value))
|
|
47
|
+
return false;
|
|
48
|
+
const entries = Object.values(value);
|
|
49
|
+
if (entries.length > MAX_APPEARANCE_ENTRIES)
|
|
50
|
+
return false;
|
|
51
|
+
return entries.every((v) => (typeof v === "string" && v.length <= MAX_APPEARANCE_VALUE_LENGTH) ||
|
|
52
|
+
typeof v === "number" ||
|
|
53
|
+
typeof v === "boolean");
|
|
54
|
+
}
|
|
55
|
+
function withinFeedEntryBudget(entry) {
|
|
56
|
+
return new TextEncoder().encode(JSON.stringify(entry)).length <= MAX_FEED_ENTRY_BYTES;
|
|
37
57
|
}
|
|
38
58
|
function isFiniteNumber(value) {
|
|
39
59
|
return typeof value === "number" && Number.isFinite(value);
|
|
@@ -47,6 +67,7 @@ function isPose(value) {
|
|
|
47
67
|
isFiniteNumber(value.rotationPitch) &&
|
|
48
68
|
(value.appearance === undefined || isAppearance(value.appearance)));
|
|
49
69
|
}
|
|
70
|
+
/** @internal */
|
|
50
71
|
export function inspectWsDecodeFailure(raw) {
|
|
51
72
|
if (typeof raw !== "string")
|
|
52
73
|
return { reason: "Invalid message framing" };
|
|
@@ -66,8 +87,27 @@ export function inspectWsDecodeFailure(raw) {
|
|
|
66
87
|
if (typeof parsed.t !== "string") {
|
|
67
88
|
return { reason: "Invalid message type", id };
|
|
68
89
|
}
|
|
90
|
+
if (parsed.t === "runCommand" && typeof parsed.command === "string" && parsed.command.length > MAX_COMMAND_LENGTH) {
|
|
91
|
+
return { reason: "Command exceeds max length", id };
|
|
92
|
+
}
|
|
93
|
+
if (parsed.t === "pushFeed" && typeof parsed.action === "string" && parsed.action.length > MAX_FEED_ACTION_LENGTH) {
|
|
94
|
+
return { reason: "Feed action exceeds max length", id };
|
|
95
|
+
}
|
|
96
|
+
if (parsed.t === "pushFeed" && "entry" in parsed && !withinFeedEntryBudget(parsed.entry)) {
|
|
97
|
+
return { reason: "Feed entry exceeds max size", id };
|
|
98
|
+
}
|
|
99
|
+
if (parsed.t === "pose" && isRecord(parsed.pose) && isRecord(parsed.pose.appearance)) {
|
|
100
|
+
const values = Object.values(parsed.pose.appearance);
|
|
101
|
+
if (values.length > MAX_APPEARANCE_ENTRIES) {
|
|
102
|
+
return { reason: "Appearance exceeds max entries", id };
|
|
103
|
+
}
|
|
104
|
+
if (values.some((v) => typeof v === "string" && v.length > MAX_APPEARANCE_VALUE_LENGTH)) {
|
|
105
|
+
return { reason: "Appearance value exceeds max length", id };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
69
108
|
return { reason: "Malformed message", id };
|
|
70
109
|
}
|
|
110
|
+
/** @internal */
|
|
71
111
|
export function decodeWsClientMessage(raw) {
|
|
72
112
|
const message = parseVersioned(raw);
|
|
73
113
|
if (message === null)
|
|
@@ -82,7 +122,8 @@ export function decodeWsClientMessage(raw) {
|
|
|
82
122
|
case "join":
|
|
83
123
|
return typeof message.id === "number" &&
|
|
84
124
|
typeof message.gameId === "string" &&
|
|
85
|
-
(message.serverId === undefined || typeof message.serverId === "string")
|
|
125
|
+
(message.serverId === undefined || typeof message.serverId === "string") &&
|
|
126
|
+
(message.code === undefined || typeof message.code === "string")
|
|
86
127
|
? message
|
|
87
128
|
: null;
|
|
88
129
|
case "joinByCode":
|
|
@@ -102,13 +143,16 @@ export function decodeWsClientMessage(raw) {
|
|
|
102
143
|
case "runCommand":
|
|
103
144
|
return typeof message.id === "number" &&
|
|
104
145
|
typeof message.serverId === "string" &&
|
|
105
|
-
typeof message.command === "string"
|
|
146
|
+
typeof message.command === "string" &&
|
|
147
|
+
message.command.length <= MAX_COMMAND_LENGTH
|
|
106
148
|
? message
|
|
107
149
|
: null;
|
|
108
150
|
case "pushFeed":
|
|
109
151
|
return typeof message.id === "number" &&
|
|
110
152
|
typeof message.serverId === "string" &&
|
|
111
|
-
typeof message.action === "string"
|
|
153
|
+
typeof message.action === "string" &&
|
|
154
|
+
message.action.length <= MAX_FEED_ACTION_LENGTH &&
|
|
155
|
+
withinFeedEntryBudget(message.entry)
|
|
112
156
|
? message
|
|
113
157
|
: null;
|
|
114
158
|
case "subscribe":
|
|
@@ -154,6 +198,7 @@ export function decodeWsClientMessage(raw) {
|
|
|
154
198
|
return null;
|
|
155
199
|
}
|
|
156
200
|
}
|
|
201
|
+
/** @internal */
|
|
157
202
|
export function decodeWsServerMessage(raw) {
|
|
158
203
|
const message = parseVersioned(raw);
|
|
159
204
|
if (message === null)
|
|
@@ -176,6 +221,7 @@ export function decodeWsServerMessage(raw) {
|
|
|
176
221
|
return null;
|
|
177
222
|
}
|
|
178
223
|
}
|
|
224
|
+
/** @internal */
|
|
179
225
|
export function subscriptionKey(channel, serverId, action) {
|
|
180
226
|
return `${channel}|${serverId}|${action ?? ""}`;
|
|
181
227
|
}
|
package/dist/readsHandler.js
CHANGED
|
@@ -41,7 +41,8 @@ export function createReadsHandler(options) {
|
|
|
41
41
|
return json(await options.listOpenServers({ gameId, limit }));
|
|
42
42
|
}
|
|
43
43
|
const records = await (await getPersistence()).listServers(gameId);
|
|
44
|
-
|
|
44
|
+
const publicRecords = records.filter((record) => (record.visibility ?? "public") !== "private");
|
|
45
|
+
return json(toOpenServerListings(publicRecords.map((record) => toServerListing(record)), limit));
|
|
45
46
|
}
|
|
46
47
|
if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
|
|
47
48
|
const scope = url.searchParams.get("scope") ?? "global";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HostedWorldSession } from "@jgengine/core/runtime/hostedWorldSession";
|
|
2
|
+
import type { GameHost } from "./host.js";
|
|
3
|
+
/** Config for {@link createWorldGameHost}: how to resolve a hosted world's authoritative session per server. */
|
|
4
|
+
export interface WorldGameHostOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Resolve (or lazily build) the authoritative {@link HostedWorldSession} for a server — called once per new
|
|
7
|
+
* `serverId`. Return `null` for an unknown game. Bind the game's definition + content here.
|
|
8
|
+
*/
|
|
9
|
+
session(args: {
|
|
10
|
+
gameId: string;
|
|
11
|
+
serverId: string;
|
|
12
|
+
}): HostedWorldSession | null;
|
|
13
|
+
now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
/** A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. */
|
|
16
|
+
export interface WorldGameHost extends GameHost {
|
|
17
|
+
/** Advance every live world by `dtSeconds` and emit a `server` change for each whose revision moved. */
|
|
18
|
+
tick(dtSeconds: number): void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The GameContext-loop counterpart of the reducer `createGameHost`: a structural {@link GameHost} that serves each
|
|
22
|
+
* world's full `WorldSnapshot` as `serverState`, so the existing ws router, `createWsBackend`, and the shell's
|
|
23
|
+
* `attachWorldSync` carry host-authoritative GameContext worlds with zero changes to any of them. The harness owns
|
|
24
|
+
* the tick cadence (call {@link WorldGameHost.tick} on an interval); commands and joins broadcast immediately.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createWorldGameHost(options: WorldGameHostOptions): WorldGameHost;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
|
|
2
|
+
/**
|
|
3
|
+
* The GameContext-loop counterpart of the reducer `createGameHost`: a structural {@link GameHost} that serves each
|
|
4
|
+
* world's full `WorldSnapshot` as `serverState`, so the existing ws router, `createWsBackend`, and the shell's
|
|
5
|
+
* `attachWorldSync` carry host-authoritative GameContext worlds with zero changes to any of them. The harness owns
|
|
6
|
+
* the tick cadence (call {@link WorldGameHost.tick} on an interval); commands and joins broadcast immediately.
|
|
7
|
+
*/
|
|
8
|
+
export function createWorldGameHost(options) {
|
|
9
|
+
const live = new Map();
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
const now = options.now ?? (() => Date.now());
|
|
12
|
+
function emit(event) {
|
|
13
|
+
for (const listener of listeners)
|
|
14
|
+
listener(event);
|
|
15
|
+
}
|
|
16
|
+
function ensure(gameId, serverId) {
|
|
17
|
+
const existing = live.get(serverId);
|
|
18
|
+
if (existing !== undefined)
|
|
19
|
+
return existing;
|
|
20
|
+
const session = options.session({ gameId, serverId });
|
|
21
|
+
if (session === null)
|
|
22
|
+
return null;
|
|
23
|
+
const entry = { gameId, session };
|
|
24
|
+
live.set(serverId, entry);
|
|
25
|
+
return entry;
|
|
26
|
+
}
|
|
27
|
+
function tickAll(dtSeconds) {
|
|
28
|
+
for (const [serverId, entry] of live) {
|
|
29
|
+
const before = entry.session.revision();
|
|
30
|
+
entry.session.tick(dtSeconds);
|
|
31
|
+
if (entry.session.revision() !== before)
|
|
32
|
+
emit({ type: "server", serverId });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
async joinServer({ userId, gameId, serverId }) {
|
|
37
|
+
const id = serverId ?? gameId;
|
|
38
|
+
const entry = ensure(gameId, id);
|
|
39
|
+
if (entry === null)
|
|
40
|
+
throw new Error(`no hosted world for game "${gameId}"`);
|
|
41
|
+
const isNew = !entry.session.members().includes(userId);
|
|
42
|
+
entry.session.join(userId, isNew);
|
|
43
|
+
emit({ type: "server", serverId: id });
|
|
44
|
+
emit({ type: "player", serverId: id, userId });
|
|
45
|
+
return { serverId: id, isNew };
|
|
46
|
+
},
|
|
47
|
+
async leaveServer({ userId, serverId }) {
|
|
48
|
+
const entry = live.get(serverId);
|
|
49
|
+
if (entry === undefined)
|
|
50
|
+
return;
|
|
51
|
+
entry.session.leave(userId);
|
|
52
|
+
emit({ type: "server", serverId });
|
|
53
|
+
},
|
|
54
|
+
async runCommand({ userId, serverId, command, input }) {
|
|
55
|
+
const entry = live.get(serverId);
|
|
56
|
+
if (entry === undefined)
|
|
57
|
+
return { ok: false, reason: "no-server" };
|
|
58
|
+
if (command === INPUT_COMMAND) {
|
|
59
|
+
entry.session.input(userId, input);
|
|
60
|
+
return { ok: true };
|
|
61
|
+
}
|
|
62
|
+
const before = entry.session.revision();
|
|
63
|
+
const result = entry.session.command(userId, command, input);
|
|
64
|
+
if (result.status === "rejected")
|
|
65
|
+
return { ok: false, reason: result.reason };
|
|
66
|
+
if (result.status === "unknown-command")
|
|
67
|
+
return { ok: false, reason: "unknown-command" };
|
|
68
|
+
if (entry.session.revision() !== before)
|
|
69
|
+
emit({ type: "server", serverId });
|
|
70
|
+
return { ok: true };
|
|
71
|
+
},
|
|
72
|
+
async isMember({ userId, serverId }) {
|
|
73
|
+
return live.get(serverId)?.session.members().includes(userId) ?? false;
|
|
74
|
+
},
|
|
75
|
+
async getServerView({ userId, serverId }) {
|
|
76
|
+
const entry = live.get(serverId);
|
|
77
|
+
if (entry === undefined)
|
|
78
|
+
return null;
|
|
79
|
+
return {
|
|
80
|
+
serverId,
|
|
81
|
+
gameId: entry.gameId,
|
|
82
|
+
revision: entry.session.revision(),
|
|
83
|
+
memberUserIds: [...entry.session.members()],
|
|
84
|
+
serverState: entry.session.snapshotFor({ userId }),
|
|
85
|
+
updatedAt: now(),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
async getPlayerView() {
|
|
89
|
+
return null;
|
|
90
|
+
},
|
|
91
|
+
async getFeed() {
|
|
92
|
+
return [];
|
|
93
|
+
},
|
|
94
|
+
async pushFeedEntry() { },
|
|
95
|
+
async browseServers() {
|
|
96
|
+
return [];
|
|
97
|
+
},
|
|
98
|
+
async joinByCode() {
|
|
99
|
+
return null;
|
|
100
|
+
},
|
|
101
|
+
async listOpenServers() {
|
|
102
|
+
return [];
|
|
103
|
+
},
|
|
104
|
+
tick: tickAll,
|
|
105
|
+
async tickOnce() {
|
|
106
|
+
tickAll(0);
|
|
107
|
+
return { ticked: live.size, saved: 0 };
|
|
108
|
+
},
|
|
109
|
+
async flushAll() {
|
|
110
|
+
return 0;
|
|
111
|
+
},
|
|
112
|
+
start() { },
|
|
113
|
+
async stop() { },
|
|
114
|
+
subscribe(listener) {
|
|
115
|
+
listeners.add(listener);
|
|
116
|
+
return () => listeners.delete(listener);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|