@jgengine/node 0.1.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/LICENSE +661 -0
- package/README.md +3 -0
- package/dist/host.d.ts +72 -0
- package/dist/host.js +336 -0
- package/dist/persistence.d.ts +4 -0
- package/dist/persistence.js +224 -0
- package/dist/wsServer.d.ts +22 -0
- package/dist/wsServer.js +248 -0
- package/package.json +39 -0
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
|
+
import type { HostPersistence, ServerListing } from "@jgengine/core/runtime/hostPersistence";
|
|
3
|
+
import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
|
|
4
|
+
export type HostChangeEvent = {
|
|
5
|
+
type: "server";
|
|
6
|
+
serverId: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: "player";
|
|
9
|
+
serverId: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: "feed";
|
|
13
|
+
serverId: string;
|
|
14
|
+
action: string;
|
|
15
|
+
};
|
|
16
|
+
export type GameHostOptions = {
|
|
17
|
+
runtimes?: GameRuntime[];
|
|
18
|
+
persistence: HostPersistence;
|
|
19
|
+
tickMs?: number;
|
|
20
|
+
slotsPerServer?: number;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
createServerId?: () => string;
|
|
23
|
+
};
|
|
24
|
+
export type GameHost = {
|
|
25
|
+
joinServer: (args: {
|
|
26
|
+
userId: string;
|
|
27
|
+
gameId: string;
|
|
28
|
+
serverId?: string;
|
|
29
|
+
}) => Promise<JoinServerResult>;
|
|
30
|
+
leaveServer: (args: {
|
|
31
|
+
userId: string;
|
|
32
|
+
serverId: string;
|
|
33
|
+
}) => Promise<void>;
|
|
34
|
+
runCommand: (args: {
|
|
35
|
+
userId: string;
|
|
36
|
+
serverId: string;
|
|
37
|
+
command: string;
|
|
38
|
+
input: unknown;
|
|
39
|
+
}) => Promise<TransportRunCommandResult>;
|
|
40
|
+
getServerView: (args: {
|
|
41
|
+
userId: string;
|
|
42
|
+
serverId: string;
|
|
43
|
+
}) => Promise<GameRuntimeServerView | null>;
|
|
44
|
+
getPlayerView: (args: {
|
|
45
|
+
userId: string;
|
|
46
|
+
serverId: string;
|
|
47
|
+
}) => Promise<GameRuntimePlayerView | null>;
|
|
48
|
+
getFeed: (args: {
|
|
49
|
+
userId: string;
|
|
50
|
+
serverId: string;
|
|
51
|
+
action: string;
|
|
52
|
+
}) => Promise<unknown[]>;
|
|
53
|
+
pushFeedEntry: (args: {
|
|
54
|
+
userId: string;
|
|
55
|
+
serverId: string;
|
|
56
|
+
action: string;
|
|
57
|
+
entry: unknown;
|
|
58
|
+
}) => Promise<void>;
|
|
59
|
+
listOpenServers: (args: {
|
|
60
|
+
gameId: string;
|
|
61
|
+
limit?: number;
|
|
62
|
+
}) => Promise<ServerListing[]>;
|
|
63
|
+
tickOnce: () => Promise<{
|
|
64
|
+
ticked: number;
|
|
65
|
+
saved: number;
|
|
66
|
+
}>;
|
|
67
|
+
flushAll: () => Promise<number>;
|
|
68
|
+
start: () => void;
|
|
69
|
+
stop: () => Promise<void>;
|
|
70
|
+
subscribe: (listener: (event: HostChangeEvent) => void) => () => void;
|
|
71
|
+
};
|
|
72
|
+
export declare function createGameHost(options: GameHostOptions): GameHost;
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
3
|
+
import { buildHydratePlayers, planServerPersist, shouldAutoSave, toServerListing, } from "@jgengine/core/runtime/hostPersistence";
|
|
4
|
+
import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
|
|
5
|
+
const builtinCommands = {
|
|
6
|
+
"engine.ping": {
|
|
7
|
+
validate: () => null,
|
|
8
|
+
apply: (snapshot) => snapshot,
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
export function createGameHost(options) {
|
|
12
|
+
const now = options.now ?? Date.now;
|
|
13
|
+
const tickMs = options.tickMs ?? 1_000;
|
|
14
|
+
const defaultSlots = options.slotsPerServer ?? 16;
|
|
15
|
+
const createServerId = options.createServerId ?? (() => `srv-${randomUUID()}`);
|
|
16
|
+
const persistence = options.persistence;
|
|
17
|
+
const runtimes = new Map();
|
|
18
|
+
for (const runtime of options.runtimes ?? []) {
|
|
19
|
+
runtimes.set(runtime.gameId, runtime);
|
|
20
|
+
}
|
|
21
|
+
const resolveRuntime = (gameId) => {
|
|
22
|
+
const existing = runtimes.get(gameId);
|
|
23
|
+
if (existing)
|
|
24
|
+
return existing;
|
|
25
|
+
const fallback = createGameRuntime({ gameId, save: "none", commands: builtinCommands });
|
|
26
|
+
runtimes.set(gameId, fallback);
|
|
27
|
+
return fallback;
|
|
28
|
+
};
|
|
29
|
+
const live = new Map();
|
|
30
|
+
const listeners = new Set();
|
|
31
|
+
const emit = (event) => {
|
|
32
|
+
for (const listener of listeners)
|
|
33
|
+
listener(event);
|
|
34
|
+
};
|
|
35
|
+
let queue = Promise.resolve();
|
|
36
|
+
const enqueue = (operation) => {
|
|
37
|
+
const run = queue.then(operation);
|
|
38
|
+
queue = run.catch(() => undefined);
|
|
39
|
+
return run;
|
|
40
|
+
};
|
|
41
|
+
const hydrate = async (record) => {
|
|
42
|
+
const runtime = resolveRuntime(record.gameId);
|
|
43
|
+
const profiles = {};
|
|
44
|
+
for (const userId of record.memberUserIds) {
|
|
45
|
+
profiles[userId] = await persistence.loadProfile({ userId, gameId: record.gameId });
|
|
46
|
+
}
|
|
47
|
+
const chunkRecords = await persistence.loadChunks(record.serverId);
|
|
48
|
+
const chunksByKey = {};
|
|
49
|
+
for (const chunk of chunkRecords) {
|
|
50
|
+
chunksByKey[chunk.chunkKey] = chunk.snapshot;
|
|
51
|
+
}
|
|
52
|
+
const snapshot = runtime.hydrate({
|
|
53
|
+
gameId: record.gameId,
|
|
54
|
+
serverId: record.serverId,
|
|
55
|
+
serverRow: record.serverState,
|
|
56
|
+
playersByUserId: buildHydratePlayers(record, profiles),
|
|
57
|
+
chunksByKey,
|
|
58
|
+
});
|
|
59
|
+
const existing = live.get(record.serverId);
|
|
60
|
+
if (existing)
|
|
61
|
+
return existing;
|
|
62
|
+
const entry = { record, snapshot };
|
|
63
|
+
live.set(record.serverId, entry);
|
|
64
|
+
return entry;
|
|
65
|
+
};
|
|
66
|
+
const getLive = async (serverId) => {
|
|
67
|
+
const existing = live.get(serverId);
|
|
68
|
+
if (existing)
|
|
69
|
+
return existing;
|
|
70
|
+
const record = await persistence.loadServer(serverId);
|
|
71
|
+
if (record === null)
|
|
72
|
+
return null;
|
|
73
|
+
return hydrate(record);
|
|
74
|
+
};
|
|
75
|
+
const flushServer = async (entry) => {
|
|
76
|
+
const timestamp = now();
|
|
77
|
+
const plan = planServerPersist(entry.record, entry.snapshot, entry.record.save, timestamp);
|
|
78
|
+
const cleared = { ...plan, server: { ...plan.server, dirtyAt: undefined } };
|
|
79
|
+
if (persistence.savePlan) {
|
|
80
|
+
await persistence.savePlan(cleared);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
if (cleared.leaderboard.length > 0) {
|
|
84
|
+
await persistence.applyLeaderboardIncrements(entry.record.gameId, cleared.leaderboard);
|
|
85
|
+
}
|
|
86
|
+
for (const profile of cleared.profiles) {
|
|
87
|
+
await persistence.saveProfile(profile);
|
|
88
|
+
}
|
|
89
|
+
if (cleared.chunks.length > 0) {
|
|
90
|
+
await persistence.saveChunks(entry.record.serverId, cleared.chunks);
|
|
91
|
+
}
|
|
92
|
+
await persistence.saveServer(cleared.server);
|
|
93
|
+
}
|
|
94
|
+
entry.record = cleared.server;
|
|
95
|
+
entry.snapshot = clearDirtyFlags({ ...entry.snapshot, server: cleared.server.serverState });
|
|
96
|
+
};
|
|
97
|
+
const markMutated = (entry) => {
|
|
98
|
+
const timestamp = now();
|
|
99
|
+
entry.record = {
|
|
100
|
+
...entry.record,
|
|
101
|
+
revision: entry.snapshot.revision,
|
|
102
|
+
dirtyAt: entry.record.dirtyAt ?? timestamp,
|
|
103
|
+
updatedAt: timestamp,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
let interval = null;
|
|
107
|
+
const tickOnce = () => enqueue(async () => {
|
|
108
|
+
const timestamp = now();
|
|
109
|
+
let ticked = 0;
|
|
110
|
+
let saved = 0;
|
|
111
|
+
for (const entry of live.values()) {
|
|
112
|
+
if (entry.record.status !== "running")
|
|
113
|
+
continue;
|
|
114
|
+
if (entry.record.memberUserIds.length === 0)
|
|
115
|
+
continue;
|
|
116
|
+
const elapsedMs = timestamp - entry.record.tickAnchorMs;
|
|
117
|
+
if (elapsedMs < tickMs)
|
|
118
|
+
continue;
|
|
119
|
+
const runtime = resolveRuntime(entry.record.gameId);
|
|
120
|
+
const before = entry.snapshot.revision;
|
|
121
|
+
entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
|
|
122
|
+
entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
|
|
123
|
+
ticked += 1;
|
|
124
|
+
if (entry.snapshot.revision !== before) {
|
|
125
|
+
markMutated(entry);
|
|
126
|
+
emit({ type: "server", serverId: entry.record.serverId });
|
|
127
|
+
}
|
|
128
|
+
if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
|
|
129
|
+
await flushServer(entry);
|
|
130
|
+
saved += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { ticked, saved };
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
joinServer: (args) => enqueue(async () => {
|
|
137
|
+
const timestamp = now();
|
|
138
|
+
const runtime = resolveRuntime(args.gameId);
|
|
139
|
+
let entry = args.serverId === undefined ? null : await getLive(args.serverId);
|
|
140
|
+
if (entry !== null && entry.record.gameId !== args.gameId) {
|
|
141
|
+
throw new Error("Server belongs to a different game");
|
|
142
|
+
}
|
|
143
|
+
if (entry === null) {
|
|
144
|
+
const record = {
|
|
145
|
+
serverId: createServerId(),
|
|
146
|
+
gameId: args.gameId,
|
|
147
|
+
status: "running",
|
|
148
|
+
memberUserIds: [],
|
|
149
|
+
slotsPerServer: defaultSlots,
|
|
150
|
+
save: runtime.save,
|
|
151
|
+
serverState: createEmptyServerRow(),
|
|
152
|
+
sessionPlayers: {},
|
|
153
|
+
revision: 0,
|
|
154
|
+
tickAnchorMs: timestamp,
|
|
155
|
+
createdAt: timestamp,
|
|
156
|
+
updatedAt: timestamp,
|
|
157
|
+
};
|
|
158
|
+
entry = await hydrate(record);
|
|
159
|
+
}
|
|
160
|
+
const { record } = entry;
|
|
161
|
+
if (record.memberUserIds.length >= record.slotsPerServer &&
|
|
162
|
+
!record.memberUserIds.includes(args.userId)) {
|
|
163
|
+
throw new Error("Server is full");
|
|
164
|
+
}
|
|
165
|
+
const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
|
|
166
|
+
const isNew = profile === null;
|
|
167
|
+
entry.record = {
|
|
168
|
+
...record,
|
|
169
|
+
memberUserIds: record.memberUserIds.includes(args.userId)
|
|
170
|
+
? record.memberUserIds
|
|
171
|
+
: [...record.memberUserIds, args.userId],
|
|
172
|
+
status: "running",
|
|
173
|
+
updatedAt: timestamp,
|
|
174
|
+
dirtyAt: timestamp,
|
|
175
|
+
};
|
|
176
|
+
entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
|
|
177
|
+
await flushServer(entry);
|
|
178
|
+
emit({ type: "server", serverId: entry.record.serverId });
|
|
179
|
+
emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
|
|
180
|
+
return { serverId: entry.record.serverId, isNew };
|
|
181
|
+
}),
|
|
182
|
+
leaveServer: (args) => enqueue(async () => {
|
|
183
|
+
const entry = await getLive(args.serverId);
|
|
184
|
+
if (entry === null)
|
|
185
|
+
return;
|
|
186
|
+
if (!entry.record.memberUserIds.includes(args.userId))
|
|
187
|
+
return;
|
|
188
|
+
await flushServer(entry);
|
|
189
|
+
const timestamp = now();
|
|
190
|
+
const memberUserIds = entry.record.memberUserIds.filter((id) => id !== args.userId);
|
|
191
|
+
const sessionPlayers = { ...entry.record.sessionPlayers };
|
|
192
|
+
delete sessionPlayers[args.userId];
|
|
193
|
+
const players = { ...entry.snapshot.players };
|
|
194
|
+
delete players[args.userId];
|
|
195
|
+
entry.record = {
|
|
196
|
+
...entry.record,
|
|
197
|
+
memberUserIds,
|
|
198
|
+
sessionPlayers,
|
|
199
|
+
updatedAt: timestamp,
|
|
200
|
+
status: memberUserIds.length === 0 ? "open" : entry.record.status,
|
|
201
|
+
};
|
|
202
|
+
entry.snapshot = { ...entry.snapshot, players };
|
|
203
|
+
await persistence.saveServer(entry.record);
|
|
204
|
+
if (memberUserIds.length === 0) {
|
|
205
|
+
live.delete(entry.record.serverId);
|
|
206
|
+
}
|
|
207
|
+
emit({ type: "server", serverId: args.serverId });
|
|
208
|
+
}),
|
|
209
|
+
runCommand: (args) => enqueue(async () => {
|
|
210
|
+
const entry = await getLive(args.serverId);
|
|
211
|
+
if (entry === null) {
|
|
212
|
+
return { ok: false, reason: "Server not found" };
|
|
213
|
+
}
|
|
214
|
+
if (!entry.record.memberUserIds.includes(args.userId)) {
|
|
215
|
+
return { ok: false, reason: "Not a member of this server" };
|
|
216
|
+
}
|
|
217
|
+
const runtime = resolveRuntime(entry.record.gameId);
|
|
218
|
+
const result = runtime.runCommand(entry.snapshot, args.userId, args.command, args.input);
|
|
219
|
+
if (!result.ok) {
|
|
220
|
+
return { ok: false, reason: result.reason };
|
|
221
|
+
}
|
|
222
|
+
entry.snapshot = result.snapshot;
|
|
223
|
+
markMutated(entry);
|
|
224
|
+
emit({ type: "server", serverId: args.serverId });
|
|
225
|
+
emit({ type: "player", serverId: args.serverId, userId: args.userId });
|
|
226
|
+
return { ok: true };
|
|
227
|
+
}),
|
|
228
|
+
getServerView: async (args) => {
|
|
229
|
+
const entry = await getLive(args.serverId);
|
|
230
|
+
if (entry === null)
|
|
231
|
+
return null;
|
|
232
|
+
if (!entry.record.memberUserIds.includes(args.userId))
|
|
233
|
+
return null;
|
|
234
|
+
return {
|
|
235
|
+
serverId: entry.record.serverId,
|
|
236
|
+
gameId: entry.record.gameId,
|
|
237
|
+
revision: entry.snapshot.revision,
|
|
238
|
+
memberUserIds: entry.record.memberUserIds,
|
|
239
|
+
serverState: entry.snapshot.server,
|
|
240
|
+
updatedAt: entry.record.updatedAt,
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
getPlayerView: async (args) => {
|
|
244
|
+
const entry = await getLive(args.serverId);
|
|
245
|
+
if (entry === null)
|
|
246
|
+
return null;
|
|
247
|
+
if (!entry.record.memberUserIds.includes(args.userId))
|
|
248
|
+
return null;
|
|
249
|
+
const player = entry.snapshot.players[args.userId];
|
|
250
|
+
if (player !== undefined) {
|
|
251
|
+
return {
|
|
252
|
+
userId: args.userId,
|
|
253
|
+
gameId: entry.record.gameId,
|
|
254
|
+
playerState: splitProfilePlayer(player).persistent,
|
|
255
|
+
updatedAt: entry.record.updatedAt,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const profile = await persistence.loadProfile({ userId: args.userId, gameId: entry.record.gameId });
|
|
259
|
+
if (profile === null)
|
|
260
|
+
return null;
|
|
261
|
+
return {
|
|
262
|
+
userId: profile.userId,
|
|
263
|
+
gameId: profile.gameId,
|
|
264
|
+
playerState: profile.playerState,
|
|
265
|
+
updatedAt: profile.updatedAt,
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
getFeed: async (args) => {
|
|
269
|
+
const entry = await getLive(args.serverId);
|
|
270
|
+
if (entry === null)
|
|
271
|
+
return [];
|
|
272
|
+
if (!entry.record.memberUserIds.includes(args.userId))
|
|
273
|
+
return [];
|
|
274
|
+
return persistence.loadFeed({ serverId: args.serverId, action: args.action });
|
|
275
|
+
},
|
|
276
|
+
pushFeedEntry: (args) => enqueue(async () => {
|
|
277
|
+
const entry = await getLive(args.serverId);
|
|
278
|
+
if (entry === null || !entry.record.memberUserIds.includes(args.userId)) {
|
|
279
|
+
throw new Error("Not a member of this server");
|
|
280
|
+
}
|
|
281
|
+
await persistence.appendFeed({ serverId: args.serverId, action: args.action, entry: args.entry });
|
|
282
|
+
emit({ type: "feed", serverId: args.serverId, action: args.action });
|
|
283
|
+
}),
|
|
284
|
+
listOpenServers: async (args) => {
|
|
285
|
+
const limit = args.limit ?? 20;
|
|
286
|
+
const byId = new Map();
|
|
287
|
+
for (const record of await persistence.listServers(args.gameId)) {
|
|
288
|
+
byId.set(record.serverId, toServerListing(record));
|
|
289
|
+
}
|
|
290
|
+
for (const entry of live.values()) {
|
|
291
|
+
if (entry.record.gameId !== args.gameId)
|
|
292
|
+
continue;
|
|
293
|
+
byId.set(entry.record.serverId, toServerListing(entry.record));
|
|
294
|
+
}
|
|
295
|
+
return [...byId.values()]
|
|
296
|
+
.filter((listing) => listing.status === "running")
|
|
297
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
298
|
+
.slice(0, limit);
|
|
299
|
+
},
|
|
300
|
+
tickOnce,
|
|
301
|
+
flushAll: () => enqueue(async () => {
|
|
302
|
+
let saved = 0;
|
|
303
|
+
for (const entry of live.values()) {
|
|
304
|
+
if (entry.record.dirtyAt === undefined)
|
|
305
|
+
continue;
|
|
306
|
+
await flushServer(entry);
|
|
307
|
+
saved += 1;
|
|
308
|
+
}
|
|
309
|
+
return saved;
|
|
310
|
+
}),
|
|
311
|
+
start: () => {
|
|
312
|
+
if (interval !== null)
|
|
313
|
+
return;
|
|
314
|
+
interval = setInterval(() => {
|
|
315
|
+
void tickOnce();
|
|
316
|
+
}, tickMs);
|
|
317
|
+
},
|
|
318
|
+
stop: async () => {
|
|
319
|
+
if (interval !== null) {
|
|
320
|
+
clearInterval(interval);
|
|
321
|
+
interval = null;
|
|
322
|
+
}
|
|
323
|
+
await enqueue(async () => {
|
|
324
|
+
for (const entry of live.values()) {
|
|
325
|
+
if (entry.record.dirtyAt === undefined)
|
|
326
|
+
continue;
|
|
327
|
+
await flushServer(entry);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
subscribe: (listener) => {
|
|
332
|
+
listeners.add(listener);
|
|
333
|
+
return () => listeners.delete(listener);
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { HostPersistence } from "@jgengine/core/runtime/hostPersistence";
|
|
2
|
+
export declare function memoryPersistence(now?: () => number): HostPersistence;
|
|
3
|
+
export declare function filePersistence(dir: string, now?: () => number): HostPersistence;
|
|
4
|
+
export declare function clearFilePersistence(dir: string): Promise<void>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
|
|
4
|
+
const MAX_TOP_LIMIT = 100;
|
|
5
|
+
function leaderboardRowKey(row) {
|
|
6
|
+
return [row.gameId, row.scope, row.stat, row.serverId ?? "", row.userId].join("|");
|
|
7
|
+
}
|
|
8
|
+
function applyIncrements(rows, gameId, entries, now) {
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const key = leaderboardRowKey({ ...entry, gameId });
|
|
11
|
+
const existing = rows.get(key);
|
|
12
|
+
if (existing) {
|
|
13
|
+
rows.set(key, { ...existing, value: existing.value + entry.by, updatedAt: now });
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
rows.set(key, {
|
|
17
|
+
gameId,
|
|
18
|
+
stat: entry.stat,
|
|
19
|
+
scope: entry.scope,
|
|
20
|
+
serverId: entry.serverId,
|
|
21
|
+
userId: entry.userId,
|
|
22
|
+
value: entry.by,
|
|
23
|
+
updatedAt: now,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function topRows(rows, args) {
|
|
29
|
+
const limit = Math.min(Math.max(args.limit ?? MAX_TOP_LIMIT, 1), MAX_TOP_LIMIT);
|
|
30
|
+
return [...rows]
|
|
31
|
+
.filter((row) => row.gameId === args.gameId &&
|
|
32
|
+
row.stat === args.stat &&
|
|
33
|
+
row.scope === args.scope &&
|
|
34
|
+
(args.serverId === undefined || row.serverId === args.serverId))
|
|
35
|
+
.sort((a, b) => b.value - a.value)
|
|
36
|
+
.slice(0, limit)
|
|
37
|
+
.map((row) => ({ userId: row.userId, value: row.value }));
|
|
38
|
+
}
|
|
39
|
+
function profileStats(rows, gameId, userId) {
|
|
40
|
+
const profile = {};
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
if (row.gameId === gameId && row.userId === userId && row.scope === "profile") {
|
|
43
|
+
profile[row.stat] = row.value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return profile;
|
|
47
|
+
}
|
|
48
|
+
export function memoryPersistence(now = Date.now) {
|
|
49
|
+
const servers = new Map();
|
|
50
|
+
const profiles = new Map();
|
|
51
|
+
const chunks = new Map();
|
|
52
|
+
const feeds = new Map();
|
|
53
|
+
const leaderboard = new Map();
|
|
54
|
+
const clone = (value) => structuredClone(value);
|
|
55
|
+
return {
|
|
56
|
+
async loadServer(serverId) {
|
|
57
|
+
const record = servers.get(serverId);
|
|
58
|
+
return record === undefined ? null : clone(record);
|
|
59
|
+
},
|
|
60
|
+
async saveServer(record) {
|
|
61
|
+
servers.set(record.serverId, clone(record));
|
|
62
|
+
},
|
|
63
|
+
async listServers(gameId) {
|
|
64
|
+
return [...servers.values()].filter((record) => record.gameId === gameId).map(clone);
|
|
65
|
+
},
|
|
66
|
+
async loadProfile({ userId, gameId }) {
|
|
67
|
+
const record = profiles.get(`${gameId}|${userId}`);
|
|
68
|
+
return record === undefined ? null : clone(record);
|
|
69
|
+
},
|
|
70
|
+
async saveProfile(record) {
|
|
71
|
+
profiles.set(`${record.gameId}|${record.userId}`, clone(record));
|
|
72
|
+
},
|
|
73
|
+
async loadChunks(serverId) {
|
|
74
|
+
return [...(chunks.get(serverId)?.values() ?? [])].map(clone);
|
|
75
|
+
},
|
|
76
|
+
async saveChunks(serverId, records) {
|
|
77
|
+
const byKey = chunks.get(serverId) ?? new Map();
|
|
78
|
+
for (const record of records) {
|
|
79
|
+
byKey.set(record.chunkKey, clone(record));
|
|
80
|
+
}
|
|
81
|
+
chunks.set(serverId, byKey);
|
|
82
|
+
},
|
|
83
|
+
async loadFeed({ serverId, action }) {
|
|
84
|
+
return clone(feeds.get(`${serverId}|${action}`) ?? []);
|
|
85
|
+
},
|
|
86
|
+
async appendFeed({ serverId, action, entry }) {
|
|
87
|
+
const key = `${serverId}|${action}`;
|
|
88
|
+
const entries = trimFeedEntries([...(feeds.get(key) ?? []), clone(entry)]);
|
|
89
|
+
feeds.set(key, entries);
|
|
90
|
+
return clone(entries);
|
|
91
|
+
},
|
|
92
|
+
async applyLeaderboardIncrements(gameId, entries) {
|
|
93
|
+
applyIncrements(leaderboard, gameId, entries, now());
|
|
94
|
+
},
|
|
95
|
+
async getLeaderboardTop(args) {
|
|
96
|
+
return topRows(leaderboard.values(), args);
|
|
97
|
+
},
|
|
98
|
+
async getLeaderboardProfile({ gameId, userId }) {
|
|
99
|
+
return profileStats(leaderboard.values(), gameId, userId);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function readJson(path) {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function writeJson(path, value) {
|
|
112
|
+
const temp = `${path}.tmp`;
|
|
113
|
+
await writeFile(temp, JSON.stringify(value), "utf8");
|
|
114
|
+
await rename(temp, path);
|
|
115
|
+
}
|
|
116
|
+
const enc = encodeURIComponent;
|
|
117
|
+
export function filePersistence(dir, now = Date.now) {
|
|
118
|
+
const serversDir = join(dir, "servers");
|
|
119
|
+
const profilesDir = join(dir, "profiles");
|
|
120
|
+
const chunksDir = join(dir, "chunks");
|
|
121
|
+
const feedsDir = join(dir, "feeds");
|
|
122
|
+
const leaderboardPath = join(dir, "leaderboard.json");
|
|
123
|
+
let ready = null;
|
|
124
|
+
const ensureDirs = () => {
|
|
125
|
+
ready ??= Promise.all([serversDir, profilesDir, chunksDir, feedsDir].map((path) => mkdir(path, { recursive: true }))).then(() => undefined);
|
|
126
|
+
return ready;
|
|
127
|
+
};
|
|
128
|
+
const loadLeaderboard = async () => {
|
|
129
|
+
const rows = (await readJson(leaderboardPath)) ?? [];
|
|
130
|
+
return new Map(rows.map((row) => [leaderboardRowKey(row), row]));
|
|
131
|
+
};
|
|
132
|
+
let leaderboardLock = Promise.resolve();
|
|
133
|
+
return {
|
|
134
|
+
async loadServer(serverId) {
|
|
135
|
+
await ensureDirs();
|
|
136
|
+
return readJson(join(serversDir, `${enc(serverId)}.json`));
|
|
137
|
+
},
|
|
138
|
+
async saveServer(record) {
|
|
139
|
+
await ensureDirs();
|
|
140
|
+
await writeJson(join(serversDir, `${enc(record.serverId)}.json`), record);
|
|
141
|
+
},
|
|
142
|
+
async listServers(gameId) {
|
|
143
|
+
await ensureDirs();
|
|
144
|
+
const files = await readdir(serversDir);
|
|
145
|
+
const records = [];
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
if (!file.endsWith(".json"))
|
|
148
|
+
continue;
|
|
149
|
+
const record = await readJson(join(serversDir, file));
|
|
150
|
+
if (record !== null && record.gameId === gameId)
|
|
151
|
+
records.push(record);
|
|
152
|
+
}
|
|
153
|
+
return records;
|
|
154
|
+
},
|
|
155
|
+
async loadProfile({ userId, gameId }) {
|
|
156
|
+
await ensureDirs();
|
|
157
|
+
return readJson(join(profilesDir, `${enc(gameId)}__${enc(userId)}.json`));
|
|
158
|
+
},
|
|
159
|
+
async saveProfile(record) {
|
|
160
|
+
await ensureDirs();
|
|
161
|
+
await writeJson(join(profilesDir, `${enc(record.gameId)}__${enc(record.userId)}.json`), record);
|
|
162
|
+
},
|
|
163
|
+
async loadChunks(serverId) {
|
|
164
|
+
await ensureDirs();
|
|
165
|
+
const serverDir = join(chunksDir, enc(serverId));
|
|
166
|
+
let files;
|
|
167
|
+
try {
|
|
168
|
+
files = await readdir(serverDir);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const records = [];
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
if (!file.endsWith(".json"))
|
|
176
|
+
continue;
|
|
177
|
+
const record = await readJson(join(serverDir, file));
|
|
178
|
+
if (record !== null)
|
|
179
|
+
records.push(record);
|
|
180
|
+
}
|
|
181
|
+
return records;
|
|
182
|
+
},
|
|
183
|
+
async saveChunks(serverId, records) {
|
|
184
|
+
await ensureDirs();
|
|
185
|
+
const serverDir = join(chunksDir, enc(serverId));
|
|
186
|
+
await mkdir(serverDir, { recursive: true });
|
|
187
|
+
for (const record of records) {
|
|
188
|
+
await writeJson(join(serverDir, `${enc(record.chunkKey)}.json`), record);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
async loadFeed({ serverId, action }) {
|
|
192
|
+
await ensureDirs();
|
|
193
|
+
return (await readJson(join(feedsDir, `${enc(serverId)}__${enc(action)}.json`))) ?? [];
|
|
194
|
+
},
|
|
195
|
+
async appendFeed({ serverId, action, entry }) {
|
|
196
|
+
await ensureDirs();
|
|
197
|
+
const path = join(feedsDir, `${enc(serverId)}__${enc(action)}.json`);
|
|
198
|
+
const entries = trimFeedEntries([...((await readJson(path)) ?? []), entry]);
|
|
199
|
+
await writeJson(path, entries);
|
|
200
|
+
return entries;
|
|
201
|
+
},
|
|
202
|
+
async applyLeaderboardIncrements(gameId, entries) {
|
|
203
|
+
const run = leaderboardLock.then(async () => {
|
|
204
|
+
await ensureDirs();
|
|
205
|
+
const rows = await loadLeaderboard();
|
|
206
|
+
applyIncrements(rows, gameId, entries, now());
|
|
207
|
+
await writeJson(leaderboardPath, [...rows.values()]);
|
|
208
|
+
});
|
|
209
|
+
leaderboardLock = run.catch(() => undefined);
|
|
210
|
+
await run;
|
|
211
|
+
},
|
|
212
|
+
async getLeaderboardTop(args) {
|
|
213
|
+
await ensureDirs();
|
|
214
|
+
return topRows((await loadLeaderboard()).values(), args);
|
|
215
|
+
},
|
|
216
|
+
async getLeaderboardProfile({ gameId, userId }) {
|
|
217
|
+
await ensureDirs();
|
|
218
|
+
return profileStats((await loadLeaderboard()).values(), gameId, userId);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export async function clearFilePersistence(dir) {
|
|
223
|
+
await rm(dir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Server as HttpServer } from "node:http";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import type { PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
|
|
4
|
+
import type { GameHost } from "./host.js";
|
|
5
|
+
export type GameWsServerOptions = {
|
|
6
|
+
host: GameHost;
|
|
7
|
+
server?: HttpServer;
|
|
8
|
+
port?: number;
|
|
9
|
+
path?: string;
|
|
10
|
+
authenticate?: (args: {
|
|
11
|
+
userId: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
}) => Promise<string | null> | string | null;
|
|
14
|
+
poseRules?: PoseSyncRules;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
};
|
|
17
|
+
export type GameWsServer = {
|
|
18
|
+
wss: WebSocketServer;
|
|
19
|
+
port: () => number;
|
|
20
|
+
close: () => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
export declare function createGameWsServer(options: GameWsServerOptions): GameWsServer;
|