@jgengine/ws 0.18.0 → 0.19.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 +265 -2
- package/dist/createWsBackend.d.ts +6 -0
- package/dist/createWsBackend.js +121 -12
- package/dist/host.d.ts +12 -1
- package/dist/host.js +8 -3
- package/dist/hostRouter.d.ts +5 -1
- package/dist/hostRouter.js +109 -10
- package/dist/protocol.d.ts +18 -0
- package/dist/protocol.js +15 -3
- package/dist/worldHost.d.ts +1 -1
- package/dist/worldHost.js +46 -10
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-multiplayer/SKILL.md +25 -0
- package/skills/jgengine-multiplayer/api.md +159 -109
- package/skills/jgengine-multiplayer/capabilities.md +37 -0
package/dist/host.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
2
|
import type { HostPersistence, ServerListing, SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
|
|
3
3
|
import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
|
-
import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
|
|
4
|
+
import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult, WorldSyncFrame } from "@jgengine/core/runtime/transport";
|
|
5
|
+
import type { SnapshotViewer } from "@jgengine/core/runtime/worldSnapshot";
|
|
5
6
|
/** A change notification emitted by a `GameHost` for a server, player, or feed mutation. */
|
|
6
7
|
export type HostChangeEvent = {
|
|
7
8
|
type: "server";
|
|
@@ -33,6 +34,7 @@ export type GameHost = {
|
|
|
33
34
|
serverId?: string;
|
|
34
35
|
attributes?: SessionAttributes;
|
|
35
36
|
code?: string;
|
|
37
|
+
role?: SnapshotViewer["role"];
|
|
36
38
|
}) => Promise<JoinServerResult>;
|
|
37
39
|
browseServers: (args: {
|
|
38
40
|
gameId: string;
|
|
@@ -53,6 +55,8 @@ export type GameHost = {
|
|
|
53
55
|
serverId: string;
|
|
54
56
|
command: string;
|
|
55
57
|
input: unknown;
|
|
58
|
+
/** Set only from host code: runs commands marked `access: "server"`, which client messages cannot. */
|
|
59
|
+
trusted?: boolean;
|
|
56
60
|
}) => Promise<TransportRunCommandResult>;
|
|
57
61
|
isMember: (args: {
|
|
58
62
|
userId: string;
|
|
@@ -61,7 +65,14 @@ export type GameHost = {
|
|
|
61
65
|
getServerView: (args: {
|
|
62
66
|
userId: string;
|
|
63
67
|
serverId: string;
|
|
68
|
+
role?: SnapshotViewer["role"];
|
|
64
69
|
}) => Promise<GameRuntimeServerView | null>;
|
|
70
|
+
pullWorld?: (args: {
|
|
71
|
+
userId: string;
|
|
72
|
+
serverId: string;
|
|
73
|
+
sinceRevision: number | null;
|
|
74
|
+
role?: SnapshotViewer["role"];
|
|
75
|
+
}) => Promise<WorldSyncFrame | null>;
|
|
65
76
|
getPlayerView: (args: {
|
|
66
77
|
userId: string;
|
|
67
78
|
serverId: string;
|
package/dist/host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
|
+
import { SERVER_ONLY_COMMAND_REASON } from "@jgengine/core/runtime/commandRunner";
|
|
2
3
|
import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
3
4
|
import { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
4
5
|
import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
@@ -96,6 +97,7 @@ export function createGameHost(options) {
|
|
|
96
97
|
playersByUserId: buildHydratePlayers(record, profiles),
|
|
97
98
|
chunksByKey,
|
|
98
99
|
revision: record.revision,
|
|
100
|
+
nowMs: now(),
|
|
99
101
|
});
|
|
100
102
|
const existing = live.get(record.serverId);
|
|
101
103
|
if (existing)
|
|
@@ -160,7 +162,7 @@ export function createGameHost(options) {
|
|
|
160
162
|
return { ticked: 0, saved: 0 };
|
|
161
163
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
162
164
|
const before = entry.snapshot.revision;
|
|
163
|
-
entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
|
|
165
|
+
entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000, timestamp);
|
|
164
166
|
entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
|
|
165
167
|
if (entry.snapshot.revision !== before) {
|
|
166
168
|
markMutated(entry);
|
|
@@ -265,7 +267,7 @@ export function createGameHost(options) {
|
|
|
265
267
|
updatedAt: timestamp,
|
|
266
268
|
dirtyAt: timestamp,
|
|
267
269
|
};
|
|
268
|
-
entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
|
|
270
|
+
entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew, timestamp);
|
|
269
271
|
await flushServer(entry);
|
|
270
272
|
emit({ type: "server", serverId: entry.record.serverId });
|
|
271
273
|
emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
|
|
@@ -329,7 +331,10 @@ export function createGameHost(options) {
|
|
|
329
331
|
}
|
|
330
332
|
const commandInput = envelope !== null ? envelope.input : args.input;
|
|
331
333
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
332
|
-
|
|
334
|
+
if (args.trusted !== true && runtime.commandAccess(args.command) === "server") {
|
|
335
|
+
return { ok: false, reason: SERVER_ONLY_COMMAND_REASON };
|
|
336
|
+
}
|
|
337
|
+
const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput, now());
|
|
333
338
|
const outcome = result.ok
|
|
334
339
|
? { ok: true }
|
|
335
340
|
: { ok: false, reason: result.reason };
|
package/dist/hostRouter.d.ts
CHANGED
|
@@ -17,13 +17,15 @@ export type HostRouterOptions = {
|
|
|
17
17
|
chatRateLimit?: ChatRateLimit;
|
|
18
18
|
chatHistoryLimit?: number;
|
|
19
19
|
chatMaxBodyLength?: number;
|
|
20
|
-
/** Per-op rate limits for pose/runCommand/join/browse/voice.
|
|
20
|
+
/** Per-op rate limits for pose/runCommand/join/browse/voice. Defaults to {@link DEFAULT_COMMAND_LIMITS}. */
|
|
21
21
|
limits?: CommandLimits;
|
|
22
22
|
/** Per-command authorization hook; defaults to allow-all. */
|
|
23
23
|
authorize?: CommandAuthorize;
|
|
24
24
|
/** Declared `runCommand` names and input validators. Omit (default) to pass every command through unchanged; set to reject unknown command names and validate declared ones. */
|
|
25
25
|
validate?: CommandCatalog;
|
|
26
26
|
now?: () => number;
|
|
27
|
+
/** How long a disconnected membership remains reclaimable. Defaults to 15 seconds. */
|
|
28
|
+
graceMs?: number;
|
|
27
29
|
};
|
|
28
30
|
export type HostRouterTransport = {
|
|
29
31
|
send: (data: string) => void;
|
|
@@ -51,5 +53,7 @@ export type HostRouter = {
|
|
|
51
53
|
export declare const DEFAULT_POSE_RULES: PoseSyncRules;
|
|
52
54
|
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
|
53
55
|
export declare const MAX_QUEUED_MESSAGES = 64;
|
|
56
|
+
/** Default reconnect grace period for hosted socket sessions. */
|
|
57
|
+
export declare const DEFAULT_GRACE_MS = 15000;
|
|
54
58
|
export declare function createHostRouter(options: HostRouterOptions): HostRouter;
|
|
55
59
|
export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;
|
package/dist/hostRouter.js
CHANGED
|
@@ -2,10 +2,13 @@ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@j
|
|
|
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
4
|
import { createCommandMiddleware, } from "./commandMiddleware.js";
|
|
5
|
+
import { DEFAULT_COMMAND_LIMITS } from "./commandMiddleware.js";
|
|
5
6
|
import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
|
|
6
7
|
export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
|
|
7
8
|
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
|
8
9
|
export const MAX_QUEUED_MESSAGES = 64;
|
|
10
|
+
/** Default reconnect grace period for hosted socket sessions. */
|
|
11
|
+
export const DEFAULT_GRACE_MS = 15_000;
|
|
9
12
|
function appearanceEqual(a, b) {
|
|
10
13
|
if (a === b)
|
|
11
14
|
return true;
|
|
@@ -43,6 +46,7 @@ function dropUserFrom(store, userId, shouldVisit, onChanged) {
|
|
|
43
46
|
export function createHostRouter(options) {
|
|
44
47
|
const host = options.host;
|
|
45
48
|
const now = options.now ?? Date.now;
|
|
49
|
+
const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
|
46
50
|
const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
|
|
47
51
|
const allowAnonymous = options.allowAnonymous === true;
|
|
48
52
|
const singleSession = options.singleSession !== false;
|
|
@@ -50,6 +54,29 @@ export function createHostRouter(options) {
|
|
|
50
54
|
(allowAnonymous ? ({ userId }) => userId : null);
|
|
51
55
|
const connections = new Set();
|
|
52
56
|
const sessionsByUserId = new Map();
|
|
57
|
+
const resumeTokens = new Map();
|
|
58
|
+
const pendingLeaves = new Map();
|
|
59
|
+
const ticketKey = (userId, serverId) => `${userId}|${serverId}`;
|
|
60
|
+
const ticketFor = (userId, serverId) => {
|
|
61
|
+
const token = resumeTokens.get(userId) ?? crypto.randomUUID();
|
|
62
|
+
resumeTokens.set(userId, token);
|
|
63
|
+
return { userId, serverId, token };
|
|
64
|
+
};
|
|
65
|
+
const clearPendingLeave = (userId, serverId) => {
|
|
66
|
+
const key = ticketKey(userId, serverId);
|
|
67
|
+
const timer = pendingLeaves.get(key);
|
|
68
|
+
if (timer === undefined)
|
|
69
|
+
return;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
pendingLeaves.delete(key);
|
|
72
|
+
};
|
|
73
|
+
const heldByOtherConnection = (self, userId, serverId) => {
|
|
74
|
+
for (const other of connections) {
|
|
75
|
+
if (other !== self && other.userId === userId && other.joinedServers.has(serverId))
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
53
80
|
const subscribers = new Map();
|
|
54
81
|
const subscribersOf = (key) => {
|
|
55
82
|
const set = subscribers.get(key);
|
|
@@ -115,7 +142,7 @@ export function createHostRouter(options) {
|
|
|
115
142
|
data: presenceRows(serverId),
|
|
116
143
|
}));
|
|
117
144
|
const commandMiddleware = createCommandMiddleware({
|
|
118
|
-
limits: options.limits,
|
|
145
|
+
limits: options.limits ?? DEFAULT_COMMAND_LIMITS,
|
|
119
146
|
authorize: options.authorize,
|
|
120
147
|
validate: options.validate,
|
|
121
148
|
});
|
|
@@ -221,7 +248,19 @@ export function createHostRouter(options) {
|
|
|
221
248
|
if (connection.userId === null)
|
|
222
249
|
return;
|
|
223
250
|
if (channel === "server") {
|
|
224
|
-
|
|
251
|
+
if (host.pullWorld !== undefined) {
|
|
252
|
+
const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
|
|
253
|
+
const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
|
|
254
|
+
if (world !== null) {
|
|
255
|
+
connection.worldRevisions.set(serverId, world.revision);
|
|
256
|
+
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
257
|
+
if (data !== null) {
|
|
258
|
+
send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
225
264
|
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
226
265
|
}
|
|
227
266
|
else if (channel === "player") {
|
|
@@ -336,7 +375,10 @@ export function createHostRouter(options) {
|
|
|
336
375
|
connection.transport.close();
|
|
337
376
|
return;
|
|
338
377
|
}
|
|
339
|
-
const
|
|
378
|
+
const resumeToken = resumeTokens.get(message.userId);
|
|
379
|
+
const userId = resumeToken !== undefined && message.token === resumeToken
|
|
380
|
+
? message.userId
|
|
381
|
+
: await authenticate({ userId: message.userId, token: message.token });
|
|
340
382
|
if (userId === null) {
|
|
341
383
|
replyError(connection, message.id, "Not authenticated");
|
|
342
384
|
connection.transport.close();
|
|
@@ -352,10 +394,20 @@ export function createHostRouter(options) {
|
|
|
352
394
|
sessionsByUserId.set(userId, connection);
|
|
353
395
|
}
|
|
354
396
|
connection.userId = userId;
|
|
397
|
+
if (resumeToken !== undefined && message.token === resumeToken) {
|
|
398
|
+
for (const key of [...pendingLeaves.keys()]) {
|
|
399
|
+
if (key.startsWith(`${userId}|`)) {
|
|
400
|
+
const serverId = key.slice(userId.length + 1);
|
|
401
|
+
clearPendingLeave(userId, serverId);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
355
405
|
reply(connection, message.id, { userId });
|
|
356
406
|
return;
|
|
357
407
|
}
|
|
358
408
|
if (message.t === "pose") {
|
|
409
|
+
if (connection.roles.get(message.serverId) === "spectator")
|
|
410
|
+
return;
|
|
359
411
|
await handlePose(connection, message.serverId, message.pose);
|
|
360
412
|
return;
|
|
361
413
|
}
|
|
@@ -366,6 +418,9 @@ export function createHostRouter(options) {
|
|
|
366
418
|
const userId = connection.userId;
|
|
367
419
|
try {
|
|
368
420
|
switch (message.t) {
|
|
421
|
+
case "ping":
|
|
422
|
+
send(connection, { v: 1, t: "pong", id: message.id, at: message.at, serverAt: now() });
|
|
423
|
+
break;
|
|
369
424
|
case "join": {
|
|
370
425
|
if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
|
|
371
426
|
return;
|
|
@@ -375,9 +430,16 @@ export function createHostRouter(options) {
|
|
|
375
430
|
serverId: message.serverId,
|
|
376
431
|
attributes: message.attributes,
|
|
377
432
|
code: message.code,
|
|
433
|
+
role: message.role,
|
|
378
434
|
});
|
|
379
435
|
connection.joinedServers.add(result.serverId);
|
|
380
|
-
|
|
436
|
+
connection.roles.set(result.serverId, message.role ?? "player");
|
|
437
|
+
if (message.sessionId !== undefined)
|
|
438
|
+
getOrCreate(connection.sessions, result.serverId, () => new Set()).add(message.sessionId);
|
|
439
|
+
reply(connection, message.id, {
|
|
440
|
+
...result,
|
|
441
|
+
resumeTicket: ticketFor(userId, result.serverId),
|
|
442
|
+
});
|
|
381
443
|
return;
|
|
382
444
|
}
|
|
383
445
|
case "joinByCode": {
|
|
@@ -388,9 +450,13 @@ export function createHostRouter(options) {
|
|
|
388
450
|
gameId: message.gameId,
|
|
389
451
|
code: message.code,
|
|
390
452
|
});
|
|
391
|
-
if (result !== null)
|
|
453
|
+
if (result !== null) {
|
|
392
454
|
connection.joinedServers.add(result.serverId);
|
|
393
|
-
|
|
455
|
+
connection.roles.set(result.serverId, message.role ?? "player");
|
|
456
|
+
}
|
|
457
|
+
reply(connection, message.id, result === null
|
|
458
|
+
? null
|
|
459
|
+
: { ...result, resumeTicket: ticketFor(userId, result.serverId) });
|
|
394
460
|
return;
|
|
395
461
|
}
|
|
396
462
|
case "browse": {
|
|
@@ -405,14 +471,31 @@ export function createHostRouter(options) {
|
|
|
405
471
|
return;
|
|
406
472
|
}
|
|
407
473
|
case "leave": {
|
|
408
|
-
|
|
474
|
+
const sessions = connection.sessions.get(message.serverId);
|
|
475
|
+
// A leave for a session that is not live (a disposed join) must not end the sessions that are.
|
|
476
|
+
if (message.sessionId !== undefined && sessions !== undefined && sessions.size > 0) {
|
|
477
|
+
if (!sessions.delete(message.sessionId) || sessions.size > 0) {
|
|
478
|
+
reply(connection, message.id, null);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
connection.sessions.delete(message.serverId);
|
|
409
483
|
connection.joinedServers.delete(message.serverId);
|
|
410
|
-
|
|
411
|
-
|
|
484
|
+
connection.roles.delete(message.serverId);
|
|
485
|
+
if (!heldByOtherConnection(connection, userId, message.serverId)) {
|
|
486
|
+
await host.leaveServer({ userId, serverId: message.serverId });
|
|
487
|
+
clearPendingLeave(userId, message.serverId);
|
|
488
|
+
dropPresenceForServer(userId, message.serverId);
|
|
489
|
+
dropVoiceForServer(userId, message.serverId);
|
|
490
|
+
}
|
|
412
491
|
reply(connection, message.id, null);
|
|
413
492
|
return;
|
|
414
493
|
}
|
|
415
494
|
case "runCommand": {
|
|
495
|
+
if (connection.roles.get(message.serverId) === "spectator") {
|
|
496
|
+
replyError(connection, message.id, "Spectators cannot run commands");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
416
499
|
if (!(await gate(connection, message.id, "runCommand", {
|
|
417
500
|
serverId: message.serverId,
|
|
418
501
|
command: message.command,
|
|
@@ -490,6 +573,8 @@ export function createHostRouter(options) {
|
|
|
490
573
|
replyError(connection, message.id, "Not a member of this server");
|
|
491
574
|
return;
|
|
492
575
|
}
|
|
576
|
+
if (message.channel === "server")
|
|
577
|
+
connection.worldRevisions.set(message.serverId, null);
|
|
493
578
|
addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
494
579
|
reply(connection, message.id, null);
|
|
495
580
|
await pushSubscription(connection, message.channel, message.serverId, message.action);
|
|
@@ -512,8 +597,16 @@ export function createHostRouter(options) {
|
|
|
512
597
|
const userId = connection.userId;
|
|
513
598
|
const servers = [...connection.joinedServers];
|
|
514
599
|
connection.joinedServers.clear();
|
|
600
|
+
connection.sessions.clear();
|
|
515
601
|
for (const serverId of servers) {
|
|
516
|
-
|
|
602
|
+
const key = ticketKey(userId, serverId);
|
|
603
|
+
clearPendingLeave(userId, serverId);
|
|
604
|
+
pendingLeaves.set(key, setTimeout(() => {
|
|
605
|
+
pendingLeaves.delete(key);
|
|
606
|
+
if (heldByOtherConnection(connection, userId, serverId))
|
|
607
|
+
return;
|
|
608
|
+
void host.leaveServer({ userId, serverId }).catch(() => undefined);
|
|
609
|
+
}, graceMs));
|
|
517
610
|
}
|
|
518
611
|
};
|
|
519
612
|
return {
|
|
@@ -523,8 +616,11 @@ export function createHostRouter(options) {
|
|
|
523
616
|
userId: null,
|
|
524
617
|
subscriptions: new Set(),
|
|
525
618
|
joinedServers: new Set(),
|
|
619
|
+
sessions: new Map(),
|
|
526
620
|
queue: Promise.resolve(),
|
|
527
621
|
queuedMessages: 0,
|
|
622
|
+
worldRevisions: new Map(),
|
|
623
|
+
roles: new Map(),
|
|
528
624
|
};
|
|
529
625
|
connections.add(connection);
|
|
530
626
|
return {
|
|
@@ -579,6 +675,9 @@ export function createHostRouter(options) {
|
|
|
579
675
|
},
|
|
580
676
|
close: () => {
|
|
581
677
|
unsubscribeHost();
|
|
678
|
+
for (const timer of pendingLeaves.values())
|
|
679
|
+
clearTimeout(timer);
|
|
680
|
+
pendingLeaves.clear();
|
|
582
681
|
connections.clear();
|
|
583
682
|
subscribers.clear();
|
|
584
683
|
},
|
package/dist/protocol.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/mat
|
|
|
5
5
|
export declare const WS_PROTOCOL_VERSION = 1;
|
|
6
6
|
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
7
7
|
export declare const MAX_COMMAND_LENGTH = 4096;
|
|
8
|
+
/** Max length of a join/leave `sessionId`, in UTF-16 code units. */
|
|
9
|
+
export declare const MAX_SESSION_ID_LENGTH = 128;
|
|
8
10
|
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
9
11
|
export declare const MAX_FEED_ACTION_LENGTH = 256;
|
|
10
12
|
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
@@ -34,6 +36,11 @@ export type WsVoiceParticipant = {
|
|
|
34
36
|
streamId?: string;
|
|
35
37
|
};
|
|
36
38
|
export type WsClientMessage = {
|
|
39
|
+
v: 1;
|
|
40
|
+
t: "ping";
|
|
41
|
+
id: number;
|
|
42
|
+
at: number;
|
|
43
|
+
} | {
|
|
37
44
|
v: 1;
|
|
38
45
|
t: "hello";
|
|
39
46
|
id: number;
|
|
@@ -47,12 +54,16 @@ export type WsClientMessage = {
|
|
|
47
54
|
serverId?: string;
|
|
48
55
|
attributes?: SessionAttributes;
|
|
49
56
|
code?: string;
|
|
57
|
+
role?: "player" | "spectator";
|
|
58
|
+
/** Client session holding this join; see `GameRuntimeTransport.joinServer`. */
|
|
59
|
+
sessionId?: string;
|
|
50
60
|
} | {
|
|
51
61
|
v: 1;
|
|
52
62
|
t: "joinByCode";
|
|
53
63
|
id: number;
|
|
54
64
|
gameId: string;
|
|
55
65
|
code: string;
|
|
66
|
+
role?: "player" | "spectator";
|
|
56
67
|
} | {
|
|
57
68
|
v: 1;
|
|
58
69
|
t: "browse";
|
|
@@ -65,6 +76,7 @@ export type WsClientMessage = {
|
|
|
65
76
|
t: "leave";
|
|
66
77
|
id: number;
|
|
67
78
|
serverId: string;
|
|
79
|
+
sessionId?: string;
|
|
68
80
|
} | {
|
|
69
81
|
v: 1;
|
|
70
82
|
t: "runCommand";
|
|
@@ -167,6 +179,12 @@ export type WsUpdateMessage = {
|
|
|
167
179
|
data: WsVoiceParticipant[];
|
|
168
180
|
};
|
|
169
181
|
export type WsServerMessage = {
|
|
182
|
+
v: 1;
|
|
183
|
+
t: "pong";
|
|
184
|
+
id: number;
|
|
185
|
+
at: number;
|
|
186
|
+
serverAt: number;
|
|
187
|
+
} | {
|
|
170
188
|
v: 1;
|
|
171
189
|
t: "reply";
|
|
172
190
|
id: number;
|
package/dist/protocol.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const WS_PROTOCOL_VERSION = 1;
|
|
2
2
|
/** Max length of a `runCommand` command name, in UTF-16 code units. */
|
|
3
3
|
export const MAX_COMMAND_LENGTH = 4_096;
|
|
4
|
+
/** Max length of a join/leave `sessionId`, in UTF-16 code units. */
|
|
5
|
+
export const MAX_SESSION_ID_LENGTH = 128;
|
|
4
6
|
/** Max length of a `pushFeed` action name, in UTF-16 code units. */
|
|
5
7
|
export const MAX_FEED_ACTION_LENGTH = 256;
|
|
6
8
|
/** Max serialized size of a `pushFeed` entry payload, in bytes. */
|
|
@@ -13,6 +15,9 @@ export const MAX_APPEARANCE_VALUE_LENGTH = 256;
|
|
|
13
15
|
export function encodeWsMessage(message) {
|
|
14
16
|
return JSON.stringify(message);
|
|
15
17
|
}
|
|
18
|
+
function isOptionalSessionId(value) {
|
|
19
|
+
return value === undefined || (typeof value === "string" && value.length > 0 && value.length <= MAX_SESSION_ID_LENGTH);
|
|
20
|
+
}
|
|
16
21
|
function isRecord(value) {
|
|
17
22
|
return typeof value === "object" && value !== null;
|
|
18
23
|
}
|
|
@@ -113,6 +118,8 @@ export function decodeWsClientMessage(raw) {
|
|
|
113
118
|
if (message === null)
|
|
114
119
|
return null;
|
|
115
120
|
switch (message.t) {
|
|
121
|
+
case "ping":
|
|
122
|
+
return typeof message.id === "number" && typeof message.at === "number" ? message : null;
|
|
116
123
|
case "hello":
|
|
117
124
|
return typeof message.id === "number" &&
|
|
118
125
|
typeof message.userId === "string" &&
|
|
@@ -123,13 +130,16 @@ export function decodeWsClientMessage(raw) {
|
|
|
123
130
|
return typeof message.id === "number" &&
|
|
124
131
|
typeof message.gameId === "string" &&
|
|
125
132
|
(message.serverId === undefined || typeof message.serverId === "string") &&
|
|
126
|
-
(message.code === undefined || typeof message.code === "string")
|
|
133
|
+
(message.code === undefined || typeof message.code === "string") &&
|
|
134
|
+
(message.role === undefined || message.role === "player" || message.role === "spectator") &&
|
|
135
|
+
isOptionalSessionId(message.sessionId)
|
|
127
136
|
? message
|
|
128
137
|
: null;
|
|
129
138
|
case "joinByCode":
|
|
130
139
|
return typeof message.id === "number" &&
|
|
131
140
|
typeof message.gameId === "string" &&
|
|
132
|
-
typeof message.code === "string"
|
|
141
|
+
typeof message.code === "string" &&
|
|
142
|
+
(message.role === undefined || message.role === "player" || message.role === "spectator")
|
|
133
143
|
? message
|
|
134
144
|
: null;
|
|
135
145
|
case "browse":
|
|
@@ -137,7 +147,7 @@ export function decodeWsClientMessage(raw) {
|
|
|
137
147
|
? message
|
|
138
148
|
: null;
|
|
139
149
|
case "leave":
|
|
140
|
-
return typeof message.id === "number" && typeof message.serverId === "string"
|
|
150
|
+
return typeof message.id === "number" && typeof message.serverId === "string" && isOptionalSessionId(message.sessionId)
|
|
141
151
|
? message
|
|
142
152
|
: null;
|
|
143
153
|
case "runCommand":
|
|
@@ -204,6 +214,8 @@ export function decodeWsServerMessage(raw) {
|
|
|
204
214
|
if (message === null)
|
|
205
215
|
return null;
|
|
206
216
|
switch (message.t) {
|
|
217
|
+
case "pong":
|
|
218
|
+
return typeof message.id === "number" && typeof message.at === "number" && typeof message.serverAt === "number" ? message : null;
|
|
207
219
|
case "reply":
|
|
208
220
|
if (typeof message.id !== "number")
|
|
209
221
|
return null;
|
package/dist/worldHost.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface WorldGameHostOptions {
|
|
|
9
9
|
session(args: {
|
|
10
10
|
gameId: string;
|
|
11
11
|
serverId: string;
|
|
12
|
-
}): HostedWorldSession | null;
|
|
12
|
+
}): HostedWorldSession | Promise<HostedWorldSession | null> | null;
|
|
13
13
|
now?: () => number;
|
|
14
14
|
}
|
|
15
15
|
/** A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. */
|
package/dist/worldHost.js
CHANGED
|
@@ -7,6 +7,7 @@ import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
|
|
|
7
7
|
*/
|
|
8
8
|
export function createWorldGameHost(options) {
|
|
9
9
|
const live = new Map();
|
|
10
|
+
const roles = new Map();
|
|
10
11
|
const listeners = new Set();
|
|
11
12
|
const now = options.now ?? (() => Date.now());
|
|
12
13
|
function emit(event) {
|
|
@@ -17,10 +18,19 @@ export function createWorldGameHost(options) {
|
|
|
17
18
|
const existing = live.get(serverId);
|
|
18
19
|
if (existing !== undefined)
|
|
19
20
|
return existing;
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
21
|
+
const resolved = options.session({ gameId, serverId });
|
|
22
|
+
if (resolved instanceof Promise) {
|
|
23
|
+
return resolved.then((session) => {
|
|
24
|
+
if (session === null)
|
|
25
|
+
return null;
|
|
26
|
+
const entry = { gameId, session };
|
|
27
|
+
live.set(serverId, entry);
|
|
28
|
+
return entry;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (resolved === null)
|
|
22
32
|
return null;
|
|
23
|
-
const entry = { gameId, session };
|
|
33
|
+
const entry = { gameId, session: resolved };
|
|
24
34
|
live.set(serverId, entry);
|
|
25
35
|
return entry;
|
|
26
36
|
}
|
|
@@ -33,13 +43,21 @@ export function createWorldGameHost(options) {
|
|
|
33
43
|
}
|
|
34
44
|
}
|
|
35
45
|
return {
|
|
36
|
-
async joinServer({ userId, gameId, serverId }) {
|
|
46
|
+
async joinServer({ userId, gameId, serverId, role }) {
|
|
37
47
|
const id = serverId ?? gameId;
|
|
38
|
-
const
|
|
48
|
+
const pending = ensure(gameId, id);
|
|
49
|
+
const entry = pending instanceof Promise ? await pending : pending;
|
|
39
50
|
if (entry === null)
|
|
40
51
|
throw new Error(`no hosted world for game "${gameId}"`);
|
|
41
52
|
const isNew = !entry.session.members().includes(userId);
|
|
42
|
-
|
|
53
|
+
let serverRoles = roles.get(id);
|
|
54
|
+
if (serverRoles === undefined) {
|
|
55
|
+
serverRoles = new Map();
|
|
56
|
+
roles.set(id, serverRoles);
|
|
57
|
+
}
|
|
58
|
+
serverRoles.set(userId, role ?? "player");
|
|
59
|
+
if (role !== "spectator")
|
|
60
|
+
entry.session.join(userId, isNew);
|
|
43
61
|
emit({ type: "server", serverId: id });
|
|
44
62
|
emit({ type: "player", serverId: id, userId });
|
|
45
63
|
return { serverId: id, isNew };
|
|
@@ -48,7 +66,9 @@ export function createWorldGameHost(options) {
|
|
|
48
66
|
const entry = live.get(serverId);
|
|
49
67
|
if (entry === undefined)
|
|
50
68
|
return;
|
|
51
|
-
entry.session.
|
|
69
|
+
if (entry.session.members().includes(userId))
|
|
70
|
+
entry.session.leave(userId);
|
|
71
|
+
roles.get(serverId)?.delete(userId);
|
|
52
72
|
emit({ type: "server", serverId });
|
|
53
73
|
},
|
|
54
74
|
async runCommand({ userId, serverId, command, input }) {
|
|
@@ -70,9 +90,9 @@ export function createWorldGameHost(options) {
|
|
|
70
90
|
return { ok: true };
|
|
71
91
|
},
|
|
72
92
|
async isMember({ userId, serverId }) {
|
|
73
|
-
return live.get(serverId)?.session.members().includes(userId)
|
|
93
|
+
return live.get(serverId)?.session.members().includes(userId) || roles.get(serverId)?.has(userId) === true;
|
|
74
94
|
},
|
|
75
|
-
async getServerView({ userId, serverId }) {
|
|
95
|
+
async getServerView({ userId, serverId, role }) {
|
|
76
96
|
const entry = live.get(serverId);
|
|
77
97
|
if (entry === undefined)
|
|
78
98
|
return null;
|
|
@@ -81,10 +101,26 @@ export function createWorldGameHost(options) {
|
|
|
81
101
|
gameId: entry.gameId,
|
|
82
102
|
revision: entry.session.revision(),
|
|
83
103
|
memberUserIds: [...entry.session.members()],
|
|
84
|
-
serverState: entry.session.snapshotFor({ userId }),
|
|
104
|
+
serverState: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
|
|
85
105
|
updatedAt: now(),
|
|
86
106
|
};
|
|
87
107
|
},
|
|
108
|
+
async pullWorld({ userId, serverId, sinceRevision, role }) {
|
|
109
|
+
const entry = live.get(serverId);
|
|
110
|
+
if (entry === undefined || (!entry.session.members().includes(userId) && roles.get(serverId)?.get(userId) !== "spectator"))
|
|
111
|
+
return null;
|
|
112
|
+
if (entry.session.projectsViewers()) {
|
|
113
|
+
return {
|
|
114
|
+
kind: "baseline",
|
|
115
|
+
revision: entry.session.revision(),
|
|
116
|
+
snapshot: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const sync = entry.session.pull(sinceRevision);
|
|
120
|
+
return sync.kind === "baseline"
|
|
121
|
+
? sync
|
|
122
|
+
: { kind: "diff", revision: sync.diff.revision, diff: sync.diff };
|
|
123
|
+
},
|
|
88
124
|
async getPlayerView() {
|
|
89
125
|
return null;
|
|
90
126
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/ws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Browser-safe WebSocket client backend for JGengine: protocol codec, createWsBackend, and HTTP read helpers.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test src"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jgengine/core": "^0.
|
|
34
|
+
"@jgengine/core": "^0.19.0"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
@@ -143,7 +143,7 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
|
|
|
143
143
|
|
|
144
144
|
## JGengine notes
|
|
145
145
|
|
|
146
|
-
- Multi-session engine work
|
|
146
|
+
- Multi-session engine work spans GitHub issues and PRs — point at those; do not paste full plan bodies.
|
|
147
147
|
- Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
|
|
148
148
|
- Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
|
|
149
|
-
-
|
|
149
|
+
- Durable process changes belong in skills, not only the handoff.
|