@jgengine/ws 0.8.0 → 0.10.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 +36 -0
- package/dist/createWsBackend.d.ts +4 -0
- package/dist/createWsBackend.js +167 -27
- package/dist/host.d.ts +10 -0
- package/dist/host.js +34 -3
- package/dist/hostRouter.d.ts +8 -4
- package/dist/hostRouter.js +100 -29
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/peer.js +5 -1
- package/dist/protocol.d.ts +5 -0
- package/dist/protocol.js +29 -5
- package/dist/readsHandler.d.ts +3 -0
- package/dist/readsHandler.js +21 -2
- package/dist/worldHost.d.ts +26 -0
- package/dist/worldHost.js +119 -0
- package/llms.txt +267 -688
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,42 @@ Agents building on the published SDK can also read this programmatically:
|
|
|
11
11
|
same data as typed values, so an updater can diff its installed version against
|
|
12
12
|
the latest and surface the migration steps.
|
|
13
13
|
|
|
14
|
+
## 0.10.0
|
|
15
|
+
|
|
16
|
+
### Migrate
|
|
17
|
+
|
|
18
|
+
- **Nothing to change for existing games** — the shared mobile-composition system (viewport allocation, region collision, `--jg-*` viewport vars) is wired automatically by the shell for every game; `HudPanel`s inside a `HudCanvas` register themselves. Legacy `orientation: "landscape" | "portrait"` keeps its advisory dismissible-hint behavior unchanged.
|
|
19
|
+
- **Opt a driving/landscape game into the strict gate**: replace the advisory `orientation` with the contract form — `orientation: { mobile: "landscape-required" }` (rules: `any` · `portrait`/`landscape` advisory · `portrait-required`/`landscape-required` · `unsupported`). In portrait the engine now shows a polished `RotateDeviceScreen`, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.
|
|
20
|
+
- **Declare HUD intent** where useful: `HudPanel` gains `priority` (`critical`/`secondary`/`tertiary`), `mobileBehavior` (`hidden` unmounts on phones, `transient` softens collision, …), `allowOverlapWith`, and `collisionGroup`. Optional — omitting them keeps prior behavior.
|
|
21
|
+
- **Mobile validation now also fails on overlap**: `bun run shoot <game> --device mobile|mobile-landscape|both` exits non-zero on forbidden inter-element collisions (not just viewport overflow), naming both regions.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Shared mobile layout composition — `@jgengine/core/ui/gameLayout` (pure geometry: `resolveLayoutMode`, `intersects`, `overlapArea`, `detectLayoutCollisions`, `computeGameplayRect`, `LayoutRegion`, `GameViewportLayout`) and `@jgengine/core/ui/orientation` (`resolveOrientationRequirement`, `orientationGateActive`, `MobileOrientationRule`). `@jgengine/react` adds `GameViewportProvider` (mounted by the shell), hooks (`useGameViewportLayout`, `useGameLayoutMode`, `useGameOrientation`, `useReservedControlZones`, `useLayoutCollisions`, `useRegisterLayoutRegion`), and a headless `RotateDeviceScreen`. The provider tracks `window.visualViewport`, publishes `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS vars, resolves the explicit layout mode, and detects forbidden region overlaps (dev `data-jg-layout-collision` + console diagnostic).
|
|
26
|
+
- Mandatory orientation contract — `PlayableGame.orientation` accepts `{ mobile: <rule> }`; `landscape-required`/`portrait-required` render an engine-owned rotate gate that blocks gameplay (input suppressed, simulation frozen, HUD/controls unmounted), self-correcting when the device is turned. Reduced-motion and safe-area respected; `visualViewport`-sized for mobile Safari/PWA.
|
|
27
|
+
- Reserved touch-control zones — the touch dock registers its joystick / action-cluster / utility rectangles (runtime-measured) as `control` regions, so HUD placement and collision validation know exactly where controls sit.
|
|
28
|
+
- HUD placement metadata — `HudPanel` gains `priority`, `mobileBehavior`, `allowOverlapWith`, `collisionGroup`, and registers as a collision-tracked layout region.
|
|
29
|
+
- Mobile-landscape shoot device — `bun run shoot --device mobile-landscape` (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.
|
|
30
|
+
- Canyon Chase migrated as the reference landscape-required game — declares `orientation: { mobile: "landscape-required" }`, composes its HUD through coordinated `HudPanel` regions (pursuit distance critical, border secondary, survey map hidden on mobile, radio transient) instead of independent fixed corners.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline. Hit detection is unchanged (still crosshair-accurate) — only the drawn tracer's start point moved to the viewmodel muzzle, which tracks full aim yaw and pitch. Non-viewmodel games and enemy tracers are unaffected.
|
|
35
|
+
|
|
36
|
+
## 0.9.0
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Mobile HUD fit — design-resolution UI scaling now applies to every game by default: `HudCanvas` measures the live viewport and scales the whole HUD from `PlayableGame.hudFit.designSize` (default 1600×900, clamps `minScale`/`maxScale` 0.4–1) so desktop-authored layouts shrink to fit a phone instead of overflowing it; `hudFit.mobile` overrides tune the phone fit separately. Pure math lives in `@jgengine/core/ui/hudScale` (`hudScaleForViewport`, `resolveHudFit`, `rectOverflow`, `overflowingPanels`); the shell mounts `@jgengine/react`'s `HudViewportProvider` around `GameUI` so games need no wiring.
|
|
41
|
+
- Graphics → UI scale — a player-facing `graphics.uiScale` slider (0.5–1.5, default 1) multiplies the computed HUD scale on every platform; the same resolution system drives desktop preference and mobile shrink.
|
|
42
|
+
- HUD overflow gate — `HudCanvas` measures every `HudPanel` against the viewport at runtime and reports offenders on a `data-hud-overflow` attribute (plus a console warning); `bun run shoot <game> --device mobile|both` now exits non-zero naming the panels that escape the viewport.
|
|
43
|
+
- Automatic visibility & streaming defaults — an engine-level `VisibilitySystem` (exported from `@jgengine/core`) gives every scene renderer-agnostic culling and asset-streaming policy with no per-object wiring: distance culling, preload margins, hysteresis, delayed unloading, multi-camera awareness (including cameras excluded from streaming), and per-object overrides (always-visible, never-unload, disabled culling/streaming, custom render distance and preload margin).
|
|
44
|
+
- Self-hosted asset mirror — `DEFAULT_RELEASE_BASE` now points at this repo's rolling `packs` release instead of the upstream host; a catalog-driven `mirror-assets` workflow (weekly cron + manual dispatch) keeps the mirror in sync with the asset catalog.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- Mobile HUD fit is on by default — design-resolution HUD scaling now applies to every game with no config change (previously opt-in via `platforms: ["web", "mobile"]`). A desktop-only game keeps the legacy fixed 0.85 compact zoom by declaring `platforms: ["web"]` (without `"mobile"`).
|
|
49
|
+
|
|
14
50
|
## 0.8.0
|
|
15
51
|
|
|
16
52
|
Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
|
|
@@ -13,8 +13,12 @@ export type WsBackendOptions = {
|
|
|
13
13
|
token?: string;
|
|
14
14
|
webSocketFactory?: (url: string) => WebSocket;
|
|
15
15
|
reconnectDelayMs?: number;
|
|
16
|
+
maxReconnectDelayMs?: number;
|
|
17
|
+
rpcTimeoutMs?: number;
|
|
16
18
|
poseTuning?: PoseSyncTuning;
|
|
17
19
|
now?: () => number;
|
|
20
|
+
setTimeoutFn?: typeof setTimeout;
|
|
21
|
+
clearTimeoutFn?: typeof clearTimeout;
|
|
18
22
|
};
|
|
19
23
|
export type WsPresenceSync = {
|
|
20
24
|
subscribe: (serverId: string, onChange: (rows: WsPresenceRow[]) => void) => () => void;
|
package/dist/createWsBackend.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createPoseSyncGate } from "@jgengine/core/multiplayer/poseSyncGate";
|
|
2
|
-
import { decodeWsServerMessage, encodeWsMessage, subscriptionKey, } from "./protocol.js";
|
|
2
|
+
import { decodeWsServerMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
|
|
3
3
|
import { webSocketPipe } from "./pipe.js";
|
|
4
4
|
const DEFAULT_POSE_TUNING = {
|
|
5
5
|
minIntervalMs: 100,
|
|
@@ -8,9 +8,16 @@ const DEFAULT_POSE_TUNING = {
|
|
|
8
8
|
verticalEpsilon: 0.01,
|
|
9
9
|
rotationEpsilon: 0.01,
|
|
10
10
|
};
|
|
11
|
+
const DEFAULT_RPC_TIMEOUT_MS = 15_000;
|
|
12
|
+
const DEFAULT_RECONNECT_DELAY_MS = 1_000;
|
|
13
|
+
const DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;
|
|
11
14
|
export function createWsBackend(options) {
|
|
12
15
|
const now = options.now ?? Date.now;
|
|
13
|
-
const
|
|
16
|
+
const baseReconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
|
|
17
|
+
const maxReconnectDelayMs = options.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS;
|
|
18
|
+
const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
|
|
19
|
+
const schedule = options.setTimeoutFn ?? setTimeout;
|
|
20
|
+
const cancel = options.clearTimeoutFn ?? clearTimeout;
|
|
14
21
|
const pipeFactory = options.pipe ??
|
|
15
22
|
(() => {
|
|
16
23
|
const url = options.url;
|
|
@@ -22,29 +29,89 @@ export function createWsBackend(options) {
|
|
|
22
29
|
let open = false;
|
|
23
30
|
let ready = null;
|
|
24
31
|
let closed = false;
|
|
32
|
+
let wantConnection = false;
|
|
25
33
|
let nextId = 1;
|
|
34
|
+
let reconnectAttempt = 0;
|
|
35
|
+
let reconnectTimer = null;
|
|
26
36
|
const pending = new Map();
|
|
27
37
|
const subscriptions = new Map();
|
|
38
|
+
const clearRequestTimer = (request) => {
|
|
39
|
+
if (request.timer !== null) {
|
|
40
|
+
cancel(request.timer);
|
|
41
|
+
request.timer = null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
28
44
|
const failPending = (reason) => {
|
|
29
45
|
for (const request of pending.values()) {
|
|
46
|
+
clearRequestTimer(request);
|
|
30
47
|
request.reject(new Error(reason));
|
|
31
48
|
}
|
|
32
49
|
pending.clear();
|
|
33
50
|
};
|
|
51
|
+
const armRpcTimeout = (request) => {
|
|
52
|
+
if (rpcTimeoutMs <= 0 || request.kind === "hello")
|
|
53
|
+
return;
|
|
54
|
+
request.timer = schedule(() => {
|
|
55
|
+
const current = pending.get(request.id);
|
|
56
|
+
if (current !== request)
|
|
57
|
+
return;
|
|
58
|
+
pending.delete(request.id);
|
|
59
|
+
request.timer = null;
|
|
60
|
+
request.reject(new Error("RPC timeout"));
|
|
61
|
+
}, rpcTimeoutMs);
|
|
62
|
+
};
|
|
34
63
|
const rawSend = (message) => {
|
|
35
64
|
if (pipe === null || !open)
|
|
36
65
|
return;
|
|
37
66
|
pipe.send(encodeWsMessage(message));
|
|
38
67
|
};
|
|
68
|
+
const resendPendingRpcs = () => {
|
|
69
|
+
const entries = [...pending.entries()];
|
|
70
|
+
pending.clear();
|
|
71
|
+
for (const [, request] of entries) {
|
|
72
|
+
if (request.kind === "hello") {
|
|
73
|
+
clearRequestTimer(request);
|
|
74
|
+
request.reject(new Error("Connection reset"));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
request.id = nextId++;
|
|
78
|
+
request.sent = true;
|
|
79
|
+
pending.set(request.id, request);
|
|
80
|
+
rawSend(request.build(request.id));
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const resubscribeAll = () => {
|
|
84
|
+
for (const subscription of subscriptions.values()) {
|
|
85
|
+
rawSend({
|
|
86
|
+
v: 1,
|
|
87
|
+
t: "subscribe",
|
|
88
|
+
id: nextId++,
|
|
89
|
+
channel: subscription.channel,
|
|
90
|
+
serverId: subscription.serverId,
|
|
91
|
+
action: subscription.action,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
39
95
|
const handleMessage = (raw) => {
|
|
40
96
|
const message = decodeWsServerMessage(raw);
|
|
41
|
-
if (message === null)
|
|
97
|
+
if (message === null) {
|
|
98
|
+
const failure = inspectWsDecodeFailure(raw);
|
|
99
|
+
if (failure.id !== undefined) {
|
|
100
|
+
const request = pending.get(failure.id);
|
|
101
|
+
if (request !== undefined) {
|
|
102
|
+
pending.delete(failure.id);
|
|
103
|
+
clearRequestTimer(request);
|
|
104
|
+
request.reject(new Error(failure.reason));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
42
107
|
return;
|
|
108
|
+
}
|
|
43
109
|
if (message.t === "reply") {
|
|
44
110
|
const request = pending.get(message.id);
|
|
45
111
|
if (request === undefined)
|
|
46
112
|
return;
|
|
47
113
|
pending.delete(message.id);
|
|
114
|
+
clearRequestTimer(request);
|
|
48
115
|
if (message.ok) {
|
|
49
116
|
request.resolve(message.result);
|
|
50
117
|
}
|
|
@@ -65,58 +132,129 @@ export function createWsBackend(options) {
|
|
|
65
132
|
for (const callback of subscription.callbacks)
|
|
66
133
|
callback(data);
|
|
67
134
|
};
|
|
135
|
+
const clearReconnectTimer = () => {
|
|
136
|
+
if (reconnectTimer !== null) {
|
|
137
|
+
cancel(reconnectTimer);
|
|
138
|
+
reconnectTimer = null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const scheduleReconnect = () => {
|
|
142
|
+
if (closed || !wantConnection || reconnectTimer !== null)
|
|
143
|
+
return;
|
|
144
|
+
const delay = Math.min(maxReconnectDelayMs, baseReconnectDelayMs * 2 ** reconnectAttempt);
|
|
145
|
+
reconnectAttempt += 1;
|
|
146
|
+
reconnectTimer = schedule(() => {
|
|
147
|
+
reconnectTimer = null;
|
|
148
|
+
if (closed || !wantConnection)
|
|
149
|
+
return;
|
|
150
|
+
void connect().catch(() => undefined);
|
|
151
|
+
}, delay);
|
|
152
|
+
};
|
|
68
153
|
const connect = () => {
|
|
69
154
|
if (closed)
|
|
70
155
|
return Promise.reject(new Error("Backend closed"));
|
|
156
|
+
wantConnection = true;
|
|
71
157
|
if (ready !== null)
|
|
72
158
|
return ready;
|
|
159
|
+
clearReconnectTimer();
|
|
73
160
|
ready = new Promise((resolve, reject) => {
|
|
161
|
+
let settled = false;
|
|
162
|
+
const settleReject = (error) => {
|
|
163
|
+
if (settled)
|
|
164
|
+
return;
|
|
165
|
+
settled = true;
|
|
166
|
+
reject(error);
|
|
167
|
+
};
|
|
168
|
+
const settleResolve = () => {
|
|
169
|
+
if (settled)
|
|
170
|
+
return;
|
|
171
|
+
settled = true;
|
|
172
|
+
resolve();
|
|
173
|
+
};
|
|
74
174
|
pipe = pipeFactory({
|
|
75
175
|
onOpen: () => {
|
|
76
176
|
open = true;
|
|
77
177
|
const id = nextId++;
|
|
178
|
+
const helloBuild = (requestId) => ({
|
|
179
|
+
v: 1,
|
|
180
|
+
t: "hello",
|
|
181
|
+
id: requestId,
|
|
182
|
+
userId: options.userId,
|
|
183
|
+
token: options.token,
|
|
184
|
+
});
|
|
78
185
|
pending.set(id, {
|
|
186
|
+
id,
|
|
79
187
|
resolve: () => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
id: nextId++,
|
|
85
|
-
channel: subscription.channel,
|
|
86
|
-
serverId: subscription.serverId,
|
|
87
|
-
action: subscription.action,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
resolve();
|
|
188
|
+
reconnectAttempt = 0;
|
|
189
|
+
resubscribeAll();
|
|
190
|
+
resendPendingRpcs();
|
|
191
|
+
settleResolve();
|
|
91
192
|
},
|
|
92
|
-
reject
|
|
193
|
+
reject: (error) => {
|
|
194
|
+
settleReject(error);
|
|
195
|
+
},
|
|
196
|
+
build: helloBuild,
|
|
197
|
+
timer: null,
|
|
198
|
+
kind: "hello",
|
|
199
|
+
sent: true,
|
|
93
200
|
});
|
|
94
|
-
rawSend(
|
|
201
|
+
rawSend(helloBuild(id));
|
|
95
202
|
},
|
|
96
203
|
onMessage: handleMessage,
|
|
97
204
|
onClose: () => {
|
|
98
205
|
open = false;
|
|
99
|
-
failPending("Connection closed");
|
|
100
206
|
pipe = null;
|
|
101
207
|
ready = null;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
208
|
+
for (const [id, request] of [...pending.entries()]) {
|
|
209
|
+
if (request.kind === "hello") {
|
|
210
|
+
pending.delete(id);
|
|
211
|
+
clearRequestTimer(request);
|
|
212
|
+
request.reject(new Error("Connection closed"));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
request.sent = false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
settleReject(new Error("Connection closed"));
|
|
219
|
+
if (!closed && wantConnection) {
|
|
220
|
+
scheduleReconnect();
|
|
108
221
|
}
|
|
109
222
|
},
|
|
110
223
|
});
|
|
111
224
|
});
|
|
112
225
|
return ready;
|
|
113
226
|
};
|
|
114
|
-
const request =
|
|
115
|
-
|
|
227
|
+
const request = (build) => {
|
|
228
|
+
if (closed)
|
|
229
|
+
return Promise.reject(new Error("Backend closed"));
|
|
230
|
+
wantConnection = true;
|
|
116
231
|
const id = nextId++;
|
|
117
232
|
return new Promise((resolve, reject) => {
|
|
118
|
-
|
|
119
|
-
|
|
233
|
+
const entry = {
|
|
234
|
+
id,
|
|
235
|
+
resolve,
|
|
236
|
+
reject,
|
|
237
|
+
build,
|
|
238
|
+
timer: null,
|
|
239
|
+
kind: "rpc",
|
|
240
|
+
sent: false,
|
|
241
|
+
};
|
|
242
|
+
pending.set(id, entry);
|
|
243
|
+
armRpcTimeout(entry);
|
|
244
|
+
void connect()
|
|
245
|
+
.then(() => {
|
|
246
|
+
if (pending.get(entry.id) !== entry || entry.sent || !open)
|
|
247
|
+
return;
|
|
248
|
+
entry.sent = true;
|
|
249
|
+
rawSend(build(entry.id));
|
|
250
|
+
})
|
|
251
|
+
.catch((error) => {
|
|
252
|
+
if (pending.get(entry.id) !== entry)
|
|
253
|
+
return;
|
|
254
|
+
pending.delete(entry.id);
|
|
255
|
+
clearRequestTimer(entry);
|
|
256
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
257
|
+
});
|
|
120
258
|
});
|
|
121
259
|
};
|
|
122
260
|
const transport = {
|
|
@@ -290,6 +428,8 @@ export function createWsBackend(options) {
|
|
|
290
428
|
},
|
|
291
429
|
close: () => {
|
|
292
430
|
closed = true;
|
|
431
|
+
wantConnection = false;
|
|
432
|
+
clearReconnectTimer();
|
|
293
433
|
failPending("Backend closed");
|
|
294
434
|
pipe?.close();
|
|
295
435
|
pipe = null;
|
package/dist/host.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
|
2
2
|
import type { HostPersistence, ServerListing, SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
|
|
3
3
|
import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
4
|
import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
|
|
5
|
+
/** A change notification emitted by a `GameHost` for a server, player, or feed mutation. */
|
|
5
6
|
export type HostChangeEvent = {
|
|
6
7
|
type: "server";
|
|
7
8
|
serverId: string;
|
|
@@ -14,6 +15,7 @@ export type HostChangeEvent = {
|
|
|
14
15
|
serverId: string;
|
|
15
16
|
action: string;
|
|
16
17
|
};
|
|
18
|
+
/** Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. */
|
|
17
19
|
export type GameHostOptions = {
|
|
18
20
|
runtimes?: GameRuntime[];
|
|
19
21
|
persistence: HostPersistence;
|
|
@@ -21,7 +23,9 @@ export type GameHostOptions = {
|
|
|
21
23
|
slotsPerServer?: number;
|
|
22
24
|
now?: () => number;
|
|
23
25
|
createServerId?: () => string;
|
|
26
|
+
allowedFeedActions?: readonly string[];
|
|
24
27
|
};
|
|
28
|
+
/** A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. */
|
|
25
29
|
export type GameHost = {
|
|
26
30
|
joinServer: (args: {
|
|
27
31
|
userId: string;
|
|
@@ -49,6 +53,10 @@ export type GameHost = {
|
|
|
49
53
|
command: string;
|
|
50
54
|
input: unknown;
|
|
51
55
|
}) => Promise<TransportRunCommandResult>;
|
|
56
|
+
isMember: (args: {
|
|
57
|
+
userId: string;
|
|
58
|
+
serverId: string;
|
|
59
|
+
}) => Promise<boolean>;
|
|
52
60
|
getServerView: (args: {
|
|
53
61
|
userId: string;
|
|
54
62
|
serverId: string;
|
|
@@ -81,5 +89,7 @@ export type GameHost = {
|
|
|
81
89
|
stop: () => Promise<void>;
|
|
82
90
|
subscribe: (listener: (event: HostChangeEvent) => void) => () => void;
|
|
83
91
|
};
|
|
92
|
+
/** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
|
|
84
93
|
export declare function createGameHost(options: GameHostOptions): GameHost;
|
|
94
|
+
/** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
|
|
85
95
|
export declare function memoryPersistence(now?: () => number): HostPersistence;
|
package/dist/host.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
3
|
import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
|
+
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
4
5
|
import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
|
|
5
6
|
const builtinCommands = {
|
|
6
7
|
"engine.ping": {
|
|
@@ -8,12 +9,14 @@ const builtinCommands = {
|
|
|
8
9
|
apply: (snapshot) => snapshot,
|
|
9
10
|
},
|
|
10
11
|
};
|
|
12
|
+
/** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
|
|
11
13
|
export function createGameHost(options) {
|
|
12
14
|
const now = options.now ?? Date.now;
|
|
13
15
|
const tickMs = options.tickMs ?? 1_000;
|
|
14
16
|
const defaultSlots = options.slotsPerServer ?? 16;
|
|
15
17
|
const createServerId = options.createServerId ?? (() => `srv-${crypto.randomUUID()}`);
|
|
16
18
|
const persistence = options.persistence;
|
|
19
|
+
const feedWriteGate = createFeedWriteGate(options.allowedFeedActions ?? []);
|
|
17
20
|
const runtimes = new Map();
|
|
18
21
|
for (const runtime of options.runtimes ?? []) {
|
|
19
22
|
runtimes.set(runtime.gameId, runtime);
|
|
@@ -55,6 +58,7 @@ export function createGameHost(options) {
|
|
|
55
58
|
serverRow: record.serverState,
|
|
56
59
|
playersByUserId: buildHydratePlayers(record, profiles),
|
|
57
60
|
chunksByKey,
|
|
61
|
+
revision: record.revision,
|
|
58
62
|
});
|
|
59
63
|
const existing = live.get(record.serverId);
|
|
60
64
|
if (existing)
|
|
@@ -89,6 +93,9 @@ export function createGameHost(options) {
|
|
|
89
93
|
if (cleared.chunks.length > 0) {
|
|
90
94
|
await persistence.saveChunks(entry.record.serverId, cleared.chunks);
|
|
91
95
|
}
|
|
96
|
+
if (cleared.deletedChunks.length > 0) {
|
|
97
|
+
await persistence.deleteChunks(entry.record.serverId, cleared.deletedChunks);
|
|
98
|
+
}
|
|
92
99
|
await persistence.saveServer(cleared.server);
|
|
93
100
|
}
|
|
94
101
|
entry.record = cleared.server;
|
|
@@ -132,7 +139,7 @@ export function createGameHost(options) {
|
|
|
132
139
|
}
|
|
133
140
|
return { ticked, saved };
|
|
134
141
|
});
|
|
135
|
-
const collectListings = async (gameId) => {
|
|
142
|
+
const collectListings = async (gameId, options = {}) => {
|
|
136
143
|
const byId = new Map();
|
|
137
144
|
for (const record of await persistence.listServers(gameId)) {
|
|
138
145
|
byId.set(record.serverId, record);
|
|
@@ -151,7 +158,9 @@ export function createGameHost(options) {
|
|
|
151
158
|
slotsPerServer: record.slotsPerServer,
|
|
152
159
|
label: record.label,
|
|
153
160
|
mode: record.mode,
|
|
154
|
-
|
|
161
|
+
...(options.includeJoinCode === true && record.joinCode !== undefined
|
|
162
|
+
? { joinCode: record.joinCode }
|
|
163
|
+
: {}),
|
|
155
164
|
tags: record.tags,
|
|
156
165
|
updatedAt: record.updatedAt,
|
|
157
166
|
}));
|
|
@@ -161,6 +170,9 @@ export function createGameHost(options) {
|
|
|
161
170
|
const timestamp = now();
|
|
162
171
|
const runtime = resolveRuntime(args.gameId);
|
|
163
172
|
let entry = args.serverId === undefined ? null : await getLive(args.serverId);
|
|
173
|
+
if (args.serverId !== undefined && entry === null) {
|
|
174
|
+
throw new Error("Server not found");
|
|
175
|
+
}
|
|
164
176
|
if (entry !== null && entry.record.gameId !== args.gameId) {
|
|
165
177
|
throw new Error("Server belongs to a different game");
|
|
166
178
|
}
|
|
@@ -211,7 +223,7 @@ export function createGameHost(options) {
|
|
|
211
223
|
}),
|
|
212
224
|
browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
|
|
213
225
|
joinByCode: async (args) => {
|
|
214
|
-
const match = findByJoinCode(await collectListings(args.gameId), args.code);
|
|
226
|
+
const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
|
|
215
227
|
if (match === null)
|
|
216
228
|
return null;
|
|
217
229
|
return host.joinServer({ userId: args.userId, gameId: args.gameId, serverId: match.serverId });
|
|
@@ -262,6 +274,12 @@ export function createGameHost(options) {
|
|
|
262
274
|
emit({ type: "player", serverId: args.serverId, userId: args.userId });
|
|
263
275
|
return { ok: true };
|
|
264
276
|
}),
|
|
277
|
+
isMember: async (args) => {
|
|
278
|
+
const entry = await getLive(args.serverId);
|
|
279
|
+
if (entry === null)
|
|
280
|
+
return false;
|
|
281
|
+
return entry.record.memberUserIds.includes(args.userId);
|
|
282
|
+
},
|
|
265
283
|
getServerView: async (args) => {
|
|
266
284
|
const entry = await getLive(args.serverId);
|
|
267
285
|
if (entry === null)
|
|
@@ -311,6 +329,10 @@ export function createGameHost(options) {
|
|
|
311
329
|
return persistence.loadFeed({ serverId: args.serverId, action: args.action });
|
|
312
330
|
},
|
|
313
331
|
pushFeedEntry: (args) => enqueue(async () => {
|
|
332
|
+
const allowed = validateFeedWrite(feedWriteGate, args.action);
|
|
333
|
+
if (!allowed.ok) {
|
|
334
|
+
throw new Error(allowed.reason);
|
|
335
|
+
}
|
|
314
336
|
const entry = await getLive(args.serverId);
|
|
315
337
|
if (entry === null || !entry.record.memberUserIds.includes(args.userId)) {
|
|
316
338
|
throw new Error("Not a member of this server");
|
|
@@ -368,6 +390,7 @@ export function createGameHost(options) {
|
|
|
368
390
|
};
|
|
369
391
|
return host;
|
|
370
392
|
}
|
|
393
|
+
/** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
|
|
371
394
|
export function memoryPersistence(now = Date.now) {
|
|
372
395
|
const servers = new Map();
|
|
373
396
|
const profiles = new Map();
|
|
@@ -403,6 +426,14 @@ export function memoryPersistence(now = Date.now) {
|
|
|
403
426
|
}
|
|
404
427
|
chunks.set(serverId, byKey);
|
|
405
428
|
},
|
|
429
|
+
async deleteChunks(serverId, chunkKeys) {
|
|
430
|
+
const byKey = chunks.get(serverId);
|
|
431
|
+
if (byKey === undefined)
|
|
432
|
+
return;
|
|
433
|
+
for (const chunkKey of chunkKeys) {
|
|
434
|
+
byKey.delete(chunkKey);
|
|
435
|
+
}
|
|
436
|
+
},
|
|
406
437
|
async loadFeed({ serverId, action }) {
|
|
407
438
|
return clone(feeds.get(`${serverId}|${action}`) ?? []);
|
|
408
439
|
},
|
package/dist/hostRouter.d.ts
CHANGED
|
@@ -2,12 +2,15 @@ import type { PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
|
|
|
2
2
|
import { type ChatRateLimit } from "@jgengine/core/game/chat";
|
|
3
3
|
import type { GameHost } from "./host.js";
|
|
4
4
|
import type { TransportPipeFactory } from "./pipe.js";
|
|
5
|
+
export type HostRouterAuthenticate = (args: {
|
|
6
|
+
userId: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
}) => Promise<string | null> | string | null;
|
|
5
9
|
export type HostRouterOptions = {
|
|
6
10
|
host: GameHost;
|
|
7
|
-
authenticate?:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}) => Promise<string | null> | string | null;
|
|
11
|
+
authenticate?: HostRouterAuthenticate;
|
|
12
|
+
allowAnonymous?: boolean;
|
|
13
|
+
singleSession?: boolean;
|
|
11
14
|
poseRules?: PoseSyncRules;
|
|
12
15
|
positionHistoryMs?: number;
|
|
13
16
|
chatRateLimit?: ChatRateLimit;
|
|
@@ -23,6 +26,7 @@ export type HostRouterConnection = {
|
|
|
23
26
|
handleRaw: (raw: unknown) => void;
|
|
24
27
|
close: () => void;
|
|
25
28
|
};
|
|
29
|
+
/** A player's interpolated position sampled from history at a past timestamp. */
|
|
26
30
|
export type RewoundPosition = {
|
|
27
31
|
userId: string;
|
|
28
32
|
x: number;
|