@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/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# @jgengine/ws
|
|
2
2
|
|
|
3
|
-
Browser-safe WebSocket client backend for [JGengine](https://github.com/Noisemaker111/jgengine): protocol codec, `createWsBackend`, `createHttpReads`.
|
|
3
|
+
Browser-safe WebSocket client backend for [JGengine](https://github.com/Noisemaker111/jgengine): protocol codec, `createWsBackend`, `createHttpReads`. Apache-2.0.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CommandRejection } from "@jgengine/core/commands/commandRegistry";
|
|
2
|
+
/** A host-side op the command middleware pipeline can gate: pose sync, `runCommand`, join/joinByCode, browse, or voice join/leave/publish. */
|
|
3
|
+
export type HostCommandOp = "pose" | "runCommand" | "join" | "browse" | "voice";
|
|
4
|
+
/** A sliding-window rate limit: at most `count` calls per `perMs` window. */
|
|
5
|
+
export type CommandRateLimit = {
|
|
6
|
+
count: number;
|
|
7
|
+
perMs: number;
|
|
8
|
+
};
|
|
9
|
+
/** Per-op rate limits. An op with no entry (or an undefined `limits`) is unlimited. */
|
|
10
|
+
export type CommandLimits = Partial<Record<HostCommandOp, CommandRateLimit>>;
|
|
11
|
+
/** Recommended per-op limits a host can opt into via `limits: DEFAULT_COMMAND_LIMITS`. Rate limiting is off unless `limits` is set. */
|
|
12
|
+
export declare const DEFAULT_COMMAND_LIMITS: CommandLimits;
|
|
13
|
+
/** A declared `runCommand` name's input validator, run before the command reaches the game host. */
|
|
14
|
+
export type CommandCatalogEntry = {
|
|
15
|
+
validate?: (input: unknown) => CommandRejection | null;
|
|
16
|
+
};
|
|
17
|
+
/** Declared `runCommand` names and their input validators. When set, any `runCommand` name absent from this catalog is rejected as unknown. */
|
|
18
|
+
export type CommandCatalog = Record<string, CommandCatalogEntry>;
|
|
19
|
+
/** Per-command authorization hook: return `false` to reject. Defaults to allow-all when omitted. */
|
|
20
|
+
export type CommandAuthorize = (args: {
|
|
21
|
+
userId: string;
|
|
22
|
+
op: HostCommandOp;
|
|
23
|
+
serverId?: string;
|
|
24
|
+
command?: string;
|
|
25
|
+
}) => boolean | Promise<boolean>;
|
|
26
|
+
/** A composable per-connection/per-op sliding-window rate limiter. */
|
|
27
|
+
export type CommandRateLimiter = {
|
|
28
|
+
allow: (connection: object, op: HostCommandOp, atMs: number) => boolean;
|
|
29
|
+
};
|
|
30
|
+
/** Creates a sliding-window rate limiter keyed by connection identity and op; ops absent from `limits` are always allowed. */
|
|
31
|
+
export declare function createCommandRateLimiter(limits: CommandLimits): CommandRateLimiter;
|
|
32
|
+
/** Validates a `runCommand` input against a declared catalog. `undefined` catalog means "no declarations" — everything passes through unchanged. */
|
|
33
|
+
export declare function validateCommandInput(catalog: CommandCatalog | undefined, command: string, input: unknown): CommandRejection | null;
|
|
34
|
+
/** Config for {@link createCommandMiddleware}: the same `limits`/`authorize`/`validate` fields accepted by `HostRouterOptions`. */
|
|
35
|
+
export type CommandMiddlewareOptions = {
|
|
36
|
+
limits?: CommandLimits;
|
|
37
|
+
authorize?: CommandAuthorize;
|
|
38
|
+
validate?: CommandCatalog;
|
|
39
|
+
};
|
|
40
|
+
/** One op attempt to run through the middleware pipeline: which connection, which op, and (for `runCommand`) the command name/input. */
|
|
41
|
+
export type CommandGateArgs = {
|
|
42
|
+
connection: object;
|
|
43
|
+
userId: string;
|
|
44
|
+
op: HostCommandOp;
|
|
45
|
+
atMs: number;
|
|
46
|
+
serverId?: string;
|
|
47
|
+
command?: string;
|
|
48
|
+
input?: unknown;
|
|
49
|
+
};
|
|
50
|
+
/** The pipeline's verdict for one {@link CommandGateArgs}: allowed, or rejected with a client-facing reason. */
|
|
51
|
+
export type CommandGateDecision = {
|
|
52
|
+
allow: true;
|
|
53
|
+
} | {
|
|
54
|
+
allow: false;
|
|
55
|
+
reason: string;
|
|
56
|
+
};
|
|
57
|
+
/** A composable rate-limit → validate → authorize pipeline the host router runs before dispatching pose/runCommand/join/browse/voice ops. Every stage defaults to a no-op, so an unconfigured router behaves exactly as before. */
|
|
58
|
+
export type CommandMiddleware = {
|
|
59
|
+
check: (args: CommandGateArgs) => Promise<CommandGateDecision>;
|
|
60
|
+
};
|
|
61
|
+
/** Builds the composed command middleware pipeline from game-intent config: `limits`, `validate`, `authorize`. */
|
|
62
|
+
export declare function createCommandMiddleware(options: CommandMiddlewareOptions): CommandMiddleware;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Recommended per-op limits a host can opt into via `limits: DEFAULT_COMMAND_LIMITS`. Rate limiting is off unless `limits` is set. */
|
|
2
|
+
export const DEFAULT_COMMAND_LIMITS = {
|
|
3
|
+
pose: { count: 60, perMs: 1_000 },
|
|
4
|
+
runCommand: { count: 30, perMs: 1_000 },
|
|
5
|
+
join: { count: 5, perMs: 10_000 },
|
|
6
|
+
browse: { count: 10, perMs: 5_000 },
|
|
7
|
+
voice: { count: 20, perMs: 1_000 },
|
|
8
|
+
};
|
|
9
|
+
/** Creates a sliding-window rate limiter keyed by connection identity and op; ops absent from `limits` are always allowed. */
|
|
10
|
+
export function createCommandRateLimiter(limits) {
|
|
11
|
+
const windows = new WeakMap();
|
|
12
|
+
return {
|
|
13
|
+
allow(connection, op, atMs) {
|
|
14
|
+
const limit = limits[op];
|
|
15
|
+
if (limit === undefined)
|
|
16
|
+
return true;
|
|
17
|
+
let perOp = windows.get(connection);
|
|
18
|
+
if (perOp === undefined) {
|
|
19
|
+
perOp = new Map();
|
|
20
|
+
windows.set(connection, perOp);
|
|
21
|
+
}
|
|
22
|
+
const cutoff = atMs - limit.perMs;
|
|
23
|
+
const stamps = (perOp.get(op) ?? []).filter((stamp) => stamp > cutoff);
|
|
24
|
+
if (stamps.length >= limit.count) {
|
|
25
|
+
perOp.set(op, stamps);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
stamps.push(atMs);
|
|
29
|
+
perOp.set(op, stamps);
|
|
30
|
+
return true;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Validates a `runCommand` input against a declared catalog. `undefined` catalog means "no declarations" — everything passes through unchanged. */
|
|
35
|
+
export function validateCommandInput(catalog, command, input) {
|
|
36
|
+
if (catalog === undefined)
|
|
37
|
+
return null;
|
|
38
|
+
const declared = catalog[command];
|
|
39
|
+
if (declared === undefined)
|
|
40
|
+
return { reason: `Unknown command: ${command}` };
|
|
41
|
+
return declared.validate?.(input) ?? null;
|
|
42
|
+
}
|
|
43
|
+
/** Builds the composed command middleware pipeline from game-intent config: `limits`, `validate`, `authorize`. */
|
|
44
|
+
export function createCommandMiddleware(options) {
|
|
45
|
+
const limiter = createCommandRateLimiter(options.limits ?? {});
|
|
46
|
+
const catalog = options.validate;
|
|
47
|
+
const authorize = options.authorize;
|
|
48
|
+
return {
|
|
49
|
+
async check({ connection, userId, op, atMs, serverId, command, input }) {
|
|
50
|
+
if (!limiter.allow(connection, op, atMs)) {
|
|
51
|
+
return { allow: false, reason: `Rate limited: ${op}` };
|
|
52
|
+
}
|
|
53
|
+
if (op === "runCommand" && command !== undefined) {
|
|
54
|
+
const rejection = validateCommandInput(catalog, command, input);
|
|
55
|
+
if (rejection !== null)
|
|
56
|
+
return { allow: false, reason: rejection.reason };
|
|
57
|
+
}
|
|
58
|
+
if (authorize !== undefined) {
|
|
59
|
+
const authorized = await authorize({ userId, op, serverId, command });
|
|
60
|
+
if (!authorized)
|
|
61
|
+
return { allow: false, reason: `Not authorized: ${op}` };
|
|
62
|
+
}
|
|
63
|
+
return { allow: true };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/createWsBackend.js
CHANGED
|
@@ -11,6 +11,16 @@ const DEFAULT_POSE_TUNING = {
|
|
|
11
11
|
const DEFAULT_RPC_TIMEOUT_MS = 15_000;
|
|
12
12
|
const DEFAULT_RECONNECT_DELAY_MS = 1_000;
|
|
13
13
|
const DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;
|
|
14
|
+
const WS_OP_ID_FIELD = "__jgWsOpId";
|
|
15
|
+
const RESERVED_INPUT_COMMAND = "engine.input";
|
|
16
|
+
function isPlainRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function stampOpId(command, opId, input) {
|
|
20
|
+
if (command === RESERVED_INPUT_COMMAND || !isPlainRecord(input))
|
|
21
|
+
return input;
|
|
22
|
+
return { ...input, [WS_OP_ID_FIELD]: opId };
|
|
23
|
+
}
|
|
14
24
|
export function createWsBackend(options) {
|
|
15
25
|
const now = options.now ?? Date.now;
|
|
16
26
|
const baseReconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
|
|
@@ -31,6 +41,7 @@ export function createWsBackend(options) {
|
|
|
31
41
|
let closed = false;
|
|
32
42
|
let wantConnection = false;
|
|
33
43
|
let nextId = 1;
|
|
44
|
+
let nextOpId = 1;
|
|
34
45
|
let reconnectAttempt = 0;
|
|
35
46
|
let reconnectTimer = null;
|
|
36
47
|
const pending = new Map();
|
|
@@ -273,6 +284,7 @@ export function createWsBackend(options) {
|
|
|
273
284
|
await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId }));
|
|
274
285
|
},
|
|
275
286
|
async runCommand(args) {
|
|
287
|
+
const opId = String(nextOpId++);
|
|
276
288
|
try {
|
|
277
289
|
const result = await request((id) => ({
|
|
278
290
|
v: 1,
|
|
@@ -280,7 +292,7 @@ export function createWsBackend(options) {
|
|
|
280
292
|
id,
|
|
281
293
|
serverId: args.serverId,
|
|
282
294
|
command: args.command,
|
|
283
|
-
input: args.input,
|
|
295
|
+
input: stampOpId(args.command, opId, args.input),
|
|
284
296
|
}));
|
|
285
297
|
return result;
|
|
286
298
|
}
|
package/dist/host.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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
4
|
import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
|
|
5
|
+
/** A change notification emitted by a `GameHost` for a server, player, or feed mutation. */
|
|
5
6
|
export type HostChangeEvent = {
|
|
6
7
|
type: "server";
|
|
7
8
|
serverId: string;
|
|
@@ -14,6 +15,7 @@ export type HostChangeEvent = {
|
|
|
14
15
|
serverId: string;
|
|
15
16
|
action: string;
|
|
16
17
|
};
|
|
18
|
+
/** Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. */
|
|
17
19
|
export type GameHostOptions = {
|
|
18
20
|
runtimes?: GameRuntime[];
|
|
19
21
|
persistence: HostPersistence;
|
|
@@ -23,12 +25,14 @@ export type GameHostOptions = {
|
|
|
23
25
|
createServerId?: () => string;
|
|
24
26
|
allowedFeedActions?: readonly string[];
|
|
25
27
|
};
|
|
28
|
+
/** A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. */
|
|
26
29
|
export type GameHost = {
|
|
27
30
|
joinServer: (args: {
|
|
28
31
|
userId: string;
|
|
29
32
|
gameId: string;
|
|
30
33
|
serverId?: string;
|
|
31
34
|
attributes?: SessionAttributes;
|
|
35
|
+
code?: string;
|
|
32
36
|
}) => Promise<JoinServerResult>;
|
|
33
37
|
browseServers: (args: {
|
|
34
38
|
gameId: string;
|
|
@@ -86,5 +90,9 @@ export type GameHost = {
|
|
|
86
90
|
stop: () => Promise<void>;
|
|
87
91
|
subscribe: (listener: (event: HostChangeEvent) => void) => () => void;
|
|
88
92
|
};
|
|
93
|
+
/** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
|
|
94
|
+
export declare const OP_LEDGER_LIMIT = 64;
|
|
95
|
+
/** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
|
|
89
96
|
export declare function createGameHost(options: GameHostOptions): GameHost;
|
|
97
|
+
/** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
|
|
90
98
|
export declare function memoryPersistence(now?: () => number): HostPersistence;
|
package/dist/host.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
2
|
import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
3
|
-
import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
3
|
+
import { browseSessions, findByJoinCode, normalizeJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
4
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
5
5
|
import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
|
|
6
6
|
const builtinCommands = {
|
|
@@ -9,6 +9,23 @@ const builtinCommands = {
|
|
|
9
9
|
apply: (snapshot) => snapshot,
|
|
10
10
|
},
|
|
11
11
|
};
|
|
12
|
+
/** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
|
|
13
|
+
export const OP_LEDGER_LIMIT = 64;
|
|
14
|
+
const WS_OP_ID_FIELD = "__jgWsOpId";
|
|
15
|
+
function unwrapOpEnvelope(input) {
|
|
16
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
17
|
+
return null;
|
|
18
|
+
const record = input;
|
|
19
|
+
const opId = record[WS_OP_ID_FIELD];
|
|
20
|
+
if (typeof opId !== "string")
|
|
21
|
+
return null;
|
|
22
|
+
const { [WS_OP_ID_FIELD]: _opId, ...rest } = record;
|
|
23
|
+
return { opId, input: rest };
|
|
24
|
+
}
|
|
25
|
+
function opLedgerKey(serverId, userId) {
|
|
26
|
+
return `${serverId}|${userId}`;
|
|
27
|
+
}
|
|
28
|
+
/** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
|
|
12
29
|
export function createGameHost(options) {
|
|
13
30
|
const now = options.now ?? Date.now;
|
|
14
31
|
const tickMs = options.tickMs ?? 1_000;
|
|
@@ -34,10 +51,30 @@ export function createGameHost(options) {
|
|
|
34
51
|
for (const listener of listeners)
|
|
35
52
|
listener(event);
|
|
36
53
|
};
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
const opLedgers = new Map();
|
|
55
|
+
const findAppliedOp = (serverId, userId, opId) => opLedgers.get(opLedgerKey(serverId, userId))?.find((entry) => entry.opId === opId)?.result;
|
|
56
|
+
const rememberAppliedOp = (serverId, userId, opId, result) => {
|
|
57
|
+
const key = opLedgerKey(serverId, userId);
|
|
58
|
+
const entries = opLedgers.get(key) ?? [];
|
|
59
|
+
entries.push({ opId, result });
|
|
60
|
+
if (entries.length > OP_LEDGER_LIMIT)
|
|
61
|
+
entries.shift();
|
|
62
|
+
opLedgers.set(key, entries);
|
|
63
|
+
};
|
|
64
|
+
const forgetOpLedger = (serverId, userId) => {
|
|
65
|
+
opLedgers.delete(opLedgerKey(serverId, userId));
|
|
66
|
+
};
|
|
67
|
+
const roomQueues = new Map();
|
|
68
|
+
const enqueueRoom = (roomId, operation) => {
|
|
69
|
+
const previous = roomQueues.get(roomId) ?? Promise.resolve();
|
|
70
|
+
const run = previous.then(operation);
|
|
71
|
+
const settled = run.catch(() => undefined);
|
|
72
|
+
roomQueues.set(roomId, settled);
|
|
73
|
+
void settled.finally(() => {
|
|
74
|
+
if (roomQueues.get(roomId) === settled) {
|
|
75
|
+
roomQueues.delete(roomId);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
41
78
|
return run;
|
|
42
79
|
};
|
|
43
80
|
const hydrate = async (record) => {
|
|
@@ -110,34 +147,39 @@ export function createGameHost(options) {
|
|
|
110
147
|
};
|
|
111
148
|
};
|
|
112
149
|
let interval = null;
|
|
113
|
-
const tickOnce =
|
|
150
|
+
const tickOnce = async () => {
|
|
114
151
|
const timestamp = now();
|
|
115
|
-
|
|
116
|
-
let saved = 0;
|
|
117
|
-
for (const entry of live.values()) {
|
|
152
|
+
const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
118
153
|
if (entry.record.status !== "running")
|
|
119
|
-
|
|
154
|
+
return { ticked: 0, saved: 0 };
|
|
120
155
|
if (entry.record.memberUserIds.length === 0)
|
|
121
|
-
|
|
156
|
+
return { ticked: 0, saved: 0 };
|
|
122
157
|
const elapsedMs = timestamp - entry.record.tickAnchorMs;
|
|
123
158
|
if (elapsedMs < tickMs)
|
|
124
|
-
|
|
159
|
+
return { ticked: 0, saved: 0 };
|
|
125
160
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
126
161
|
const before = entry.snapshot.revision;
|
|
127
162
|
entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
|
|
128
163
|
entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
|
|
129
|
-
ticked += 1;
|
|
130
164
|
if (entry.snapshot.revision !== before) {
|
|
131
165
|
markMutated(entry);
|
|
132
166
|
emit({ type: "server", serverId: entry.record.serverId });
|
|
133
167
|
}
|
|
168
|
+
let saved = 0;
|
|
134
169
|
if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
|
|
135
170
|
await flushServer(entry);
|
|
136
|
-
saved
|
|
171
|
+
saved = 1;
|
|
137
172
|
}
|
|
173
|
+
return { ticked: 1, saved };
|
|
174
|
+
})));
|
|
175
|
+
let ticked = 0;
|
|
176
|
+
let saved = 0;
|
|
177
|
+
for (const outcome of outcomes) {
|
|
178
|
+
ticked += outcome.ticked;
|
|
179
|
+
saved += outcome.saved;
|
|
138
180
|
}
|
|
139
181
|
return { ticked, saved };
|
|
140
|
-
}
|
|
182
|
+
};
|
|
141
183
|
const collectListings = async (gameId, options = {}) => {
|
|
142
184
|
const byId = new Map();
|
|
143
185
|
for (const record of await persistence.listServers(gameId)) {
|
|
@@ -165,69 +207,88 @@ export function createGameHost(options) {
|
|
|
165
207
|
}));
|
|
166
208
|
};
|
|
167
209
|
const host = {
|
|
168
|
-
joinServer: (args) =>
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
210
|
+
joinServer: (args) => {
|
|
211
|
+
const joinOperation = async () => {
|
|
212
|
+
const timestamp = now();
|
|
213
|
+
const runtime = resolveRuntime(args.gameId);
|
|
214
|
+
let entry = args.serverId === undefined ? null : await getLive(args.serverId);
|
|
215
|
+
if (args.serverId !== undefined && entry === null) {
|
|
216
|
+
throw new Error("Server not found");
|
|
217
|
+
}
|
|
218
|
+
if (entry !== null && entry.record.gameId !== args.gameId) {
|
|
219
|
+
throw new Error("Server belongs to a different game");
|
|
220
|
+
}
|
|
221
|
+
if (entry !== null &&
|
|
222
|
+
entry.record.visibility === "private" &&
|
|
223
|
+
!entry.record.memberUserIds.includes(args.userId)) {
|
|
224
|
+
const requiredCode = entry.record.joinCode;
|
|
225
|
+
const matches = requiredCode !== undefined &&
|
|
226
|
+
args.code !== undefined &&
|
|
227
|
+
normalizeJoinCode(args.code) === normalizeJoinCode(requiredCode);
|
|
228
|
+
if (!matches) {
|
|
229
|
+
throw new Error("Server is private");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (entry === null) {
|
|
233
|
+
const attributes = args.attributes;
|
|
234
|
+
const record = {
|
|
235
|
+
serverId: createServerId(),
|
|
236
|
+
gameId: args.gameId,
|
|
237
|
+
status: "running",
|
|
238
|
+
memberUserIds: [],
|
|
239
|
+
slotsPerServer: defaultSlots,
|
|
240
|
+
save: runtime.save,
|
|
241
|
+
serverState: createEmptyServerRow(),
|
|
242
|
+
sessionPlayers: {},
|
|
243
|
+
revision: 0,
|
|
244
|
+
tickAnchorMs: timestamp,
|
|
245
|
+
createdAt: timestamp,
|
|
246
|
+
updatedAt: timestamp,
|
|
247
|
+
...(attributes?.label !== undefined ? { label: attributes.label } : {}),
|
|
248
|
+
...(attributes?.mode !== undefined ? { mode: attributes.mode } : {}),
|
|
249
|
+
...(attributes?.visibility !== undefined ? { visibility: attributes.visibility } : {}),
|
|
250
|
+
...(attributes?.joinCode !== undefined ? { joinCode: attributes.joinCode } : {}),
|
|
251
|
+
...(attributes?.tags !== undefined ? { tags: attributes.tags } : {}),
|
|
252
|
+
};
|
|
253
|
+
entry = await hydrate(record);
|
|
254
|
+
}
|
|
255
|
+
const { record } = entry;
|
|
256
|
+
if (record.memberUserIds.length >= record.slotsPerServer &&
|
|
257
|
+
!record.memberUserIds.includes(args.userId)) {
|
|
258
|
+
throw new Error("Server is full");
|
|
259
|
+
}
|
|
260
|
+
const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
|
|
261
|
+
const isNew = profile === null;
|
|
262
|
+
entry.record = {
|
|
263
|
+
...record,
|
|
264
|
+
memberUserIds: record.memberUserIds.includes(args.userId)
|
|
265
|
+
? record.memberUserIds
|
|
266
|
+
: [...record.memberUserIds, args.userId],
|
|
183
267
|
status: "running",
|
|
184
|
-
memberUserIds: [],
|
|
185
|
-
slotsPerServer: defaultSlots,
|
|
186
|
-
save: runtime.save,
|
|
187
|
-
serverState: createEmptyServerRow(),
|
|
188
|
-
sessionPlayers: {},
|
|
189
|
-
revision: 0,
|
|
190
|
-
tickAnchorMs: timestamp,
|
|
191
|
-
createdAt: timestamp,
|
|
192
268
|
updatedAt: timestamp,
|
|
193
|
-
|
|
194
|
-
...(attributes?.mode !== undefined ? { mode: attributes.mode } : {}),
|
|
195
|
-
...(attributes?.visibility !== undefined ? { visibility: attributes.visibility } : {}),
|
|
196
|
-
...(attributes?.joinCode !== undefined ? { joinCode: attributes.joinCode } : {}),
|
|
197
|
-
...(attributes?.tags !== undefined ? { tags: attributes.tags } : {}),
|
|
269
|
+
dirtyAt: timestamp,
|
|
198
270
|
};
|
|
199
|
-
entry =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
throw new Error("Server is full");
|
|
205
|
-
}
|
|
206
|
-
const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
|
|
207
|
-
const isNew = profile === null;
|
|
208
|
-
entry.record = {
|
|
209
|
-
...record,
|
|
210
|
-
memberUserIds: record.memberUserIds.includes(args.userId)
|
|
211
|
-
? record.memberUserIds
|
|
212
|
-
: [...record.memberUserIds, args.userId],
|
|
213
|
-
status: "running",
|
|
214
|
-
updatedAt: timestamp,
|
|
215
|
-
dirtyAt: timestamp,
|
|
271
|
+
entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
|
|
272
|
+
await flushServer(entry);
|
|
273
|
+
emit({ type: "server", serverId: entry.record.serverId });
|
|
274
|
+
emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
|
|
275
|
+
return { serverId: entry.record.serverId, isNew };
|
|
216
276
|
};
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
emit({ type: "server", serverId: entry.record.serverId });
|
|
220
|
-
emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
|
|
221
|
-
return { serverId: entry.record.serverId, isNew };
|
|
222
|
-
}),
|
|
277
|
+
return args.serverId === undefined ? joinOperation() : enqueueRoom(args.serverId, joinOperation);
|
|
278
|
+
},
|
|
223
279
|
browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
|
|
224
280
|
joinByCode: async (args) => {
|
|
225
281
|
const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
|
|
226
282
|
if (match === null)
|
|
227
283
|
return null;
|
|
228
|
-
return host.joinServer({
|
|
284
|
+
return host.joinServer({
|
|
285
|
+
userId: args.userId,
|
|
286
|
+
gameId: args.gameId,
|
|
287
|
+
serverId: match.serverId,
|
|
288
|
+
code: args.code,
|
|
289
|
+
});
|
|
229
290
|
},
|
|
230
|
-
leaveServer: (args) =>
|
|
291
|
+
leaveServer: (args) => enqueueRoom(args.serverId, async () => {
|
|
231
292
|
const entry = await getLive(args.serverId);
|
|
232
293
|
if (entry === null)
|
|
233
294
|
return;
|
|
@@ -249,12 +310,13 @@ export function createGameHost(options) {
|
|
|
249
310
|
};
|
|
250
311
|
entry.snapshot = { ...entry.snapshot, players };
|
|
251
312
|
await persistence.saveServer(entry.record);
|
|
313
|
+
forgetOpLedger(args.serverId, args.userId);
|
|
252
314
|
if (memberUserIds.length === 0) {
|
|
253
315
|
live.delete(entry.record.serverId);
|
|
254
316
|
}
|
|
255
317
|
emit({ type: "server", serverId: args.serverId });
|
|
256
318
|
}),
|
|
257
|
-
runCommand: (args) =>
|
|
319
|
+
runCommand: (args) => enqueueRoom(args.serverId, async () => {
|
|
258
320
|
const entry = await getLive(args.serverId);
|
|
259
321
|
if (entry === null) {
|
|
260
322
|
return { ok: false, reason: "Server not found" };
|
|
@@ -262,16 +324,28 @@ export function createGameHost(options) {
|
|
|
262
324
|
if (!entry.record.memberUserIds.includes(args.userId)) {
|
|
263
325
|
return { ok: false, reason: "Not a member of this server" };
|
|
264
326
|
}
|
|
327
|
+
const envelope = unwrapOpEnvelope(args.input);
|
|
328
|
+
if (envelope !== null) {
|
|
329
|
+
const applied = findAppliedOp(args.serverId, args.userId, envelope.opId);
|
|
330
|
+
if (applied !== undefined)
|
|
331
|
+
return applied;
|
|
332
|
+
}
|
|
333
|
+
const commandInput = envelope !== null ? envelope.input : args.input;
|
|
265
334
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
266
|
-
const result = runtime.runCommand(entry.snapshot, args.userId, args.command,
|
|
267
|
-
|
|
268
|
-
|
|
335
|
+
const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput);
|
|
336
|
+
const outcome = result.ok
|
|
337
|
+
? { ok: true }
|
|
338
|
+
: { ok: false, reason: result.reason };
|
|
339
|
+
if (result.ok) {
|
|
340
|
+
entry.snapshot = result.snapshot;
|
|
341
|
+
markMutated(entry);
|
|
342
|
+
emit({ type: "server", serverId: args.serverId });
|
|
343
|
+
emit({ type: "player", serverId: args.serverId, userId: args.userId });
|
|
269
344
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return { ok: true };
|
|
345
|
+
if (envelope !== null) {
|
|
346
|
+
rememberAppliedOp(args.serverId, args.userId, envelope.opId, outcome);
|
|
347
|
+
}
|
|
348
|
+
return outcome;
|
|
275
349
|
}),
|
|
276
350
|
isMember: async (args) => {
|
|
277
351
|
const entry = await getLive(args.serverId);
|
|
@@ -327,7 +401,7 @@ export function createGameHost(options) {
|
|
|
327
401
|
return [];
|
|
328
402
|
return persistence.loadFeed({ serverId: args.serverId, action: args.action });
|
|
329
403
|
},
|
|
330
|
-
pushFeedEntry: (args) =>
|
|
404
|
+
pushFeedEntry: (args) => enqueueRoom(args.serverId, async () => {
|
|
331
405
|
const allowed = validateFeedWrite(feedWriteGate, args.action);
|
|
332
406
|
if (!allowed.ok) {
|
|
333
407
|
throw new Error(allowed.reason);
|
|
@@ -342,26 +416,29 @@ export function createGameHost(options) {
|
|
|
342
416
|
listOpenServers: async (args) => {
|
|
343
417
|
const byId = new Map();
|
|
344
418
|
for (const record of await persistence.listServers(args.gameId)) {
|
|
419
|
+
if ((record.visibility ?? "public") === "private")
|
|
420
|
+
continue;
|
|
345
421
|
byId.set(record.serverId, toServerListing(record));
|
|
346
422
|
}
|
|
347
423
|
for (const entry of live.values()) {
|
|
348
424
|
if (entry.record.gameId !== args.gameId)
|
|
349
425
|
continue;
|
|
426
|
+
if ((entry.record.visibility ?? "public") === "private")
|
|
427
|
+
continue;
|
|
350
428
|
byId.set(entry.record.serverId, toServerListing(entry.record));
|
|
351
429
|
}
|
|
352
430
|
return toOpenServerListings(byId.values(), args.limit);
|
|
353
431
|
},
|
|
354
432
|
tickOnce,
|
|
355
|
-
flushAll:
|
|
356
|
-
|
|
357
|
-
for (const entry of live.values()) {
|
|
433
|
+
flushAll: async () => {
|
|
434
|
+
const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
358
435
|
if (entry.record.dirtyAt === undefined)
|
|
359
|
-
|
|
436
|
+
return 0;
|
|
360
437
|
await flushServer(entry);
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
return saved;
|
|
364
|
-
}
|
|
438
|
+
return 1;
|
|
439
|
+
})));
|
|
440
|
+
return outcomes.reduce((total, saved) => total + saved, 0);
|
|
441
|
+
},
|
|
365
442
|
start: () => {
|
|
366
443
|
if (interval !== null)
|
|
367
444
|
return;
|
|
@@ -374,13 +451,12 @@ export function createGameHost(options) {
|
|
|
374
451
|
clearInterval(interval);
|
|
375
452
|
interval = null;
|
|
376
453
|
}
|
|
377
|
-
await
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
});
|
|
454
|
+
await Promise.all(roomQueues.values());
|
|
455
|
+
await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
456
|
+
if (entry.record.dirtyAt === undefined)
|
|
457
|
+
return;
|
|
458
|
+
await flushServer(entry);
|
|
459
|
+
})));
|
|
384
460
|
},
|
|
385
461
|
subscribe: (listener) => {
|
|
386
462
|
listeners.add(listener);
|
|
@@ -389,6 +465,7 @@ export function createGameHost(options) {
|
|
|
389
465
|
};
|
|
390
466
|
return host;
|
|
391
467
|
}
|
|
468
|
+
/** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
|
|
392
469
|
export function memoryPersistence(now = Date.now) {
|
|
393
470
|
const servers = new Map();
|
|
394
471
|
const profiles = new Map();
|
package/dist/hostRouter.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
|
|
2
2
|
import { type ChatRateLimit } from "@jgengine/core/game/chat";
|
|
3
|
+
import { type CommandAuthorize, type CommandCatalog, type CommandLimits } from "./commandMiddleware.js";
|
|
3
4
|
import type { GameHost } from "./host.js";
|
|
4
5
|
import type { TransportPipeFactory } from "./pipe.js";
|
|
5
6
|
export type HostRouterAuthenticate = (args: {
|
|
@@ -16,6 +17,12 @@ export type HostRouterOptions = {
|
|
|
16
17
|
chatRateLimit?: ChatRateLimit;
|
|
17
18
|
chatHistoryLimit?: number;
|
|
18
19
|
chatMaxBodyLength?: number;
|
|
20
|
+
/** Per-op rate limits for pose/runCommand/join/browse/voice. Omit (default) for no rate limiting; pass {@link DEFAULT_COMMAND_LIMITS} or a custom {@link CommandLimits} to opt in. */
|
|
21
|
+
limits?: CommandLimits;
|
|
22
|
+
/** Per-command authorization hook; defaults to allow-all. */
|
|
23
|
+
authorize?: CommandAuthorize;
|
|
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
|
+
validate?: CommandCatalog;
|
|
19
26
|
now?: () => number;
|
|
20
27
|
};
|
|
21
28
|
export type HostRouterTransport = {
|
|
@@ -26,6 +33,7 @@ export type HostRouterConnection = {
|
|
|
26
33
|
handleRaw: (raw: unknown) => void;
|
|
27
34
|
close: () => void;
|
|
28
35
|
};
|
|
36
|
+
/** A player's interpolated position sampled from history at a past timestamp. */
|
|
29
37
|
export type RewoundPosition = {
|
|
30
38
|
userId: string;
|
|
31
39
|
x: number;
|
|
@@ -41,5 +49,7 @@ export type HostRouter = {
|
|
|
41
49
|
close: () => void;
|
|
42
50
|
};
|
|
43
51
|
export declare const DEFAULT_POSE_RULES: PoseSyncRules;
|
|
52
|
+
/** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
|
|
53
|
+
export declare const MAX_QUEUED_MESSAGES = 64;
|
|
44
54
|
export declare function createHostRouter(options: HostRouterOptions): HostRouter;
|
|
45
55
|
export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;
|