@jgengine/node 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/CHANGELOG.md +63 -0
- package/dist/host.d.ts +1 -84
- package/dist/host.js +1 -375
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/persistence.d.ts +1 -1
- package/dist/persistence.js +49 -115
- package/dist/socketIoServer.d.ts +21 -0
- package/dist/socketIoServer.js +16 -0
- package/dist/webHandler.d.ts +5 -0
- package/dist/webHandler.js +40 -0
- package/dist/wsServer.d.ts +3 -17
- package/dist/wsServer.js +15 -262
- package/llms.txt +907 -0
- package/package.json +9 -4
package/dist/persistence.js
CHANGED
|
@@ -1,117 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
|
-
import { trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
|
|
4
|
-
|
|
5
|
-
function
|
|
6
|
-
return
|
|
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
|
-
};
|
|
4
|
+
import { applyLeaderboardRows, leaderboardRowKey, profileLeaderboardStats, topLeaderboardRows, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
5
|
+
export { memoryPersistence } from "@jgengine/ws/host";
|
|
6
|
+
function isMissing(error) {
|
|
7
|
+
return error?.code === "ENOENT";
|
|
102
8
|
}
|
|
103
9
|
async function readJson(path) {
|
|
10
|
+
let text;
|
|
104
11
|
try {
|
|
105
|
-
|
|
12
|
+
text = await readFile(path, "utf8");
|
|
106
13
|
}
|
|
107
|
-
catch {
|
|
108
|
-
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (isMissing(error))
|
|
16
|
+
return null;
|
|
17
|
+
throw error;
|
|
109
18
|
}
|
|
19
|
+
return JSON.parse(text);
|
|
110
20
|
}
|
|
111
|
-
async function
|
|
112
|
-
const temp = `${path}.tmp`;
|
|
21
|
+
async function stageJson(path, value) {
|
|
22
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
113
23
|
await writeFile(temp, JSON.stringify(value), "utf8");
|
|
114
|
-
|
|
24
|
+
return temp;
|
|
25
|
+
}
|
|
26
|
+
async function writeJson(path, value) {
|
|
27
|
+
await rename(await stageJson(path, value), path);
|
|
115
28
|
}
|
|
116
29
|
const enc = encodeURIComponent;
|
|
117
30
|
export function filePersistence(dir, now = Date.now) {
|
|
@@ -130,6 +43,7 @@ export function filePersistence(dir, now = Date.now) {
|
|
|
130
43
|
return new Map(rows.map((row) => [leaderboardRowKey(row), row]));
|
|
131
44
|
};
|
|
132
45
|
let leaderboardLock = Promise.resolve();
|
|
46
|
+
const feedLocks = new Map();
|
|
133
47
|
return {
|
|
134
48
|
async loadServer(serverId) {
|
|
135
49
|
await ensureDirs();
|
|
@@ -184,9 +98,24 @@ export function filePersistence(dir, now = Date.now) {
|
|
|
184
98
|
await ensureDirs();
|
|
185
99
|
const serverDir = join(chunksDir, enc(serverId));
|
|
186
100
|
await mkdir(serverDir, { recursive: true });
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
101
|
+
const staged = await Promise.all(records.map(async (record) => {
|
|
102
|
+
const final = join(serverDir, `${enc(record.chunkKey)}.json`);
|
|
103
|
+
return { temp: await stageJson(final, record), final };
|
|
104
|
+
}));
|
|
105
|
+
await Promise.all(staged.map(({ temp, final }) => rename(temp, final)));
|
|
106
|
+
},
|
|
107
|
+
async deleteChunks(serverId, chunkKeys) {
|
|
108
|
+
await ensureDirs();
|
|
109
|
+
const serverDir = join(chunksDir, enc(serverId));
|
|
110
|
+
await Promise.all(chunkKeys.map(async (chunkKey) => {
|
|
111
|
+
try {
|
|
112
|
+
await unlink(join(serverDir, `${enc(chunkKey)}.json`));
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (!isMissing(error))
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}));
|
|
190
119
|
},
|
|
191
120
|
async loadFeed({ serverId, action }) {
|
|
192
121
|
await ensureDirs();
|
|
@@ -195,15 +124,20 @@ export function filePersistence(dir, now = Date.now) {
|
|
|
195
124
|
async appendFeed({ serverId, action, entry }) {
|
|
196
125
|
await ensureDirs();
|
|
197
126
|
const path = join(feedsDir, `${enc(serverId)}__${enc(action)}.json`);
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
127
|
+
const prev = feedLocks.get(path) ?? Promise.resolve();
|
|
128
|
+
const run = prev.then(async () => {
|
|
129
|
+
const entries = trimFeedEntries([...((await readJson(path)) ?? []), entry]);
|
|
130
|
+
await writeJson(path, entries);
|
|
131
|
+
return entries;
|
|
132
|
+
});
|
|
133
|
+
feedLocks.set(path, run.catch(() => undefined));
|
|
134
|
+
return run;
|
|
201
135
|
},
|
|
202
136
|
async applyLeaderboardIncrements(gameId, entries) {
|
|
203
137
|
const run = leaderboardLock.then(async () => {
|
|
204
138
|
await ensureDirs();
|
|
205
139
|
const rows = await loadLeaderboard();
|
|
206
|
-
|
|
140
|
+
applyLeaderboardRows(rows, gameId, entries, now());
|
|
207
141
|
await writeJson(leaderboardPath, [...rows.values()]);
|
|
208
142
|
});
|
|
209
143
|
leaderboardLock = run.catch(() => undefined);
|
|
@@ -211,11 +145,11 @@ export function filePersistence(dir, now = Date.now) {
|
|
|
211
145
|
},
|
|
212
146
|
async getLeaderboardTop(args) {
|
|
213
147
|
await ensureDirs();
|
|
214
|
-
return
|
|
148
|
+
return topLeaderboardRows((await loadLeaderboard()).values(), args);
|
|
215
149
|
},
|
|
216
150
|
async getLeaderboardProfile({ gameId, userId }) {
|
|
217
151
|
await ensureDirs();
|
|
218
|
-
return
|
|
152
|
+
return profileLeaderboardStats((await loadLeaderboard()).values(), gameId, userId);
|
|
219
153
|
},
|
|
220
154
|
};
|
|
221
155
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type HostRouterOptions, type RewoundPosition } from "@jgengine/ws/hostRouter";
|
|
2
|
+
export type { RewoundPosition };
|
|
3
|
+
export type SocketIoLikeServerSocket = {
|
|
4
|
+
on: (event: string, listener: (payload: string) => void) => unknown;
|
|
5
|
+
send: (data: string) => unknown;
|
|
6
|
+
disconnect: (close?: boolean) => unknown;
|
|
7
|
+
};
|
|
8
|
+
export type SocketIoLikeServer = {
|
|
9
|
+
on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown;
|
|
10
|
+
};
|
|
11
|
+
export type GameSocketIoServerOptions = HostRouterOptions & {
|
|
12
|
+
io: SocketIoLikeServer;
|
|
13
|
+
};
|
|
14
|
+
export type GameSocketIoServer = {
|
|
15
|
+
rewind: (args: {
|
|
16
|
+
serverId: string;
|
|
17
|
+
atMs: number;
|
|
18
|
+
}) => RewoundPosition[];
|
|
19
|
+
close: () => void;
|
|
20
|
+
};
|
|
21
|
+
export declare function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHostRouter } from "@jgengine/ws/hostRouter";
|
|
2
|
+
export function attachGameSocketIoServer(options) {
|
|
3
|
+
const router = createHostRouter(options);
|
|
4
|
+
options.io.on("connection", (socket) => {
|
|
5
|
+
const connection = router.connect({
|
|
6
|
+
send: (data) => socket.send(data),
|
|
7
|
+
close: () => socket.disconnect(true),
|
|
8
|
+
});
|
|
9
|
+
socket.on("message", (raw) => connection.handleRaw(raw));
|
|
10
|
+
socket.on("disconnect", () => connection.close());
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
rewind: router.rewind,
|
|
14
|
+
close: () => router.close(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export type WebHandler = (request: Request) => Promise<Response>;
|
|
3
|
+
export type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void;
|
|
4
|
+
export declare function toWebRequest(req: IncomingMessage): Promise<Request>;
|
|
5
|
+
export declare function toNodeHandler(handler: WebHandler): NodeHandler;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export async function toWebRequest(req) {
|
|
2
|
+
const host = req.headers.host ?? "localhost";
|
|
3
|
+
const headers = new Headers();
|
|
4
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
5
|
+
if (typeof value === "string")
|
|
6
|
+
headers.set(key, value);
|
|
7
|
+
else if (Array.isArray(value))
|
|
8
|
+
for (const entry of value)
|
|
9
|
+
headers.append(key, entry);
|
|
10
|
+
}
|
|
11
|
+
const method = req.method ?? "GET";
|
|
12
|
+
let body;
|
|
13
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
for await (const chunk of req)
|
|
16
|
+
chunks.push(chunk);
|
|
17
|
+
if (chunks.length > 0) {
|
|
18
|
+
const buffer = Buffer.concat(chunks);
|
|
19
|
+
if (buffer.byteLength > 0)
|
|
20
|
+
body = Uint8Array.from(buffer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return new Request(`http://${host}${req.url ?? "/"}`, { method, headers, body });
|
|
24
|
+
}
|
|
25
|
+
export function toNodeHandler(handler) {
|
|
26
|
+
return (req, res) => {
|
|
27
|
+
void toWebRequest(req)
|
|
28
|
+
.then(handler)
|
|
29
|
+
.then(async (response) => {
|
|
30
|
+
res.statusCode = response.status;
|
|
31
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
32
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
33
|
+
})
|
|
34
|
+
.catch((error) => {
|
|
35
|
+
res.statusCode = 500;
|
|
36
|
+
res.setHeader("content-type", "application/json");
|
|
37
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : "internal error" }));
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/wsServer.d.ts
CHANGED
|
@@ -1,25 +1,11 @@
|
|
|
1
1
|
import type { Server as HttpServer } from "node:http";
|
|
2
2
|
import { WebSocketServer } from "ws";
|
|
3
|
-
import type
|
|
4
|
-
|
|
5
|
-
export type GameWsServerOptions = {
|
|
6
|
-
host: GameHost;
|
|
3
|
+
import { type HostRouterOptions, type RewoundPosition } from "@jgengine/ws/hostRouter";
|
|
4
|
+
export type { RewoundPosition };
|
|
5
|
+
export type GameWsServerOptions = HostRouterOptions & {
|
|
7
6
|
server?: HttpServer;
|
|
8
7
|
port?: number;
|
|
9
8
|
path?: string;
|
|
10
|
-
authenticate?: (args: {
|
|
11
|
-
userId: string;
|
|
12
|
-
token?: string;
|
|
13
|
-
}) => Promise<string | null> | string | null;
|
|
14
|
-
poseRules?: PoseSyncRules;
|
|
15
|
-
positionHistoryMs?: number;
|
|
16
|
-
now?: () => number;
|
|
17
|
-
};
|
|
18
|
-
export type RewoundPosition = {
|
|
19
|
-
userId: string;
|
|
20
|
-
x: number;
|
|
21
|
-
y: number;
|
|
22
|
-
z: number;
|
|
23
9
|
};
|
|
24
10
|
export type GameWsServer = {
|
|
25
11
|
wss: WebSocketServer;
|
package/dist/wsServer.js
CHANGED
|
@@ -1,256 +1,21 @@
|
|
|
1
1
|
import { WebSocketServer } from "ws";
|
|
2
|
-
import {
|
|
3
|
-
import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
|
|
4
|
-
import { decodeWsClientMessage, encodeWsMessage, subscriptionKey, } from "@jgengine/ws/protocol";
|
|
5
|
-
const DEFAULT_POSE_RULES = {
|
|
6
|
-
maxSpeed: 12,
|
|
7
|
-
maxVerticalOffset: 3,
|
|
8
|
-
minElapsedSec: 0.05,
|
|
9
|
-
maxElapsedSec: 0.5,
|
|
10
|
-
keepAliveRefreshMs: 10_000,
|
|
11
|
-
};
|
|
2
|
+
import { createHostRouter } from "@jgengine/ws/hostRouter";
|
|
12
3
|
export function createGameWsServer(options) {
|
|
13
|
-
const
|
|
14
|
-
const now = options.now ?? Date.now;
|
|
15
|
-
const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
|
|
16
|
-
const authenticate = options.authenticate ?? (({ userId }) => userId);
|
|
4
|
+
const router = createHostRouter(options);
|
|
17
5
|
const wss = options.server !== undefined
|
|
18
6
|
? new WebSocketServer({ server: options.server, path: options.path })
|
|
19
7
|
: new WebSocketServer({ port: options.port ?? 0, path: options.path });
|
|
20
|
-
const connections = new Set();
|
|
21
|
-
const presence = new Map();
|
|
22
|
-
const positionHistoryMs = options.positionHistoryMs ?? 1_000;
|
|
23
|
-
const histories = new Map();
|
|
24
|
-
const historyFor = (serverId) => {
|
|
25
|
-
let history = histories.get(serverId);
|
|
26
|
-
if (history === undefined) {
|
|
27
|
-
history = createPositionHistory({ historyMs: positionHistoryMs });
|
|
28
|
-
histories.set(serverId, history);
|
|
29
|
-
}
|
|
30
|
-
return history;
|
|
31
|
-
};
|
|
32
|
-
const send = (connection, message) => {
|
|
33
|
-
if (connection.socket.readyState !== connection.socket.OPEN)
|
|
34
|
-
return;
|
|
35
|
-
connection.socket.send(encodeWsMessage(message));
|
|
36
|
-
};
|
|
37
|
-
const reply = (connection, id, result) => {
|
|
38
|
-
send(connection, { v: 1, t: "reply", id, ok: true, result });
|
|
39
|
-
};
|
|
40
|
-
const replyError = (connection, id, reason) => {
|
|
41
|
-
send(connection, { v: 1, t: "reply", id, ok: false, reason });
|
|
42
|
-
};
|
|
43
|
-
const presenceRows = (serverId) => {
|
|
44
|
-
const rows = presence.get(serverId);
|
|
45
|
-
if (rows === undefined)
|
|
46
|
-
return [];
|
|
47
|
-
return [...rows.entries()].map(([userId, pose]) => ({
|
|
48
|
-
userId,
|
|
49
|
-
position: pose.position,
|
|
50
|
-
rotationY: pose.rotationY,
|
|
51
|
-
rotationPitch: pose.rotationPitch ?? 0,
|
|
52
|
-
lastSeenAt: pose.lastSeenAtMs ?? 0,
|
|
53
|
-
}));
|
|
54
|
-
};
|
|
55
|
-
const broadcastPresence = (serverId) => {
|
|
56
|
-
const key = subscriptionKey("presence", serverId);
|
|
57
|
-
const data = presenceRows(serverId);
|
|
58
|
-
for (const connection of connections) {
|
|
59
|
-
if (!connection.subscriptions.has(key))
|
|
60
|
-
continue;
|
|
61
|
-
send(connection, { v: 1, t: "update", channel: "presence", serverId, data });
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
const pushSubscription = async (connection, channel, serverId, action) => {
|
|
65
|
-
if (connection.userId === null)
|
|
66
|
-
return;
|
|
67
|
-
if (channel === "server") {
|
|
68
|
-
const data = await host.getServerView({ userId: connection.userId, serverId });
|
|
69
|
-
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
70
|
-
}
|
|
71
|
-
else if (channel === "player") {
|
|
72
|
-
const data = await host.getPlayerView({ userId: connection.userId, serverId });
|
|
73
|
-
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
74
|
-
}
|
|
75
|
-
else if (channel === "feed") {
|
|
76
|
-
const data = await host.getFeed({ userId: connection.userId, serverId, action: action ?? "" });
|
|
77
|
-
send(connection, { v: 1, t: "update", channel, serverId, action: action ?? "", data });
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
send(connection, { v: 1, t: "update", channel, serverId, data: presenceRows(serverId) });
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
const onHostEvent = (event) => {
|
|
84
|
-
for (const connection of connections) {
|
|
85
|
-
if (connection.userId === null)
|
|
86
|
-
continue;
|
|
87
|
-
if (event.type === "server") {
|
|
88
|
-
if (connection.subscriptions.has(subscriptionKey("server", event.serverId))) {
|
|
89
|
-
void pushSubscription(connection, "server", event.serverId);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
else if (event.type === "player") {
|
|
93
|
-
if (connection.userId === event.userId &&
|
|
94
|
-
connection.subscriptions.has(subscriptionKey("player", event.serverId))) {
|
|
95
|
-
void pushSubscription(connection, "player", event.serverId);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
if (connection.subscriptions.has(subscriptionKey("feed", event.serverId, event.action))) {
|
|
100
|
-
void pushSubscription(connection, "feed", event.serverId, event.action);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
const unsubscribeHost = host.subscribe(onHostEvent);
|
|
106
|
-
const handlePose = (connection, serverId, pose) => {
|
|
107
|
-
if (connection.userId === null)
|
|
108
|
-
return;
|
|
109
|
-
const rows = presence.get(serverId) ?? new Map();
|
|
110
|
-
presence.set(serverId, rows);
|
|
111
|
-
const timestamp = now();
|
|
112
|
-
const current = rows.get(connection.userId);
|
|
113
|
-
if (current === undefined) {
|
|
114
|
-
rows.set(connection.userId, {
|
|
115
|
-
position: { x: pose.x, y: pose.y, z: pose.z },
|
|
116
|
-
rotationY: pose.rotationY,
|
|
117
|
-
rotationPitch: pose.rotationPitch,
|
|
118
|
-
lastSeenAtMs: timestamp,
|
|
119
|
-
});
|
|
120
|
-
historyFor(serverId).record(connection.userId, timestamp, { x: pose.x, y: pose.y, z: pose.z });
|
|
121
|
-
broadcastPresence(serverId);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
const decision = decidePoseSync(current, { position: { x: pose.x, y: pose.y, z: pose.z }, rotationY: pose.rotationY, rotationPitch: pose.rotationPitch }, poseRules, timestamp);
|
|
125
|
-
if (decision.changed || decision.refreshKeepAlive) {
|
|
126
|
-
rows.set(connection.userId, {
|
|
127
|
-
position: decision.position,
|
|
128
|
-
rotationY: decision.rotationY,
|
|
129
|
-
rotationPitch: decision.rotationPitch,
|
|
130
|
-
lastSeenAtMs: timestamp,
|
|
131
|
-
});
|
|
132
|
-
historyFor(serverId).record(connection.userId, timestamp, decision.position);
|
|
133
|
-
}
|
|
134
|
-
if (decision.changed)
|
|
135
|
-
broadcastPresence(serverId);
|
|
136
|
-
};
|
|
137
|
-
const dropPresence = (connection) => {
|
|
138
|
-
if (connection.userId === null)
|
|
139
|
-
return;
|
|
140
|
-
for (const [serverId, rows] of presence) {
|
|
141
|
-
if (rows.delete(connection.userId)) {
|
|
142
|
-
if (rows.size === 0)
|
|
143
|
-
presence.delete(serverId);
|
|
144
|
-
broadcastPresence(serverId);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
const handleMessage = async (connection, message) => {
|
|
149
|
-
if (message.t === "hello") {
|
|
150
|
-
const userId = await authenticate({ userId: message.userId, token: message.token });
|
|
151
|
-
if (userId === null) {
|
|
152
|
-
replyError(connection, message.id, "Not authenticated");
|
|
153
|
-
connection.socket.close();
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
connection.userId = userId;
|
|
157
|
-
reply(connection, message.id, { userId });
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (message.t === "pose") {
|
|
161
|
-
handlePose(connection, message.serverId, message.pose);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
if (connection.userId === null) {
|
|
165
|
-
replyError(connection, message.id, "Not authenticated");
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const userId = connection.userId;
|
|
169
|
-
try {
|
|
170
|
-
switch (message.t) {
|
|
171
|
-
case "join": {
|
|
172
|
-
const result = await host.joinServer({
|
|
173
|
-
userId,
|
|
174
|
-
gameId: message.gameId,
|
|
175
|
-
serverId: message.serverId,
|
|
176
|
-
attributes: message.attributes,
|
|
177
|
-
});
|
|
178
|
-
reply(connection, message.id, result);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
case "joinByCode": {
|
|
182
|
-
const result = await host.joinByCode({
|
|
183
|
-
userId,
|
|
184
|
-
gameId: message.gameId,
|
|
185
|
-
code: message.code,
|
|
186
|
-
});
|
|
187
|
-
reply(connection, message.id, result);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
case "browse": {
|
|
191
|
-
const result = await host.browseServers({
|
|
192
|
-
gameId: message.gameId,
|
|
193
|
-
filter: message.filter,
|
|
194
|
-
limit: message.limit,
|
|
195
|
-
});
|
|
196
|
-
reply(connection, message.id, result);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
case "leave": {
|
|
200
|
-
await host.leaveServer({ userId, serverId: message.serverId });
|
|
201
|
-
reply(connection, message.id, null);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
case "runCommand": {
|
|
205
|
-
const result = await host.runCommand({
|
|
206
|
-
userId,
|
|
207
|
-
serverId: message.serverId,
|
|
208
|
-
command: message.command,
|
|
209
|
-
input: message.input,
|
|
210
|
-
});
|
|
211
|
-
reply(connection, message.id, result);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
case "pushFeed": {
|
|
215
|
-
await host.pushFeedEntry({
|
|
216
|
-
userId,
|
|
217
|
-
serverId: message.serverId,
|
|
218
|
-
action: message.action,
|
|
219
|
-
entry: message.entry,
|
|
220
|
-
});
|
|
221
|
-
reply(connection, message.id, null);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
case "subscribe": {
|
|
225
|
-
connection.subscriptions.add(subscriptionKey(message.channel, message.serverId, message.action));
|
|
226
|
-
reply(connection, message.id, null);
|
|
227
|
-
await pushSubscription(connection, message.channel, message.serverId, message.action);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
case "unsubscribe": {
|
|
231
|
-
connection.subscriptions.delete(subscriptionKey(message.channel, message.serverId, message.action));
|
|
232
|
-
reply(connection, message.id, null);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
catch (error) {
|
|
238
|
-
replyError(connection, message.id, error instanceof Error ? error.message : "Internal error");
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
8
|
wss.on("connection", (socket) => {
|
|
242
|
-
const connection = {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
});
|
|
250
|
-
socket.on("close", () => {
|
|
251
|
-
connections.delete(connection);
|
|
252
|
-
dropPresence(connection);
|
|
9
|
+
const connection = router.connect({
|
|
10
|
+
send: (data) => {
|
|
11
|
+
if (socket.readyState !== socket.OPEN)
|
|
12
|
+
return;
|
|
13
|
+
socket.send(data);
|
|
14
|
+
},
|
|
15
|
+
close: () => socket.close(),
|
|
253
16
|
});
|
|
17
|
+
socket.on("message", (raw) => connection.handleRaw(raw.toString()));
|
|
18
|
+
socket.on("close", () => connection.close());
|
|
254
19
|
});
|
|
255
20
|
return {
|
|
256
21
|
wss,
|
|
@@ -261,23 +26,11 @@ export function createGameWsServer(options) {
|
|
|
261
26
|
}
|
|
262
27
|
return address.port;
|
|
263
28
|
},
|
|
264
|
-
rewind:
|
|
265
|
-
const history = histories.get(serverId);
|
|
266
|
-
if (history === undefined)
|
|
267
|
-
return [];
|
|
268
|
-
const positions = [];
|
|
269
|
-
for (const userId of history.entities()) {
|
|
270
|
-
const sample = history.sampleAt(userId, atMs);
|
|
271
|
-
if (sample !== null) {
|
|
272
|
-
positions.push({ userId, x: sample.x, y: sample.y, z: sample.z });
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return positions;
|
|
276
|
-
},
|
|
29
|
+
rewind: router.rewind,
|
|
277
30
|
close: () => new Promise((resolve) => {
|
|
278
|
-
|
|
279
|
-
for (const
|
|
280
|
-
|
|
31
|
+
router.close();
|
|
32
|
+
for (const client of wss.clients) {
|
|
33
|
+
client.terminate();
|
|
281
34
|
}
|
|
282
35
|
let settled = false;
|
|
283
36
|
const finish = () => {
|