@jgengine/node 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 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
@@ -0,0 +1,6 @@
1
+ export * from "./host.js";
2
+ export * from "./persistence.js";
3
+ export * from "./socketIoServer.js";
4
+ export * from "./webHandler.js";
5
+ export * from "./worldServer.js";
6
+ export * from "./wsServer.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./host.js";
2
+ export * from "./persistence.js";
3
+ export * from "./socketIoServer.js";
4
+ export * from "./webHandler.js";
5
+ export * from "./worldServer.js";
6
+ export * from "./wsServer.js";
@@ -1,19 +1,30 @@
1
- import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
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
4
  import { applyLeaderboardRows, leaderboardRowKey, profileLeaderboardStats, topLeaderboardRows, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
4
5
  export { memoryPersistence } from "@jgengine/ws/host";
6
+ function isMissing(error) {
7
+ return error?.code === "ENOENT";
8
+ }
5
9
  async function readJson(path) {
10
+ let text;
6
11
  try {
7
- return JSON.parse(await readFile(path, "utf8"));
12
+ text = await readFile(path, "utf8");
8
13
  }
9
- catch {
10
- return null;
14
+ catch (error) {
15
+ if (isMissing(error))
16
+ return null;
17
+ throw error;
11
18
  }
19
+ return JSON.parse(text);
12
20
  }
13
- async function writeJson(path, value) {
14
- const temp = `${path}.tmp`;
21
+ async function stageJson(path, value) {
22
+ const temp = `${path}.${randomUUID()}.tmp`;
15
23
  await writeFile(temp, JSON.stringify(value), "utf8");
16
- await rename(temp, path);
24
+ return temp;
25
+ }
26
+ async function writeJson(path, value) {
27
+ await rename(await stageJson(path, value), path);
17
28
  }
18
29
  const enc = encodeURIComponent;
19
30
  export function filePersistence(dir, now = Date.now) {
@@ -32,6 +43,7 @@ export function filePersistence(dir, now = Date.now) {
32
43
  return new Map(rows.map((row) => [leaderboardRowKey(row), row]));
33
44
  };
34
45
  let leaderboardLock = Promise.resolve();
46
+ const feedLocks = new Map();
35
47
  return {
36
48
  async loadServer(serverId) {
37
49
  await ensureDirs();
@@ -86,9 +98,24 @@ export function filePersistence(dir, now = Date.now) {
86
98
  await ensureDirs();
87
99
  const serverDir = join(chunksDir, enc(serverId));
88
100
  await mkdir(serverDir, { recursive: true });
89
- for (const record of records) {
90
- await writeJson(join(serverDir, `${enc(record.chunkKey)}.json`), record);
91
- }
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
+ }));
92
119
  },
93
120
  async loadFeed({ serverId, action }) {
94
121
  await ensureDirs();
@@ -97,9 +124,14 @@ export function filePersistence(dir, now = Date.now) {
97
124
  async appendFeed({ serverId, action, entry }) {
98
125
  await ensureDirs();
99
126
  const path = join(feedsDir, `${enc(serverId)}__${enc(action)}.json`);
100
- const entries = trimFeedEntries([...((await readJson(path)) ?? []), entry]);
101
- await writeJson(path, entries);
102
- return entries;
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;
103
135
  },
104
136
  async applyLeaderboardIncrements(gameId, entries) {
105
137
  const run = leaderboardLock.then(async () => {
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  export type WebHandler = (request: Request) => Promise<Response>;
3
3
  export type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void;
4
- export declare function toWebRequest(req: IncomingMessage): Request;
4
+ export declare function toWebRequest(req: IncomingMessage): Promise<Request>;
5
5
  export declare function toNodeHandler(handler: WebHandler): NodeHandler;
@@ -1,4 +1,4 @@
1
- export function toWebRequest(req) {
1
+ export async function toWebRequest(req) {
2
2
  const host = req.headers.host ?? "localhost";
3
3
  const headers = new Headers();
4
4
  for (const [key, value] of Object.entries(req.headers)) {
@@ -8,11 +8,24 @@ export function toWebRequest(req) {
8
8
  for (const entry of value)
9
9
  headers.append(key, entry);
10
10
  }
11
- return new Request(`http://${host}${req.url ?? "/"}`, { method: req.method, headers });
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 });
12
24
  }
13
25
  export function toNodeHandler(handler) {
14
26
  return (req, res) => {
15
- void handler(toWebRequest(req))
27
+ void toWebRequest(req)
28
+ .then(handler)
16
29
  .then(async (response) => {
17
30
  res.statusCode = response.status;
18
31
  response.headers.forEach((value, key) => res.setHeader(key, value));
@@ -0,0 +1,34 @@
1
+ import type { GameDefinition } from "@jgengine/core/game/defineGame";
2
+ import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
3
+ import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
4
+ import { type WorldGameHost } from "@jgengine/ws/worldHost";
5
+ import { type GameWsServer, type GameWsServerOptions } from "./wsServer.js";
6
+ /** A game the world server can host — its authoritative {@link GameDefinition} and the content lookup a `GameContext` reads. */
7
+ export interface HostedGameDefinition {
8
+ game: GameDefinition<ModelAssetRef, unknown>;
9
+ content: GameContextContent;
10
+ }
11
+ /** Config for {@link createWorldGameServer}: how to resolve a game by id, the tick cadence, and the underlying ws-server/router options (minus `host`, which the server builds). */
12
+ export interface WorldGameServerOptions extends Omit<GameWsServerOptions, "host"> {
13
+ /** Resolve a game to host by id — `null` for an unknown id, which rejects the join. */
14
+ resolveGame(gameId: string): HostedGameDefinition | null;
15
+ /** Ticks per second for the real-clock loop {@link WorldGameServer.start} drives; default 30. Tests call {@link WorldGameServer.tick} manually instead. */
16
+ tickHz?: number;
17
+ }
18
+ /** A runnable ws host for GameContext worlds: {@link createWorldGameHost} + {@link createGameWsServer} + a tick loop, with a manual `tick(dt)` seam so a fake clock can drive it in tests. */
19
+ export interface WorldGameServer {
20
+ host: WorldGameHost;
21
+ ws: GameWsServer;
22
+ /** Advance every live world by `dtSeconds` — the manual/test drive point; {@link start} calls it on a real-clock interval. */
23
+ tick(dtSeconds: number): void;
24
+ /** Begin driving {@link tick} on a real-clock interval at `tickHz` (idempotent). */
25
+ start(): void;
26
+ /** Stop the tick interval (idempotent). */
27
+ stop(): void;
28
+ /** Stop the tick loop and tear down the ws server. */
29
+ close(): Promise<void>;
30
+ /** The bound ws port. */
31
+ port(): number;
32
+ }
33
+ /** Build a {@link WorldGameServer} — one process hosting authoritative GameContext worlds over ws, ready for two-client play once {@link WorldGameServer.start} runs. */
34
+ export declare function createWorldGameServer(options: WorldGameServerOptions): WorldGameServer;
@@ -0,0 +1,51 @@
1
+ import { createHostedWorldSession } from "@jgengine/core/runtime/hostedWorldSession";
2
+ import { createWorldGameHost } from "@jgengine/ws/worldHost";
3
+ import { createGameWsServer } from "./wsServer.js";
4
+ const DEFAULT_TICK_HZ = 30;
5
+ /** Build a {@link WorldGameServer} — one process hosting authoritative GameContext worlds over ws, ready for two-client play once {@link WorldGameServer.start} runs. */
6
+ export function createWorldGameServer(options) {
7
+ const { resolveGame, tickHz = DEFAULT_TICK_HZ, ...serverOptions } = options;
8
+ const clock = options.now ?? (() => Date.now());
9
+ const host = createWorldGameHost({
10
+ now: clock,
11
+ session: ({ gameId }) => {
12
+ const resolved = resolveGame(gameId);
13
+ if (resolved === null)
14
+ return null;
15
+ return createHostedWorldSession({ definition: resolved.game, content: resolved.content, now: clock });
16
+ },
17
+ });
18
+ const ws = createGameWsServer({ ...serverOptions, host });
19
+ let interval = null;
20
+ let last = clock();
21
+ function tick(dtSeconds) {
22
+ host.tick(dtSeconds);
23
+ }
24
+ function stop() {
25
+ if (interval !== null) {
26
+ clearInterval(interval);
27
+ interval = null;
28
+ }
29
+ }
30
+ return {
31
+ host,
32
+ ws,
33
+ tick,
34
+ stop,
35
+ port: ws.port,
36
+ start() {
37
+ if (interval !== null)
38
+ return;
39
+ last = clock();
40
+ interval = setInterval(() => {
41
+ const current = clock();
42
+ tick((current - last) / 1000);
43
+ last = current;
44
+ }, 1000 / tickHz);
45
+ },
46
+ async close() {
47
+ stop();
48
+ await ws.close();
49
+ },
50
+ };
51
+ }