@jgengine/ws 0.10.0 → 0.12.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 +39 -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 +173 -101
- package/dist/hostRouter.d.ts +9 -0
- package/dist/hostRouter.js +178 -116
- 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 +54 -0
- package/skills/jgengine/api.md +527 -0
- package/skills/jgengine/capabilities.md +131 -0
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-multiplayer/SKILL.md +47 -0
- package/skills/jgengine-multiplayer/api.md +620 -0
- package/skills/jgengine-multiplayer/capabilities.md +17 -0
- package/skills/jgengine-multiplayer/reference.md +71 -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,5 +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 { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
3
4
|
import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
5
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
5
6
|
import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
|
|
@@ -9,6 +10,22 @@ const builtinCommands = {
|
|
|
9
10
|
apply: (snapshot) => snapshot,
|
|
10
11
|
},
|
|
11
12
|
};
|
|
13
|
+
/** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
|
|
14
|
+
export const OP_LEDGER_LIMIT = 64;
|
|
15
|
+
const WS_OP_ID_FIELD = "__jgWsOpId";
|
|
16
|
+
function unwrapOpEnvelope(input) {
|
|
17
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
18
|
+
return null;
|
|
19
|
+
const record = input;
|
|
20
|
+
const opId = record[WS_OP_ID_FIELD];
|
|
21
|
+
if (typeof opId !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
const { [WS_OP_ID_FIELD]: _opId, ...rest } = record;
|
|
24
|
+
return { opId, input: rest };
|
|
25
|
+
}
|
|
26
|
+
function opLedgerKey(serverId, userId) {
|
|
27
|
+
return `${serverId}|${userId}`;
|
|
28
|
+
}
|
|
12
29
|
/** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
|
|
13
30
|
export function createGameHost(options) {
|
|
14
31
|
const now = options.now ?? Date.now;
|
|
@@ -35,10 +52,30 @@ export function createGameHost(options) {
|
|
|
35
52
|
for (const listener of listeners)
|
|
36
53
|
listener(event);
|
|
37
54
|
};
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
const opLedgers = new Map();
|
|
56
|
+
const findAppliedOp = (serverId, userId, opId) => opLedgers.get(opLedgerKey(serverId, userId))?.find((entry) => entry.opId === opId)?.result;
|
|
57
|
+
const rememberAppliedOp = (serverId, userId, opId, result) => {
|
|
58
|
+
const key = opLedgerKey(serverId, userId);
|
|
59
|
+
const entries = opLedgers.get(key) ?? [];
|
|
60
|
+
entries.push({ opId, result });
|
|
61
|
+
if (entries.length > OP_LEDGER_LIMIT)
|
|
62
|
+
entries.shift();
|
|
63
|
+
opLedgers.set(key, entries);
|
|
64
|
+
};
|
|
65
|
+
const forgetOpLedger = (serverId, userId) => {
|
|
66
|
+
opLedgers.delete(opLedgerKey(serverId, userId));
|
|
67
|
+
};
|
|
68
|
+
const roomQueues = new Map();
|
|
69
|
+
const enqueueRoom = (roomId, operation) => {
|
|
70
|
+
const previous = roomQueues.get(roomId) ?? Promise.resolve();
|
|
71
|
+
const run = previous.then(operation);
|
|
72
|
+
const settled = run.catch(() => undefined);
|
|
73
|
+
roomQueues.set(roomId, settled);
|
|
74
|
+
void settled.finally(() => {
|
|
75
|
+
if (roomQueues.get(roomId) === settled) {
|
|
76
|
+
roomQueues.delete(roomId);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
42
79
|
return run;
|
|
43
80
|
};
|
|
44
81
|
const hydrate = async (record) => {
|
|
@@ -111,34 +148,39 @@ export function createGameHost(options) {
|
|
|
111
148
|
};
|
|
112
149
|
};
|
|
113
150
|
let interval = null;
|
|
114
|
-
const tickOnce =
|
|
151
|
+
const tickOnce = async () => {
|
|
115
152
|
const timestamp = now();
|
|
116
|
-
|
|
117
|
-
let saved = 0;
|
|
118
|
-
for (const entry of live.values()) {
|
|
153
|
+
const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
119
154
|
if (entry.record.status !== "running")
|
|
120
|
-
|
|
155
|
+
return { ticked: 0, saved: 0 };
|
|
121
156
|
if (entry.record.memberUserIds.length === 0)
|
|
122
|
-
|
|
157
|
+
return { ticked: 0, saved: 0 };
|
|
123
158
|
const elapsedMs = timestamp - entry.record.tickAnchorMs;
|
|
124
159
|
if (elapsedMs < tickMs)
|
|
125
|
-
|
|
160
|
+
return { ticked: 0, saved: 0 };
|
|
126
161
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
127
162
|
const before = entry.snapshot.revision;
|
|
128
163
|
entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
|
|
129
164
|
entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
|
|
130
|
-
ticked += 1;
|
|
131
165
|
if (entry.snapshot.revision !== before) {
|
|
132
166
|
markMutated(entry);
|
|
133
167
|
emit({ type: "server", serverId: entry.record.serverId });
|
|
134
168
|
}
|
|
169
|
+
let saved = 0;
|
|
135
170
|
if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
|
|
136
171
|
await flushServer(entry);
|
|
137
|
-
saved
|
|
172
|
+
saved = 1;
|
|
138
173
|
}
|
|
174
|
+
return { ticked: 1, saved };
|
|
175
|
+
})));
|
|
176
|
+
let ticked = 0;
|
|
177
|
+
let saved = 0;
|
|
178
|
+
for (const outcome of outcomes) {
|
|
179
|
+
ticked += outcome.ticked;
|
|
180
|
+
saved += outcome.saved;
|
|
139
181
|
}
|
|
140
182
|
return { ticked, saved };
|
|
141
|
-
}
|
|
183
|
+
};
|
|
142
184
|
const collectListings = async (gameId, options = {}) => {
|
|
143
185
|
const byId = new Map();
|
|
144
186
|
for (const record of await persistence.listServers(gameId)) {
|
|
@@ -166,77 +208,92 @@ export function createGameHost(options) {
|
|
|
166
208
|
}));
|
|
167
209
|
};
|
|
168
210
|
const host = {
|
|
169
|
-
joinServer: (args) =>
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
211
|
+
joinServer: (args) => {
|
|
212
|
+
const joinOperation = async () => {
|
|
213
|
+
const timestamp = now();
|
|
214
|
+
const runtime = resolveRuntime(args.gameId);
|
|
215
|
+
let entry = args.serverId === undefined ? null : await getLive(args.serverId);
|
|
216
|
+
if (args.serverId !== undefined && entry === null) {
|
|
217
|
+
throw new Error("Server not found");
|
|
218
|
+
}
|
|
219
|
+
if (entry !== null && entry.record.gameId !== args.gameId) {
|
|
220
|
+
throw new Error("Server belongs to a different game");
|
|
221
|
+
}
|
|
222
|
+
if (entry !== null &&
|
|
223
|
+
isPrivateJoinBlocked({
|
|
224
|
+
visibility: entry.record.visibility,
|
|
225
|
+
memberUserIds: entry.record.memberUserIds,
|
|
226
|
+
userId: args.userId,
|
|
227
|
+
joinCode: entry.record.joinCode,
|
|
228
|
+
suppliedCode: args.code,
|
|
229
|
+
})) {
|
|
230
|
+
throw new Error("Server is private");
|
|
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 (isServerFull(record.memberUserIds, record.slotsPerServer, args.userId)) {
|
|
257
|
+
throw new Error("Server is full");
|
|
258
|
+
}
|
|
259
|
+
const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
|
|
260
|
+
const isNew = profile === null;
|
|
261
|
+
entry.record = {
|
|
262
|
+
...record,
|
|
263
|
+
memberUserIds: withJoinedMember(record.memberUserIds, args.userId),
|
|
184
264
|
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
265
|
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 } : {}),
|
|
266
|
+
dirtyAt: timestamp,
|
|
199
267
|
};
|
|
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,
|
|
268
|
+
entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
|
|
269
|
+
await flushServer(entry);
|
|
270
|
+
emit({ type: "server", serverId: entry.record.serverId });
|
|
271
|
+
emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
|
|
272
|
+
return { serverId: entry.record.serverId, isNew };
|
|
217
273
|
};
|
|
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
|
-
}),
|
|
274
|
+
return args.serverId === undefined ? joinOperation() : enqueueRoom(args.serverId, joinOperation);
|
|
275
|
+
},
|
|
224
276
|
browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
|
|
225
277
|
joinByCode: async (args) => {
|
|
226
278
|
const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
|
|
227
279
|
if (match === null)
|
|
228
280
|
return null;
|
|
229
|
-
return host.joinServer({
|
|
281
|
+
return host.joinServer({
|
|
282
|
+
userId: args.userId,
|
|
283
|
+
gameId: args.gameId,
|
|
284
|
+
serverId: match.serverId,
|
|
285
|
+
code: args.code,
|
|
286
|
+
});
|
|
230
287
|
},
|
|
231
|
-
leaveServer: (args) =>
|
|
288
|
+
leaveServer: (args) => enqueueRoom(args.serverId, async () => {
|
|
232
289
|
const entry = await getLive(args.serverId);
|
|
233
290
|
if (entry === null)
|
|
234
291
|
return;
|
|
235
|
-
if (!entry.record.memberUserIds
|
|
292
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
236
293
|
return;
|
|
237
294
|
await flushServer(entry);
|
|
238
295
|
const timestamp = now();
|
|
239
|
-
const memberUserIds = entry.record.memberUserIds
|
|
296
|
+
const memberUserIds = withoutMember(entry.record.memberUserIds, args.userId);
|
|
240
297
|
const sessionPlayers = { ...entry.record.sessionPlayers };
|
|
241
298
|
delete sessionPlayers[args.userId];
|
|
242
299
|
const players = { ...entry.snapshot.players };
|
|
@@ -246,45 +303,58 @@ export function createGameHost(options) {
|
|
|
246
303
|
memberUserIds,
|
|
247
304
|
sessionPlayers,
|
|
248
305
|
updatedAt: timestamp,
|
|
249
|
-
status: memberUserIds.length
|
|
306
|
+
status: statusAfterLeave(memberUserIds.length, entry.record.status),
|
|
250
307
|
};
|
|
251
308
|
entry.snapshot = { ...entry.snapshot, players };
|
|
252
309
|
await persistence.saveServer(entry.record);
|
|
310
|
+
forgetOpLedger(args.serverId, args.userId);
|
|
253
311
|
if (memberUserIds.length === 0) {
|
|
254
312
|
live.delete(entry.record.serverId);
|
|
255
313
|
}
|
|
256
314
|
emit({ type: "server", serverId: args.serverId });
|
|
257
315
|
}),
|
|
258
|
-
runCommand: (args) =>
|
|
316
|
+
runCommand: (args) => enqueueRoom(args.serverId, async () => {
|
|
259
317
|
const entry = await getLive(args.serverId);
|
|
260
318
|
if (entry === null) {
|
|
261
319
|
return { ok: false, reason: "Server not found" };
|
|
262
320
|
}
|
|
263
|
-
if (!entry.record.memberUserIds
|
|
321
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId)) {
|
|
264
322
|
return { ok: false, reason: "Not a member of this server" };
|
|
265
323
|
}
|
|
324
|
+
const envelope = unwrapOpEnvelope(args.input);
|
|
325
|
+
if (envelope !== null) {
|
|
326
|
+
const applied = findAppliedOp(args.serverId, args.userId, envelope.opId);
|
|
327
|
+
if (applied !== undefined)
|
|
328
|
+
return applied;
|
|
329
|
+
}
|
|
330
|
+
const commandInput = envelope !== null ? envelope.input : args.input;
|
|
266
331
|
const runtime = resolveRuntime(entry.record.gameId);
|
|
267
|
-
const result = runtime.runCommand(entry.snapshot, args.userId, args.command,
|
|
268
|
-
|
|
269
|
-
|
|
332
|
+
const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput);
|
|
333
|
+
const outcome = result.ok
|
|
334
|
+
? { ok: true }
|
|
335
|
+
: { ok: false, reason: result.reason };
|
|
336
|
+
if (result.ok) {
|
|
337
|
+
entry.snapshot = result.snapshot;
|
|
338
|
+
markMutated(entry);
|
|
339
|
+
emit({ type: "server", serverId: args.serverId });
|
|
340
|
+
emit({ type: "player", serverId: args.serverId, userId: args.userId });
|
|
270
341
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return { ok: true };
|
|
342
|
+
if (envelope !== null) {
|
|
343
|
+
rememberAppliedOp(args.serverId, args.userId, envelope.opId, outcome);
|
|
344
|
+
}
|
|
345
|
+
return outcome;
|
|
276
346
|
}),
|
|
277
347
|
isMember: async (args) => {
|
|
278
348
|
const entry = await getLive(args.serverId);
|
|
279
349
|
if (entry === null)
|
|
280
350
|
return false;
|
|
281
|
-
return entry.record.memberUserIds
|
|
351
|
+
return isServerMember(entry.record.memberUserIds, args.userId);
|
|
282
352
|
},
|
|
283
353
|
getServerView: async (args) => {
|
|
284
354
|
const entry = await getLive(args.serverId);
|
|
285
355
|
if (entry === null)
|
|
286
356
|
return null;
|
|
287
|
-
if (!entry.record.memberUserIds
|
|
357
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
288
358
|
return null;
|
|
289
359
|
return {
|
|
290
360
|
serverId: entry.record.serverId,
|
|
@@ -299,7 +369,7 @@ export function createGameHost(options) {
|
|
|
299
369
|
const entry = await getLive(args.serverId);
|
|
300
370
|
if (entry === null)
|
|
301
371
|
return null;
|
|
302
|
-
if (!entry.record.memberUserIds
|
|
372
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
303
373
|
return null;
|
|
304
374
|
const player = entry.snapshot.players[args.userId];
|
|
305
375
|
if (player !== undefined) {
|
|
@@ -324,17 +394,17 @@ export function createGameHost(options) {
|
|
|
324
394
|
const entry = await getLive(args.serverId);
|
|
325
395
|
if (entry === null)
|
|
326
396
|
return [];
|
|
327
|
-
if (!entry.record.memberUserIds
|
|
397
|
+
if (!isServerMember(entry.record.memberUserIds, args.userId))
|
|
328
398
|
return [];
|
|
329
399
|
return persistence.loadFeed({ serverId: args.serverId, action: args.action });
|
|
330
400
|
},
|
|
331
|
-
pushFeedEntry: (args) =>
|
|
401
|
+
pushFeedEntry: (args) => enqueueRoom(args.serverId, async () => {
|
|
332
402
|
const allowed = validateFeedWrite(feedWriteGate, args.action);
|
|
333
403
|
if (!allowed.ok) {
|
|
334
404
|
throw new Error(allowed.reason);
|
|
335
405
|
}
|
|
336
406
|
const entry = await getLive(args.serverId);
|
|
337
|
-
if (entry === null || !entry.record.memberUserIds
|
|
407
|
+
if (entry === null || !isServerMember(entry.record.memberUserIds, args.userId)) {
|
|
338
408
|
throw new Error("Not a member of this server");
|
|
339
409
|
}
|
|
340
410
|
await persistence.appendFeed({ serverId: args.serverId, action: args.action, entry: args.entry });
|
|
@@ -343,26 +413,29 @@ export function createGameHost(options) {
|
|
|
343
413
|
listOpenServers: async (args) => {
|
|
344
414
|
const byId = new Map();
|
|
345
415
|
for (const record of await persistence.listServers(args.gameId)) {
|
|
416
|
+
if (!isListablePublicly(record.visibility))
|
|
417
|
+
continue;
|
|
346
418
|
byId.set(record.serverId, toServerListing(record));
|
|
347
419
|
}
|
|
348
420
|
for (const entry of live.values()) {
|
|
349
421
|
if (entry.record.gameId !== args.gameId)
|
|
350
422
|
continue;
|
|
423
|
+
if (!isListablePublicly(entry.record.visibility))
|
|
424
|
+
continue;
|
|
351
425
|
byId.set(entry.record.serverId, toServerListing(entry.record));
|
|
352
426
|
}
|
|
353
427
|
return toOpenServerListings(byId.values(), args.limit);
|
|
354
428
|
},
|
|
355
429
|
tickOnce,
|
|
356
|
-
flushAll:
|
|
357
|
-
|
|
358
|
-
for (const entry of live.values()) {
|
|
430
|
+
flushAll: async () => {
|
|
431
|
+
const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
359
432
|
if (entry.record.dirtyAt === undefined)
|
|
360
|
-
|
|
433
|
+
return 0;
|
|
361
434
|
await flushServer(entry);
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
return saved;
|
|
365
|
-
}
|
|
435
|
+
return 1;
|
|
436
|
+
})));
|
|
437
|
+
return outcomes.reduce((total, saved) => total + saved, 0);
|
|
438
|
+
},
|
|
366
439
|
start: () => {
|
|
367
440
|
if (interval !== null)
|
|
368
441
|
return;
|
|
@@ -375,13 +448,12 @@ export function createGameHost(options) {
|
|
|
375
448
|
clearInterval(interval);
|
|
376
449
|
interval = null;
|
|
377
450
|
}
|
|
378
|
-
await
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
});
|
|
451
|
+
await Promise.all(roomQueues.values());
|
|
452
|
+
await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
|
|
453
|
+
if (entry.record.dirtyAt === undefined)
|
|
454
|
+
return;
|
|
455
|
+
await flushServer(entry);
|
|
456
|
+
})));
|
|
385
457
|
},
|
|
386
458
|
subscribe: (listener) => {
|
|
387
459
|
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;
|