@jgengine/ws 0.7.0 → 0.9.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/dist/host.js ADDED
@@ -0,0 +1,454 @@
1
+ import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
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";
4
+ import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
5
+ import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
6
+ const builtinCommands = {
7
+ "engine.ping": {
8
+ validate: () => null,
9
+ apply: (snapshot) => snapshot,
10
+ },
11
+ };
12
+ export function createGameHost(options) {
13
+ const now = options.now ?? Date.now;
14
+ const tickMs = options.tickMs ?? 1_000;
15
+ const defaultSlots = options.slotsPerServer ?? 16;
16
+ const createServerId = options.createServerId ?? (() => `srv-${crypto.randomUUID()}`);
17
+ const persistence = options.persistence;
18
+ const feedWriteGate = createFeedWriteGate(options.allowedFeedActions ?? []);
19
+ const runtimes = new Map();
20
+ for (const runtime of options.runtimes ?? []) {
21
+ runtimes.set(runtime.gameId, runtime);
22
+ }
23
+ const resolveRuntime = (gameId) => {
24
+ const existing = runtimes.get(gameId);
25
+ if (existing)
26
+ return existing;
27
+ const fallback = createGameRuntime({ gameId, save: "none", commands: builtinCommands });
28
+ runtimes.set(gameId, fallback);
29
+ return fallback;
30
+ };
31
+ const live = new Map();
32
+ const listeners = new Set();
33
+ const emit = (event) => {
34
+ for (const listener of listeners)
35
+ listener(event);
36
+ };
37
+ let queue = Promise.resolve();
38
+ const enqueue = (operation) => {
39
+ const run = queue.then(operation);
40
+ queue = run.catch(() => undefined);
41
+ return run;
42
+ };
43
+ const hydrate = async (record) => {
44
+ const runtime = resolveRuntime(record.gameId);
45
+ const profiles = {};
46
+ for (const userId of record.memberUserIds) {
47
+ profiles[userId] = await persistence.loadProfile({ userId, gameId: record.gameId });
48
+ }
49
+ const chunkRecords = await persistence.loadChunks(record.serverId);
50
+ const chunksByKey = {};
51
+ for (const chunk of chunkRecords) {
52
+ chunksByKey[chunk.chunkKey] = chunk.snapshot;
53
+ }
54
+ const snapshot = runtime.hydrate({
55
+ gameId: record.gameId,
56
+ serverId: record.serverId,
57
+ serverRow: record.serverState,
58
+ playersByUserId: buildHydratePlayers(record, profiles),
59
+ chunksByKey,
60
+ revision: record.revision,
61
+ });
62
+ const existing = live.get(record.serverId);
63
+ if (existing)
64
+ return existing;
65
+ const entry = { record, snapshot };
66
+ live.set(record.serverId, entry);
67
+ return entry;
68
+ };
69
+ const getLive = async (serverId) => {
70
+ const existing = live.get(serverId);
71
+ if (existing)
72
+ return existing;
73
+ const record = await persistence.loadServer(serverId);
74
+ if (record === null)
75
+ return null;
76
+ return hydrate(record);
77
+ };
78
+ const flushServer = async (entry) => {
79
+ const timestamp = now();
80
+ const plan = planServerPersist(entry.record, entry.snapshot, entry.record.save, timestamp);
81
+ const cleared = { ...plan, server: { ...plan.server, dirtyAt: undefined } };
82
+ if (persistence.savePlan) {
83
+ await persistence.savePlan(cleared);
84
+ }
85
+ else {
86
+ if (cleared.leaderboard.length > 0) {
87
+ await persistence.applyLeaderboardIncrements(entry.record.gameId, cleared.leaderboard);
88
+ }
89
+ for (const profile of cleared.profiles) {
90
+ await persistence.saveProfile(profile);
91
+ }
92
+ if (cleared.chunks.length > 0) {
93
+ await persistence.saveChunks(entry.record.serverId, cleared.chunks);
94
+ }
95
+ if (cleared.deletedChunks.length > 0) {
96
+ await persistence.deleteChunks(entry.record.serverId, cleared.deletedChunks);
97
+ }
98
+ await persistence.saveServer(cleared.server);
99
+ }
100
+ entry.record = cleared.server;
101
+ entry.snapshot = clearDirtyFlags({ ...entry.snapshot, server: cleared.server.serverState });
102
+ };
103
+ const markMutated = (entry) => {
104
+ const timestamp = now();
105
+ entry.record = {
106
+ ...entry.record,
107
+ revision: entry.snapshot.revision,
108
+ dirtyAt: entry.record.dirtyAt ?? timestamp,
109
+ updatedAt: timestamp,
110
+ };
111
+ };
112
+ let interval = null;
113
+ const tickOnce = () => enqueue(async () => {
114
+ const timestamp = now();
115
+ let ticked = 0;
116
+ let saved = 0;
117
+ for (const entry of live.values()) {
118
+ if (entry.record.status !== "running")
119
+ continue;
120
+ if (entry.record.memberUserIds.length === 0)
121
+ continue;
122
+ const elapsedMs = timestamp - entry.record.tickAnchorMs;
123
+ if (elapsedMs < tickMs)
124
+ continue;
125
+ const runtime = resolveRuntime(entry.record.gameId);
126
+ const before = entry.snapshot.revision;
127
+ entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
128
+ entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
129
+ ticked += 1;
130
+ if (entry.snapshot.revision !== before) {
131
+ markMutated(entry);
132
+ emit({ type: "server", serverId: entry.record.serverId });
133
+ }
134
+ if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
135
+ await flushServer(entry);
136
+ saved += 1;
137
+ }
138
+ }
139
+ return { ticked, saved };
140
+ });
141
+ const collectListings = async (gameId, options = {}) => {
142
+ const byId = new Map();
143
+ for (const record of await persistence.listServers(gameId)) {
144
+ byId.set(record.serverId, record);
145
+ }
146
+ for (const entry of live.values()) {
147
+ if (entry.record.gameId !== gameId)
148
+ continue;
149
+ byId.set(entry.record.serverId, entry.record);
150
+ }
151
+ return [...byId.values()].map((record) => ({
152
+ serverId: record.serverId,
153
+ gameId: record.gameId,
154
+ status: record.status,
155
+ visibility: record.visibility ?? "public",
156
+ memberCount: record.memberUserIds.length,
157
+ slotsPerServer: record.slotsPerServer,
158
+ label: record.label,
159
+ mode: record.mode,
160
+ ...(options.includeJoinCode === true && record.joinCode !== undefined
161
+ ? { joinCode: record.joinCode }
162
+ : {}),
163
+ tags: record.tags,
164
+ updatedAt: record.updatedAt,
165
+ }));
166
+ };
167
+ const host = {
168
+ joinServer: (args) => enqueue(async () => {
169
+ const timestamp = now();
170
+ const runtime = resolveRuntime(args.gameId);
171
+ let entry = args.serverId === undefined ? null : await getLive(args.serverId);
172
+ if (args.serverId !== undefined && entry === null) {
173
+ throw new Error("Server not found");
174
+ }
175
+ if (entry !== null && entry.record.gameId !== args.gameId) {
176
+ throw new Error("Server belongs to a different game");
177
+ }
178
+ if (entry === null) {
179
+ const attributes = args.attributes;
180
+ const record = {
181
+ serverId: createServerId(),
182
+ gameId: args.gameId,
183
+ 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
+ updatedAt: timestamp,
193
+ ...(attributes?.label !== undefined ? { label: attributes.label } : {}),
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 } : {}),
198
+ };
199
+ entry = await hydrate(record);
200
+ }
201
+ const { record } = entry;
202
+ if (record.memberUserIds.length >= record.slotsPerServer &&
203
+ !record.memberUserIds.includes(args.userId)) {
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,
216
+ };
217
+ entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
218
+ await flushServer(entry);
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
+ }),
223
+ browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
224
+ joinByCode: async (args) => {
225
+ const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
226
+ if (match === null)
227
+ return null;
228
+ return host.joinServer({ userId: args.userId, gameId: args.gameId, serverId: match.serverId });
229
+ },
230
+ leaveServer: (args) => enqueue(async () => {
231
+ const entry = await getLive(args.serverId);
232
+ if (entry === null)
233
+ return;
234
+ if (!entry.record.memberUserIds.includes(args.userId))
235
+ return;
236
+ await flushServer(entry);
237
+ const timestamp = now();
238
+ const memberUserIds = entry.record.memberUserIds.filter((id) => id !== args.userId);
239
+ const sessionPlayers = { ...entry.record.sessionPlayers };
240
+ delete sessionPlayers[args.userId];
241
+ const players = { ...entry.snapshot.players };
242
+ delete players[args.userId];
243
+ entry.record = {
244
+ ...entry.record,
245
+ memberUserIds,
246
+ sessionPlayers,
247
+ updatedAt: timestamp,
248
+ status: memberUserIds.length === 0 ? "open" : entry.record.status,
249
+ };
250
+ entry.snapshot = { ...entry.snapshot, players };
251
+ await persistence.saveServer(entry.record);
252
+ if (memberUserIds.length === 0) {
253
+ live.delete(entry.record.serverId);
254
+ }
255
+ emit({ type: "server", serverId: args.serverId });
256
+ }),
257
+ runCommand: (args) => enqueue(async () => {
258
+ const entry = await getLive(args.serverId);
259
+ if (entry === null) {
260
+ return { ok: false, reason: "Server not found" };
261
+ }
262
+ if (!entry.record.memberUserIds.includes(args.userId)) {
263
+ return { ok: false, reason: "Not a member of this server" };
264
+ }
265
+ const runtime = resolveRuntime(entry.record.gameId);
266
+ const result = runtime.runCommand(entry.snapshot, args.userId, args.command, args.input);
267
+ if (!result.ok) {
268
+ return { ok: false, reason: result.reason };
269
+ }
270
+ entry.snapshot = result.snapshot;
271
+ markMutated(entry);
272
+ emit({ type: "server", serverId: args.serverId });
273
+ emit({ type: "player", serverId: args.serverId, userId: args.userId });
274
+ return { ok: true };
275
+ }),
276
+ isMember: async (args) => {
277
+ const entry = await getLive(args.serverId);
278
+ if (entry === null)
279
+ return false;
280
+ return entry.record.memberUserIds.includes(args.userId);
281
+ },
282
+ getServerView: async (args) => {
283
+ const entry = await getLive(args.serverId);
284
+ if (entry === null)
285
+ return null;
286
+ if (!entry.record.memberUserIds.includes(args.userId))
287
+ return null;
288
+ return {
289
+ serverId: entry.record.serverId,
290
+ gameId: entry.record.gameId,
291
+ revision: entry.snapshot.revision,
292
+ memberUserIds: entry.record.memberUserIds,
293
+ serverState: entry.snapshot.server,
294
+ updatedAt: entry.record.updatedAt,
295
+ };
296
+ },
297
+ getPlayerView: async (args) => {
298
+ const entry = await getLive(args.serverId);
299
+ if (entry === null)
300
+ return null;
301
+ if (!entry.record.memberUserIds.includes(args.userId))
302
+ return null;
303
+ const player = entry.snapshot.players[args.userId];
304
+ if (player !== undefined) {
305
+ return {
306
+ userId: args.userId,
307
+ gameId: entry.record.gameId,
308
+ playerState: splitProfilePlayer(player).persistent,
309
+ updatedAt: entry.record.updatedAt,
310
+ };
311
+ }
312
+ const profile = await persistence.loadProfile({ userId: args.userId, gameId: entry.record.gameId });
313
+ if (profile === null)
314
+ return null;
315
+ return {
316
+ userId: profile.userId,
317
+ gameId: profile.gameId,
318
+ playerState: profile.playerState,
319
+ updatedAt: profile.updatedAt,
320
+ };
321
+ },
322
+ getFeed: async (args) => {
323
+ const entry = await getLive(args.serverId);
324
+ if (entry === null)
325
+ return [];
326
+ if (!entry.record.memberUserIds.includes(args.userId))
327
+ return [];
328
+ return persistence.loadFeed({ serverId: args.serverId, action: args.action });
329
+ },
330
+ pushFeedEntry: (args) => enqueue(async () => {
331
+ const allowed = validateFeedWrite(feedWriteGate, args.action);
332
+ if (!allowed.ok) {
333
+ throw new Error(allowed.reason);
334
+ }
335
+ const entry = await getLive(args.serverId);
336
+ if (entry === null || !entry.record.memberUserIds.includes(args.userId)) {
337
+ throw new Error("Not a member of this server");
338
+ }
339
+ await persistence.appendFeed({ serverId: args.serverId, action: args.action, entry: args.entry });
340
+ emit({ type: "feed", serverId: args.serverId, action: args.action });
341
+ }),
342
+ listOpenServers: async (args) => {
343
+ const byId = new Map();
344
+ for (const record of await persistence.listServers(args.gameId)) {
345
+ byId.set(record.serverId, toServerListing(record));
346
+ }
347
+ for (const entry of live.values()) {
348
+ if (entry.record.gameId !== args.gameId)
349
+ continue;
350
+ byId.set(entry.record.serverId, toServerListing(entry.record));
351
+ }
352
+ return toOpenServerListings(byId.values(), args.limit);
353
+ },
354
+ tickOnce,
355
+ flushAll: () => enqueue(async () => {
356
+ let saved = 0;
357
+ for (const entry of live.values()) {
358
+ if (entry.record.dirtyAt === undefined)
359
+ continue;
360
+ await flushServer(entry);
361
+ saved += 1;
362
+ }
363
+ return saved;
364
+ }),
365
+ start: () => {
366
+ if (interval !== null)
367
+ return;
368
+ interval = setInterval(() => {
369
+ void tickOnce();
370
+ }, tickMs);
371
+ },
372
+ stop: async () => {
373
+ if (interval !== null) {
374
+ clearInterval(interval);
375
+ interval = null;
376
+ }
377
+ await enqueue(async () => {
378
+ for (const entry of live.values()) {
379
+ if (entry.record.dirtyAt === undefined)
380
+ continue;
381
+ await flushServer(entry);
382
+ }
383
+ });
384
+ },
385
+ subscribe: (listener) => {
386
+ listeners.add(listener);
387
+ return () => listeners.delete(listener);
388
+ },
389
+ };
390
+ return host;
391
+ }
392
+ export function memoryPersistence(now = Date.now) {
393
+ const servers = new Map();
394
+ const profiles = new Map();
395
+ const chunks = new Map();
396
+ const feeds = new Map();
397
+ const leaderboard = new Map();
398
+ const clone = (value) => structuredClone(value);
399
+ return {
400
+ async loadServer(serverId) {
401
+ const record = servers.get(serverId);
402
+ return record === undefined ? null : clone(record);
403
+ },
404
+ async saveServer(record) {
405
+ servers.set(record.serverId, clone(record));
406
+ },
407
+ async listServers(gameId) {
408
+ return [...servers.values()].filter((record) => record.gameId === gameId).map(clone);
409
+ },
410
+ async loadProfile({ userId, gameId }) {
411
+ const record = profiles.get(`${gameId}|${userId}`);
412
+ return record === undefined ? null : clone(record);
413
+ },
414
+ async saveProfile(record) {
415
+ profiles.set(`${record.gameId}|${record.userId}`, clone(record));
416
+ },
417
+ async loadChunks(serverId) {
418
+ return [...(chunks.get(serverId)?.values() ?? [])].map(clone);
419
+ },
420
+ async saveChunks(serverId, records) {
421
+ const byKey = chunks.get(serverId) ?? new Map();
422
+ for (const record of records) {
423
+ byKey.set(record.chunkKey, clone(record));
424
+ }
425
+ chunks.set(serverId, byKey);
426
+ },
427
+ async deleteChunks(serverId, chunkKeys) {
428
+ const byKey = chunks.get(serverId);
429
+ if (byKey === undefined)
430
+ return;
431
+ for (const chunkKey of chunkKeys) {
432
+ byKey.delete(chunkKey);
433
+ }
434
+ },
435
+ async loadFeed({ serverId, action }) {
436
+ return clone(feeds.get(`${serverId}|${action}`) ?? []);
437
+ },
438
+ async appendFeed({ serverId, action, entry }) {
439
+ const key = `${serverId}|${action}`;
440
+ const entries = trimFeedEntries([...(feeds.get(key) ?? []), clone(entry)]);
441
+ feeds.set(key, entries);
442
+ return clone(entries);
443
+ },
444
+ async applyLeaderboardIncrements(gameId, entries) {
445
+ applyLeaderboardRows(leaderboard, gameId, entries, now());
446
+ },
447
+ async getLeaderboardTop(args) {
448
+ return topLeaderboardRows(leaderboard.values(), args);
449
+ },
450
+ async getLeaderboardProfile({ gameId, userId }) {
451
+ return profileLeaderboardStats(leaderboard.values(), gameId, userId);
452
+ },
453
+ };
454
+ }
@@ -0,0 +1,45 @@
1
+ import type { PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
2
+ import { type ChatRateLimit } from "@jgengine/core/game/chat";
3
+ import type { GameHost } from "./host.js";
4
+ import type { TransportPipeFactory } from "./pipe.js";
5
+ export type HostRouterAuthenticate = (args: {
6
+ userId: string;
7
+ token?: string;
8
+ }) => Promise<string | null> | string | null;
9
+ export type HostRouterOptions = {
10
+ host: GameHost;
11
+ authenticate?: HostRouterAuthenticate;
12
+ allowAnonymous?: boolean;
13
+ singleSession?: boolean;
14
+ poseRules?: PoseSyncRules;
15
+ positionHistoryMs?: number;
16
+ chatRateLimit?: ChatRateLimit;
17
+ chatHistoryLimit?: number;
18
+ chatMaxBodyLength?: number;
19
+ now?: () => number;
20
+ };
21
+ export type HostRouterTransport = {
22
+ send: (data: string) => void;
23
+ close: () => void;
24
+ };
25
+ export type HostRouterConnection = {
26
+ handleRaw: (raw: unknown) => void;
27
+ close: () => void;
28
+ };
29
+ export type RewoundPosition = {
30
+ userId: string;
31
+ x: number;
32
+ y: number;
33
+ z: number;
34
+ };
35
+ export type HostRouter = {
36
+ connect: (transport: HostRouterTransport) => HostRouterConnection;
37
+ rewind: (args: {
38
+ serverId: string;
39
+ atMs: number;
40
+ }) => RewoundPosition[];
41
+ close: () => void;
42
+ };
43
+ export declare const DEFAULT_POSE_RULES: PoseSyncRules;
44
+ export declare function createHostRouter(options: HostRouterOptions): HostRouter;
45
+ export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;