@jgengine/ws 0.10.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 +13 -2
- 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 +3 -0
- package/dist/host.js +168 -93
- package/dist/hostRouter.d.ts +9 -0
- package/dist/hostRouter.js +128 -38
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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.js +2 -2
- 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 -1061
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
|
@@ -32,6 +32,7 @@ export type GameHost = {
|
|
|
32
32
|
gameId: string;
|
|
33
33
|
serverId?: string;
|
|
34
34
|
attributes?: SessionAttributes;
|
|
35
|
+
code?: string;
|
|
35
36
|
}) => Promise<JoinServerResult>;
|
|
36
37
|
browseServers: (args: {
|
|
37
38
|
gameId: string;
|
|
@@ -89,6 +90,8 @@ export type GameHost = {
|
|
|
89
90
|
stop: () => Promise<void>;
|
|
90
91
|
subscribe: (listener: (event: HostChangeEvent) => void) => () => void;
|
|
91
92
|
};
|
|
93
|
+
/** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
|
|
94
|
+
export declare const OP_LEDGER_LIMIT = 64;
|
|
92
95
|
/** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
|
|
93
96
|
export declare function createGameHost(options: GameHostOptions): GameHost;
|
|
94
97
|
/** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
|
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,22 @@ 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
|
+
}
|
|
12
28
|
/** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
|
|
13
29
|
export function createGameHost(options) {
|
|
14
30
|
const now = options.now ?? Date.now;
|
|
@@ -35,10 +51,30 @@ export function createGameHost(options) {
|
|
|
35
51
|
for (const listener of listeners)
|
|
36
52
|
listener(event);
|
|
37
53
|
};
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
});
|
|
42
78
|
return run;
|
|
43
79
|
};
|
|
44
80
|
const hydrate = async (record) => {
|
|
@@ -111,34 +147,39 @@ export function createGameHost(options) {
|
|
|
111
147
|
};
|
|
112
148
|
};
|
|
113
149
|
let interval = null;
|
|
114
|
-
const tickOnce =
|
|
150
|
+
const tickOnce = async () => {
|
|
115
151
|
const timestamp = now();
|
|
116
|
-
|
|
117
|
-
let saved = 0;
|
|
118
|
-
for (const entry of live.values()) {
|
|
152
|
+
const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
119
153
|
if (entry.record.status !== "running")
|
|
120
|
-
|
|
154
|
+
return { ticked: 0, saved: 0 };
|
|
121
155
|
if (entry.record.memberUserIds.length === 0)
|
|
122
|
-
|
|
156
|
+
return { ticked: 0, saved: 0 };
|
|
123
157
|
const elapsedMs = timestamp - entry.record.tickAnchorMs;
|
|
124
158
|
if (elapsedMs < tickMs)
|
|
125
|
-
|
|
159
|
+
return { ticked: 0, saved: 0 };
|
|
126
160
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
127
161
|
const before = entry.snapshot.revision;
|
|
128
162
|
entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
|
|
129
163
|
entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
|
|
130
|
-
ticked += 1;
|
|
131
164
|
if (entry.snapshot.revision !== before) {
|
|
132
165
|
markMutated(entry);
|
|
133
166
|
emit({ type: "server", serverId: entry.record.serverId });
|
|
134
167
|
}
|
|
168
|
+
let saved = 0;
|
|
135
169
|
if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
|
|
136
170
|
await flushServer(entry);
|
|
137
|
-
saved
|
|
171
|
+
saved = 1;
|
|
138
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;
|
|
139
180
|
}
|
|
140
181
|
return { ticked, saved };
|
|
141
|
-
}
|
|
182
|
+
};
|
|
142
183
|
const collectListings = async (gameId, options = {}) => {
|
|
143
184
|
const byId = new Map();
|
|
144
185
|
for (const record of await persistence.listServers(gameId)) {
|
|
@@ -166,69 +207,88 @@ export function createGameHost(options) {
|
|
|
166
207
|
}));
|
|
167
208
|
};
|
|
168
209
|
const host = {
|
|
169
|
-
joinServer: (args) =>
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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],
|
|
184
267
|
status: "running",
|
|
185
|
-
memberUserIds: [],
|
|
186
|
-
slotsPerServer: defaultSlots,
|
|
187
|
-
save: runtime.save,
|
|
188
|
-
serverState: createEmptyServerRow(),
|
|
189
|
-
sessionPlayers: {},
|
|
190
|
-
revision: 0,
|
|
191
|
-
tickAnchorMs: timestamp,
|
|
192
|
-
createdAt: timestamp,
|
|
193
268
|
updatedAt: timestamp,
|
|
194
|
-
|
|
195
|
-
...(attributes?.mode !== undefined ? { mode: attributes.mode } : {}),
|
|
196
|
-
...(attributes?.visibility !== undefined ? { visibility: attributes.visibility } : {}),
|
|
197
|
-
...(attributes?.joinCode !== undefined ? { joinCode: attributes.joinCode } : {}),
|
|
198
|
-
...(attributes?.tags !== undefined ? { tags: attributes.tags } : {}),
|
|
269
|
+
dirtyAt: timestamp,
|
|
199
270
|
};
|
|
200
|
-
entry =
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
throw new Error("Server is full");
|
|
206
|
-
}
|
|
207
|
-
const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
|
|
208
|
-
const isNew = profile === null;
|
|
209
|
-
entry.record = {
|
|
210
|
-
...record,
|
|
211
|
-
memberUserIds: record.memberUserIds.includes(args.userId)
|
|
212
|
-
? record.memberUserIds
|
|
213
|
-
: [...record.memberUserIds, args.userId],
|
|
214
|
-
status: "running",
|
|
215
|
-
updatedAt: timestamp,
|
|
216
|
-
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 };
|
|
217
276
|
};
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
emit({ type: "server", serverId: entry.record.serverId });
|
|
221
|
-
emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
|
|
222
|
-
return { serverId: entry.record.serverId, isNew };
|
|
223
|
-
}),
|
|
277
|
+
return args.serverId === undefined ? joinOperation() : enqueueRoom(args.serverId, joinOperation);
|
|
278
|
+
},
|
|
224
279
|
browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
|
|
225
280
|
joinByCode: async (args) => {
|
|
226
281
|
const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
|
|
227
282
|
if (match === null)
|
|
228
283
|
return null;
|
|
229
|
-
return host.joinServer({
|
|
284
|
+
return host.joinServer({
|
|
285
|
+
userId: args.userId,
|
|
286
|
+
gameId: args.gameId,
|
|
287
|
+
serverId: match.serverId,
|
|
288
|
+
code: args.code,
|
|
289
|
+
});
|
|
230
290
|
},
|
|
231
|
-
leaveServer: (args) =>
|
|
291
|
+
leaveServer: (args) => enqueueRoom(args.serverId, async () => {
|
|
232
292
|
const entry = await getLive(args.serverId);
|
|
233
293
|
if (entry === null)
|
|
234
294
|
return;
|
|
@@ -250,12 +310,13 @@ export function createGameHost(options) {
|
|
|
250
310
|
};
|
|
251
311
|
entry.snapshot = { ...entry.snapshot, players };
|
|
252
312
|
await persistence.saveServer(entry.record);
|
|
313
|
+
forgetOpLedger(args.serverId, args.userId);
|
|
253
314
|
if (memberUserIds.length === 0) {
|
|
254
315
|
live.delete(entry.record.serverId);
|
|
255
316
|
}
|
|
256
317
|
emit({ type: "server", serverId: args.serverId });
|
|
257
318
|
}),
|
|
258
|
-
runCommand: (args) =>
|
|
319
|
+
runCommand: (args) => enqueueRoom(args.serverId, async () => {
|
|
259
320
|
const entry = await getLive(args.serverId);
|
|
260
321
|
if (entry === null) {
|
|
261
322
|
return { ok: false, reason: "Server not found" };
|
|
@@ -263,16 +324,28 @@ export function createGameHost(options) {
|
|
|
263
324
|
if (!entry.record.memberUserIds.includes(args.userId)) {
|
|
264
325
|
return { ok: false, reason: "Not a member of this server" };
|
|
265
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;
|
|
266
334
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
267
|
-
const result = runtime.runCommand(entry.snapshot, args.userId, args.command,
|
|
268
|
-
|
|
269
|
-
|
|
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 });
|
|
270
344
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return { ok: true };
|
|
345
|
+
if (envelope !== null) {
|
|
346
|
+
rememberAppliedOp(args.serverId, args.userId, envelope.opId, outcome);
|
|
347
|
+
}
|
|
348
|
+
return outcome;
|
|
276
349
|
}),
|
|
277
350
|
isMember: async (args) => {
|
|
278
351
|
const entry = await getLive(args.serverId);
|
|
@@ -328,7 +401,7 @@ export function createGameHost(options) {
|
|
|
328
401
|
return [];
|
|
329
402
|
return persistence.loadFeed({ serverId: args.serverId, action: args.action });
|
|
330
403
|
},
|
|
331
|
-
pushFeedEntry: (args) =>
|
|
404
|
+
pushFeedEntry: (args) => enqueueRoom(args.serverId, async () => {
|
|
332
405
|
const allowed = validateFeedWrite(feedWriteGate, args.action);
|
|
333
406
|
if (!allowed.ok) {
|
|
334
407
|
throw new Error(allowed.reason);
|
|
@@ -343,26 +416,29 @@ export function createGameHost(options) {
|
|
|
343
416
|
listOpenServers: async (args) => {
|
|
344
417
|
const byId = new Map();
|
|
345
418
|
for (const record of await persistence.listServers(args.gameId)) {
|
|
419
|
+
if ((record.visibility ?? "public") === "private")
|
|
420
|
+
continue;
|
|
346
421
|
byId.set(record.serverId, toServerListing(record));
|
|
347
422
|
}
|
|
348
423
|
for (const entry of live.values()) {
|
|
349
424
|
if (entry.record.gameId !== args.gameId)
|
|
350
425
|
continue;
|
|
426
|
+
if ((entry.record.visibility ?? "public") === "private")
|
|
427
|
+
continue;
|
|
351
428
|
byId.set(entry.record.serverId, toServerListing(entry.record));
|
|
352
429
|
}
|
|
353
430
|
return toOpenServerListings(byId.values(), args.limit);
|
|
354
431
|
},
|
|
355
432
|
tickOnce,
|
|
356
|
-
flushAll:
|
|
357
|
-
|
|
358
|
-
for (const entry of live.values()) {
|
|
433
|
+
flushAll: async () => {
|
|
434
|
+
const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
359
435
|
if (entry.record.dirtyAt === undefined)
|
|
360
|
-
|
|
436
|
+
return 0;
|
|
361
437
|
await flushServer(entry);
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
return saved;
|
|
365
|
-
}
|
|
438
|
+
return 1;
|
|
439
|
+
})));
|
|
440
|
+
return outcomes.reduce((total, saved) => total + saved, 0);
|
|
441
|
+
},
|
|
366
442
|
start: () => {
|
|
367
443
|
if (interval !== null)
|
|
368
444
|
return;
|
|
@@ -375,13 +451,12 @@ export function createGameHost(options) {
|
|
|
375
451
|
clearInterval(interval);
|
|
376
452
|
interval = null;
|
|
377
453
|
}
|
|
378
|
-
await
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
});
|
|
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
|
+
})));
|
|
385
460
|
},
|
|
386
461
|
subscribe: (listener) => {
|
|
387
462
|
listeners.add(listener);
|
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 = {
|
|
@@ -42,5 +49,7 @@ export type HostRouter = {
|
|
|
42
49
|
close: () => void;
|
|
43
50
|
};
|
|
44
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;
|
|
45
54
|
export declare function createHostRouter(options: HostRouterOptions): HostRouter;
|
|
46
55
|
export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;
|