@jgengine/node 0.7.0 → 0.8.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 CHANGED
@@ -11,6 +11,53 @@ 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.8.0
15
+
16
+ Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
17
+ and puzzle primitives, HUD-only presentation, cumulative leveling, and a round of
18
+ controller/camera/sensor additions. **Breaking:** the `gameui` component kit moved out of
19
+ `@jgengine/react` onto the shadcn registry.
20
+
21
+ ### Migrate
22
+
23
+ - Bump every `@jgengine/*` dependency to `^0.8.0` (the eight packages version in lockstep).
24
+ - Additive only, except for `gameui` — every other 0.7.0 API is unchanged; opt into any of the below by importing it directly.
25
+ - **Breaking:** replace any `@jgengine/react/gameui` import with the equivalent registry component (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), and swap `GameUiThemeProvider` for `--jg-*` CSS variables on a wrapper element. `GameIcon` and friends moved to `@jgengine/react/gameIcons`.
26
+ - `leveling({ thresholdMode: 'cumulative' })` is opt-in; the default `perLevel` behavior is unchanged.
27
+ - `defineGame.physics.gravity`/`jumpVelocity` are now read by the built-in kinematics controller every frame — if a game already set them expecting a no-op, jump/fall now actually reflects them; omit both to keep the previous defaults.
28
+
29
+ ### Added
30
+
31
+ - Cumulative leveling — `leveling({ thresholdMode: 'cumulative' })` tracks xp as a lifetime total that resolves upward across levels and clamps at the max-level threshold once capped (#12).
32
+ - Direction-aware pool depletion — `EffectSystem.canReceive(instanceId, effect, magnitude?)` takes an optional signed magnitude; negative checks the opposite direction and returns `pools-depleted` only when every stat in the receive order is already at max, so heals reach fully-depleted targets (#168).
33
+ - Puzzle primitives — `puzzle/cellGrid` (uniform-cell boards: line-clear, match-3 cascade, run detection) and `puzzle/fallingPiece` (rotation-state shapes, ghost drop, lock delay, classic gravity/level/score curves) for Tetris/match-3 games (#166).
34
+ - Voxel field — `world/voxelField` (`createVoxelField`, chunked block lattice, neighbors/exposedFaces, 3D DDA raycast, dirty-tracked `chunkVersion`) for voxel games and instanced renderers; assert on `field.summary()` the way environment worlds assert on `summarizeEnvironment` (#166).
35
+ - `defineGame` games may omit `assets` (an empty catalog is injected); `PlayableGame.presentation: 'hud'` mounts no 3D canvas/camera/pointer for board/card/menu games; an `environment()` world auto-renders as the shell's backdrop when `PlayableGame.environment` is unset (#166).
36
+ - Declared-action intent board — `turn/intent` `createIntentBoard` for one-turn-ahead intents (Slay-the-Spire style): declare/peek/all/consume/clear (#168).
37
+ - `turnLoop` lifecycle hooks — `config.onTurnStart`/`onTurnEnd` fire on every `advanceTurn()`; `ctx.game.turn.loop(id, config)` lazily creates/returns a notify-wrapped `TurnLoop` so every mutation (`advanceTurn`/`advancePhase`/`advanceRound`/`spend`/`gain`/`refill`/`setOrder`/...) auto-bumps `ctx.version()` with no manual wiring (#163/#168).
38
+ - `ctx.game.store` — a reactive per-game keyed store (`set`/`delete`/`get`/`has`/`subscribe`/`mapSnapshot`/`arraySnapshot`) plus `@jgengine/react`'s `useGameStore` selector hook, replacing hand-rolled module-level stores for ad-hoc reactive game state; `ctx.game.cards.pile(id, config?)` lazily creates/returns a notify-wrapped `CardPile` the same way; `createCardPile` gained an `onChange` hook for headless use; `CommandDefinition.apply` may return void for side-effect-only commands (#163).
39
+ - Camera — `sideScroll` rig (fixed lateral follow for 2.5D platformers/beat-'em-ups), a `none` rig (no camera mounted; pairs with `PlayableGame.presentation: 'hud'`), `rts.pan: false` (static backdrop camera: no pan/edge-scroll/rotate/zoom, still re-centers on the follow target), and the `observer` rig now defaults to the local player when `bind` is unset (#167).
40
+ - Sensors + session — `sensor/concealment` (`colorDistance`/`concealmentScore`/`createConcealmentSensor`), `sensor/freezeMonitor` (`createFreezeMonitor`), `session/roles` (`assignRoles`); `createRoundState`'s `RoundConfig.teams` accepts per-team roles and an optional `winCondition` that `evaluate()` checks each tick, and takes an optional `phaseOrder` for arbitrary named phase cycles (`concludeRound`/`evaluate` settle only while the current phase is neither the first nor the last entry) (#151).
41
+ - Appearance replication — presence rows carry an optional per-slot appearance channel (cosmetic ids, hex tints, model keys) alongside pose, riding the existing pose message with no protocol bump; wire `ctx.player.cosmetics.get(userId)` into the outgoing pose (#151).
42
+ - `ctx.input` — a per-frame held-action snapshot (`publish(held)`/`isDown(action)`/`held()`) without bumping `ctx.version()`; action bindings gained `repeatMs` (repeat-fire while held); every command resolved from a bound action now carries `aim`; `pointer.secondaryCommand` runs a command on right-click off the same raycast as move/ping (#164).
43
+ - Object spatial queries + entity patching — `ctx.scene.object.at`/`inBox`/`raycast`/`raycastAll` over unit-box objects; `ctx.scene.entity.update(id, patch)` for name/position/rotation/role/movement/behaviors/meta; per-instance `renderObject`/`objectStyles` overrides; `pointerService.worldHitCenter()` + pointer-lock center-ray aiming (#165).
44
+ - Controller movement config — `PlayerMovementConfig` (`mode`: free/axis/grid, `axis`, `cellSize`, `collideObjects`, `beforeCommit` pre-commit hook) for the shell-driven walk controller; `defineGame.physics.gravity`/`jumpVelocity` are honored by the built-in walk controller (distinct from the standalone `physics/physicsWorld` rigid-body sim); `ctx.player.motion.impulse`/`setVerticalVelocity`/`setY`/`takePending` (`MotionIntents`); `entity.spawnPoseOf`/`resetToSpawn`/`resetAllToSpawn` (#162).
45
+ - Model material/animation + paint — `ModelConfig.tint`/`metalness`/`roughness`/`animation` (GLTF clip playback, paused pose holds); `PointerHit.uv` + `pointerService.sampleSurface()` for material-aware picking; `ctx.scene.entity.paint` runtime paint layer, auto-rendered via a per-instance canvas texture with no per-game wiring; remote-player appearance tint recolors the shell's default capsule (#151).
46
+ - **Transport pipe seam** (`@jgengine/ws/pipe`) — `createWsBackend` runs over any bidirectional string channel, not just a raw `WebSocket`: `TransportPipe`/`TransportPipeHandlers`/`TransportPipeFactory`, with `webSocketPipe(url, webSocketFactory?)` as the default. `createWsBackend({ userId, url?, pipe? })` — `url` stays the common case, `pipe` opens the seam to socket.io, WebRTC, and in-process loopback below.
47
+ - **Browser-safe authoritative host** — `createGameHost` and `memoryPersistence` moved from `@jgengine/node` to `@jgengine/ws/host` (zero Node dependencies; `@jgengine/node` re-exports both unchanged from `@jgengine/node/host` / `@jgengine/node/persistence`, so existing imports keep working). `@jgengine/ws/hostRouter`'s `createHostRouter({ host, authenticate?, poseRules?, positionHistoryMs?, chatRateLimit?, chatHistoryLimit?, chatMaxBodyLength?, now? })` extracts the ws wire-protocol session logic out of `createGameWsServer` into a transport-agnostic `HostRouter` (`connect(transport) → { handleRaw, close }`, `rewind`, `close`); `@jgengine/node`'s `createGameWsServer` is now a thin binding of this router onto the `ws` npm package, same public API. `loopbackPipe(router): TransportPipeFactory` connects a `createWsBackend` straight into an in-process router.
48
+ - **Socket.IO transport** — `@jgengine/ws/socketIoPipe` (`SocketIoLikeSocket` structural type, `socketIoPipe(socket)`, `createSocketIoBackend({ socket, userId, … })`) and `@jgengine/node/socketIoServer` (`attachGameSocketIoServer({ io, host, …router options }): { rewind, close }`, structural `SocketIoLikeServer`/`SocketIoLikeServerSocket`) ride the existing ws JSON protocol over socket.io's `send`/`message` frames — no socket.io dependency in either package's types. New `socketIo({ topology?, url? })` adapter in `@jgengine/core/runtime/adapter`.
49
+ - **WebRTC peer-to-peer** (`@jgengine/ws/peer`) — one browser tab hosts, authoritatively, with no server process. `createPeerHost({ userId, host?, runtimes?, persistence?, tickMs?, router?, rtc? })` runs a `GameHost` + `HostRouter` in the host tab (`backend` is the host player's own loopback connection, `accept(offerCode) → Promise<answerCode>` per guest); `createPeerGuest({ userId, token?, rtc? })` offers/connects from the joining side. `encodePeerSignal`/`decodePeerSignal` turn SDP into copy-pasteable base64url codes for manual cross-machine signaling; `broadcastChannelSignaling(room)` automates it for same-origin multi-tab play, with `announcePeerHost`/`joinPeerSession` wiring host/guest to a `PeerSignaling` in one call. New `p2p({ topology?, room? })` adapter (topology defaults `"private"`).
50
+ - **LAN adapter + Fly sugar** — `lan({ topology?, port?, path? })` in `@jgengine/core/runtime/adapter` resolves through `@jgengine/shell/multiplayer`'s `resolveShellMultiplayer` to `ws(s)://<page hostname>:<port ?? 8080><path ?? /ws>` derived from `window.location`, so any browser on the LAN auto-connects to whichever machine served the page — no URL configuration. `fly({ app, topology?, path? })` is `ws` sugar for a Fly.io deploy: resolves to `wss://<app>.fly.dev<path ?? "/ws">`. `apps/dev`'s Vite server now listens on the network (`server: { host: true }`) and exposes `?p2p=host` / `?p2p=join` query params wired through the new `resolvePeerShellMultiplayer({ gameId, role, room?, userId?, feedActions? })`.
51
+ - `ws()` (`@jgengine/core/runtime/adapter`) gained an optional `url` field, carried through by `resolveShellMultiplayer` (`args.url ?? adapter.url ?? default`).
52
+
53
+ ### Removed
54
+
55
+ - **The `gameui` component kit** (`@jgengine/react/gameui`) — the themed HUD kit (bars, slots, feedback, meters, panels, screens, reticles, icons) and its `GameUiThemeProvider`/`useGameUiTheme` theming have been removed. **Breaking** for anyone importing `@jgengine/react/gameui` (or its subpaths/barrel). The components now ship as installable shadcn registry items at `https://jgengine.com/r/<name>.json` (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), styled with Tailwind + `--jg-*` CSS variables instead of a theme object. The icon catalog (`GameIcon`, `iconForAction`, `iconForItemId`, `isGameIconName`, `GameIconName`) moved to `@jgengine/react/gameIcons`. To theme, set the `--jg-*` variables on a wrapper element (the registry's `jg-theme` presets mirror the old `ember`/`synthwave`/`fieldkit` palettes) instead of wrapping in `GameUiThemeProvider`.
56
+
57
+ ### Docs
58
+
59
+ - Added [CREDITS.md](CREDITS.md) crediting [achrefelouafi](https://github.com/achrefelouafi) for the MIT Three.js reference projects behind the procedural building, water, rain, and snow renderers, with links from the root, `@jgengine/core`, and `@jgengine/shell` READMEs.
60
+
14
61
  ## 0.7.0
15
62
 
16
63
  The engine-gaps release — 22 system-level additions across turn/tactics, cards & boards,
package/dist/host.d.ts CHANGED
@@ -1,84 +1 @@
1
- import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
- import type { HostPersistence, ServerListing, SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
3
- import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
4
- import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
5
- export type HostChangeEvent = {
6
- type: "server";
7
- serverId: string;
8
- } | {
9
- type: "player";
10
- serverId: string;
11
- userId: string;
12
- } | {
13
- type: "feed";
14
- serverId: string;
15
- action: string;
16
- };
17
- export type GameHostOptions = {
18
- runtimes?: GameRuntime[];
19
- persistence: HostPersistence;
20
- tickMs?: number;
21
- slotsPerServer?: number;
22
- now?: () => number;
23
- createServerId?: () => string;
24
- };
25
- export type GameHost = {
26
- joinServer: (args: {
27
- userId: string;
28
- gameId: string;
29
- serverId?: string;
30
- attributes?: SessionAttributes;
31
- }) => Promise<JoinServerResult>;
32
- browseServers: (args: {
33
- gameId: string;
34
- filter?: MatchFilter;
35
- limit?: number;
36
- }) => Promise<SessionListing[]>;
37
- joinByCode: (args: {
38
- userId: string;
39
- gameId: string;
40
- code: string;
41
- }) => Promise<JoinServerResult | null>;
42
- leaveServer: (args: {
43
- userId: string;
44
- serverId: string;
45
- }) => Promise<void>;
46
- runCommand: (args: {
47
- userId: string;
48
- serverId: string;
49
- command: string;
50
- input: unknown;
51
- }) => Promise<TransportRunCommandResult>;
52
- getServerView: (args: {
53
- userId: string;
54
- serverId: string;
55
- }) => Promise<GameRuntimeServerView | null>;
56
- getPlayerView: (args: {
57
- userId: string;
58
- serverId: string;
59
- }) => Promise<GameRuntimePlayerView | null>;
60
- getFeed: (args: {
61
- userId: string;
62
- serverId: string;
63
- action: string;
64
- }) => Promise<unknown[]>;
65
- pushFeedEntry: (args: {
66
- userId: string;
67
- serverId: string;
68
- action: string;
69
- entry: unknown;
70
- }) => Promise<void>;
71
- listOpenServers: (args: {
72
- gameId: string;
73
- limit?: number;
74
- }) => Promise<ServerListing[]>;
75
- tickOnce: () => Promise<{
76
- ticked: number;
77
- saved: number;
78
- }>;
79
- flushAll: () => Promise<number>;
80
- start: () => void;
81
- stop: () => Promise<void>;
82
- subscribe: (listener: (event: HostChangeEvent) => void) => () => void;
83
- };
84
- export declare function createGameHost(options: GameHostOptions): GameHost;
1
+ export { createGameHost, memoryPersistence, type GameHost, type GameHostOptions, type HostChangeEvent, } from "@jgengine/ws/host";
package/dist/host.js CHANGED
@@ -1,375 +1 @@
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 { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
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-${randomUUID()}`);
17
- const persistence = options.persistence;
18
- const runtimes = new Map();
19
- for (const runtime of options.runtimes ?? []) {
20
- runtimes.set(runtime.gameId, runtime);
21
- }
22
- const resolveRuntime = (gameId) => {
23
- const existing = runtimes.get(gameId);
24
- if (existing)
25
- return existing;
26
- const fallback = createGameRuntime({ gameId, save: "none", commands: builtinCommands });
27
- runtimes.set(gameId, fallback);
28
- return fallback;
29
- };
30
- const live = new Map();
31
- const listeners = new Set();
32
- const emit = (event) => {
33
- for (const listener of listeners)
34
- listener(event);
35
- };
36
- let queue = Promise.resolve();
37
- const enqueue = (operation) => {
38
- const run = queue.then(operation);
39
- queue = run.catch(() => undefined);
40
- return run;
41
- };
42
- const hydrate = async (record) => {
43
- const runtime = resolveRuntime(record.gameId);
44
- const profiles = {};
45
- for (const userId of record.memberUserIds) {
46
- profiles[userId] = await persistence.loadProfile({ userId, gameId: record.gameId });
47
- }
48
- const chunkRecords = await persistence.loadChunks(record.serverId);
49
- const chunksByKey = {};
50
- for (const chunk of chunkRecords) {
51
- chunksByKey[chunk.chunkKey] = chunk.snapshot;
52
- }
53
- const snapshot = runtime.hydrate({
54
- gameId: record.gameId,
55
- serverId: record.serverId,
56
- serverRow: record.serverState,
57
- playersByUserId: buildHydratePlayers(record, profiles),
58
- chunksByKey,
59
- });
60
- const existing = live.get(record.serverId);
61
- if (existing)
62
- return existing;
63
- const entry = { record, snapshot };
64
- live.set(record.serverId, entry);
65
- return entry;
66
- };
67
- const getLive = async (serverId) => {
68
- const existing = live.get(serverId);
69
- if (existing)
70
- return existing;
71
- const record = await persistence.loadServer(serverId);
72
- if (record === null)
73
- return null;
74
- return hydrate(record);
75
- };
76
- const flushServer = async (entry) => {
77
- const timestamp = now();
78
- const plan = planServerPersist(entry.record, entry.snapshot, entry.record.save, timestamp);
79
- const cleared = { ...plan, server: { ...plan.server, dirtyAt: undefined } };
80
- if (persistence.savePlan) {
81
- await persistence.savePlan(cleared);
82
- }
83
- else {
84
- if (cleared.leaderboard.length > 0) {
85
- await persistence.applyLeaderboardIncrements(entry.record.gameId, cleared.leaderboard);
86
- }
87
- for (const profile of cleared.profiles) {
88
- await persistence.saveProfile(profile);
89
- }
90
- if (cleared.chunks.length > 0) {
91
- await persistence.saveChunks(entry.record.serverId, cleared.chunks);
92
- }
93
- await persistence.saveServer(cleared.server);
94
- }
95
- entry.record = cleared.server;
96
- entry.snapshot = clearDirtyFlags({ ...entry.snapshot, server: cleared.server.serverState });
97
- };
98
- const markMutated = (entry) => {
99
- const timestamp = now();
100
- entry.record = {
101
- ...entry.record,
102
- revision: entry.snapshot.revision,
103
- dirtyAt: entry.record.dirtyAt ?? timestamp,
104
- updatedAt: timestamp,
105
- };
106
- };
107
- let interval = null;
108
- const tickOnce = () => enqueue(async () => {
109
- const timestamp = now();
110
- let ticked = 0;
111
- let saved = 0;
112
- for (const entry of live.values()) {
113
- if (entry.record.status !== "running")
114
- continue;
115
- if (entry.record.memberUserIds.length === 0)
116
- continue;
117
- const elapsedMs = timestamp - entry.record.tickAnchorMs;
118
- if (elapsedMs < tickMs)
119
- continue;
120
- const runtime = resolveRuntime(entry.record.gameId);
121
- const before = entry.snapshot.revision;
122
- entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
123
- entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
124
- ticked += 1;
125
- if (entry.snapshot.revision !== before) {
126
- markMutated(entry);
127
- emit({ type: "server", serverId: entry.record.serverId });
128
- }
129
- if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
130
- await flushServer(entry);
131
- saved += 1;
132
- }
133
- }
134
- return { ticked, saved };
135
- });
136
- const collectListings = async (gameId) => {
137
- const byId = new Map();
138
- for (const record of await persistence.listServers(gameId)) {
139
- byId.set(record.serverId, record);
140
- }
141
- for (const entry of live.values()) {
142
- if (entry.record.gameId !== gameId)
143
- continue;
144
- byId.set(entry.record.serverId, entry.record);
145
- }
146
- return [...byId.values()].map((record) => ({
147
- serverId: record.serverId,
148
- gameId: record.gameId,
149
- status: record.status,
150
- visibility: record.visibility ?? "public",
151
- memberCount: record.memberUserIds.length,
152
- slotsPerServer: record.slotsPerServer,
153
- label: record.label,
154
- mode: record.mode,
155
- joinCode: record.joinCode,
156
- tags: record.tags,
157
- updatedAt: record.updatedAt,
158
- }));
159
- };
160
- const host = {
161
- joinServer: (args) => enqueue(async () => {
162
- const timestamp = now();
163
- const runtime = resolveRuntime(args.gameId);
164
- let entry = args.serverId === undefined ? null : await getLive(args.serverId);
165
- if (entry !== null && entry.record.gameId !== args.gameId) {
166
- throw new Error("Server belongs to a different game");
167
- }
168
- if (entry === null) {
169
- const attributes = args.attributes;
170
- const record = {
171
- serverId: createServerId(),
172
- gameId: args.gameId,
173
- status: "running",
174
- memberUserIds: [],
175
- slotsPerServer: defaultSlots,
176
- save: runtime.save,
177
- serverState: createEmptyServerRow(),
178
- sessionPlayers: {},
179
- revision: 0,
180
- tickAnchorMs: timestamp,
181
- createdAt: timestamp,
182
- updatedAt: timestamp,
183
- ...(attributes?.label !== undefined ? { label: attributes.label } : {}),
184
- ...(attributes?.mode !== undefined ? { mode: attributes.mode } : {}),
185
- ...(attributes?.visibility !== undefined ? { visibility: attributes.visibility } : {}),
186
- ...(attributes?.joinCode !== undefined ? { joinCode: attributes.joinCode } : {}),
187
- ...(attributes?.tags !== undefined ? { tags: attributes.tags } : {}),
188
- };
189
- entry = await hydrate(record);
190
- }
191
- const { record } = entry;
192
- if (record.memberUserIds.length >= record.slotsPerServer &&
193
- !record.memberUserIds.includes(args.userId)) {
194
- throw new Error("Server is full");
195
- }
196
- const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
197
- const isNew = profile === null;
198
- entry.record = {
199
- ...record,
200
- memberUserIds: record.memberUserIds.includes(args.userId)
201
- ? record.memberUserIds
202
- : [...record.memberUserIds, args.userId],
203
- status: "running",
204
- updatedAt: timestamp,
205
- dirtyAt: timestamp,
206
- };
207
- entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
208
- await flushServer(entry);
209
- emit({ type: "server", serverId: entry.record.serverId });
210
- emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
211
- return { serverId: entry.record.serverId, isNew };
212
- }),
213
- browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
214
- joinByCode: async (args) => {
215
- const match = findByJoinCode(await collectListings(args.gameId), args.code);
216
- if (match === null)
217
- return null;
218
- return host.joinServer({ userId: args.userId, gameId: args.gameId, serverId: match.serverId });
219
- },
220
- leaveServer: (args) => enqueue(async () => {
221
- const entry = await getLive(args.serverId);
222
- if (entry === null)
223
- return;
224
- if (!entry.record.memberUserIds.includes(args.userId))
225
- return;
226
- await flushServer(entry);
227
- const timestamp = now();
228
- const memberUserIds = entry.record.memberUserIds.filter((id) => id !== args.userId);
229
- const sessionPlayers = { ...entry.record.sessionPlayers };
230
- delete sessionPlayers[args.userId];
231
- const players = { ...entry.snapshot.players };
232
- delete players[args.userId];
233
- entry.record = {
234
- ...entry.record,
235
- memberUserIds,
236
- sessionPlayers,
237
- updatedAt: timestamp,
238
- status: memberUserIds.length === 0 ? "open" : entry.record.status,
239
- };
240
- entry.snapshot = { ...entry.snapshot, players };
241
- await persistence.saveServer(entry.record);
242
- if (memberUserIds.length === 0) {
243
- live.delete(entry.record.serverId);
244
- }
245
- emit({ type: "server", serverId: args.serverId });
246
- }),
247
- runCommand: (args) => enqueue(async () => {
248
- const entry = await getLive(args.serverId);
249
- if (entry === null) {
250
- return { ok: false, reason: "Server not found" };
251
- }
252
- if (!entry.record.memberUserIds.includes(args.userId)) {
253
- return { ok: false, reason: "Not a member of this server" };
254
- }
255
- const runtime = resolveRuntime(entry.record.gameId);
256
- const result = runtime.runCommand(entry.snapshot, args.userId, args.command, args.input);
257
- if (!result.ok) {
258
- return { ok: false, reason: result.reason };
259
- }
260
- entry.snapshot = result.snapshot;
261
- markMutated(entry);
262
- emit({ type: "server", serverId: args.serverId });
263
- emit({ type: "player", serverId: args.serverId, userId: args.userId });
264
- return { ok: true };
265
- }),
266
- getServerView: async (args) => {
267
- const entry = await getLive(args.serverId);
268
- if (entry === null)
269
- return null;
270
- if (!entry.record.memberUserIds.includes(args.userId))
271
- return null;
272
- return {
273
- serverId: entry.record.serverId,
274
- gameId: entry.record.gameId,
275
- revision: entry.snapshot.revision,
276
- memberUserIds: entry.record.memberUserIds,
277
- serverState: entry.snapshot.server,
278
- updatedAt: entry.record.updatedAt,
279
- };
280
- },
281
- getPlayerView: async (args) => {
282
- const entry = await getLive(args.serverId);
283
- if (entry === null)
284
- return null;
285
- if (!entry.record.memberUserIds.includes(args.userId))
286
- return null;
287
- const player = entry.snapshot.players[args.userId];
288
- if (player !== undefined) {
289
- return {
290
- userId: args.userId,
291
- gameId: entry.record.gameId,
292
- playerState: splitProfilePlayer(player).persistent,
293
- updatedAt: entry.record.updatedAt,
294
- };
295
- }
296
- const profile = await persistence.loadProfile({ userId: args.userId, gameId: entry.record.gameId });
297
- if (profile === null)
298
- return null;
299
- return {
300
- userId: profile.userId,
301
- gameId: profile.gameId,
302
- playerState: profile.playerState,
303
- updatedAt: profile.updatedAt,
304
- };
305
- },
306
- getFeed: async (args) => {
307
- const entry = await getLive(args.serverId);
308
- if (entry === null)
309
- return [];
310
- if (!entry.record.memberUserIds.includes(args.userId))
311
- return [];
312
- return persistence.loadFeed({ serverId: args.serverId, action: args.action });
313
- },
314
- pushFeedEntry: (args) => enqueue(async () => {
315
- const entry = await getLive(args.serverId);
316
- if (entry === null || !entry.record.memberUserIds.includes(args.userId)) {
317
- throw new Error("Not a member of this server");
318
- }
319
- await persistence.appendFeed({ serverId: args.serverId, action: args.action, entry: args.entry });
320
- emit({ type: "feed", serverId: args.serverId, action: args.action });
321
- }),
322
- listOpenServers: async (args) => {
323
- const limit = args.limit ?? 20;
324
- const byId = new Map();
325
- for (const record of await persistence.listServers(args.gameId)) {
326
- byId.set(record.serverId, toServerListing(record));
327
- }
328
- for (const entry of live.values()) {
329
- if (entry.record.gameId !== args.gameId)
330
- continue;
331
- byId.set(entry.record.serverId, toServerListing(entry.record));
332
- }
333
- return [...byId.values()]
334
- .filter((listing) => listing.status === "running")
335
- .sort((a, b) => b.updatedAt - a.updatedAt)
336
- .slice(0, limit);
337
- },
338
- tickOnce,
339
- flushAll: () => enqueue(async () => {
340
- let saved = 0;
341
- for (const entry of live.values()) {
342
- if (entry.record.dirtyAt === undefined)
343
- continue;
344
- await flushServer(entry);
345
- saved += 1;
346
- }
347
- return saved;
348
- }),
349
- start: () => {
350
- if (interval !== null)
351
- return;
352
- interval = setInterval(() => {
353
- void tickOnce();
354
- }, tickMs);
355
- },
356
- stop: async () => {
357
- if (interval !== null) {
358
- clearInterval(interval);
359
- interval = null;
360
- }
361
- await enqueue(async () => {
362
- for (const entry of live.values()) {
363
- if (entry.record.dirtyAt === undefined)
364
- continue;
365
- await flushServer(entry);
366
- }
367
- });
368
- },
369
- subscribe: (listener) => {
370
- listeners.add(listener);
371
- return () => listeners.delete(listener);
372
- },
373
- };
374
- return host;
375
- }
1
+ export { createGameHost, memoryPersistence, } from "@jgengine/ws/host";
@@ -1,4 +1,4 @@
1
1
  import type { HostPersistence } from "@jgengine/core/runtime/hostPersistence";
2
- export declare function memoryPersistence(now?: () => number): HostPersistence;
2
+ export { memoryPersistence } from "@jgengine/ws/host";
3
3
  export declare function filePersistence(dir: string, now?: () => number): HostPersistence;
4
4
  export declare function clearFilePersistence(dir: string): Promise<void>;
@@ -1,105 +1,7 @@
1
1
  import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
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
- }
3
+ import { applyLeaderboardRows, leaderboardRowKey, profileLeaderboardStats, topLeaderboardRows, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
4
+ export { memoryPersistence } from "@jgengine/ws/host";
103
5
  async function readJson(path) {
104
6
  try {
105
7
  return JSON.parse(await readFile(path, "utf8"));
@@ -203,7 +105,7 @@ export function filePersistence(dir, now = Date.now) {
203
105
  const run = leaderboardLock.then(async () => {
204
106
  await ensureDirs();
205
107
  const rows = await loadLeaderboard();
206
- applyIncrements(rows, gameId, entries, now());
108
+ applyLeaderboardRows(rows, gameId, entries, now());
207
109
  await writeJson(leaderboardPath, [...rows.values()]);
208
110
  });
209
111
  leaderboardLock = run.catch(() => undefined);
@@ -211,11 +113,11 @@ export function filePersistence(dir, now = Date.now) {
211
113
  },
212
114
  async getLeaderboardTop(args) {
213
115
  await ensureDirs();
214
- return topRows((await loadLeaderboard()).values(), args);
116
+ return topLeaderboardRows((await loadLeaderboard()).values(), args);
215
117
  },
216
118
  async getLeaderboardProfile({ gameId, userId }) {
217
119
  await ensureDirs();
218
- return profileStats((await loadLeaderboard()).values(), gameId, userId);
120
+ return profileLeaderboardStats((await loadLeaderboard()).values(), gameId, userId);
219
121
  },
220
122
  };
221
123
  }