@jgengine/node 0.10.0 → 0.12.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/README.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # @jgengine/node
2
2
 
3
- Standalone authoritative game host for [JGengine](https://github.com/Noisemaker111/jgengine): in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence. AGPL-3.0-only.
3
+ Standalone authoritative game host for [JGengine](https://github.com/Noisemaker111/jgengine): in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence. Apache-2.0.
@@ -0,0 +1,16 @@
1
+ /** File extensions the editor treats as placeable models. */
2
+ export declare const MODEL_EXT: RegExp;
3
+ /**
4
+ * Derives the stable catalog id a model's workspace-relative path resolves under — lowercased,
5
+ * extension-stripped, non-alphanumerics collapsed to single hyphens. The folder scan and every
6
+ * durable import share this so a placement referencing the id resolves regardless of how the
7
+ * asset entered the catalog.
8
+ * @internal
9
+ */
10
+ export declare function assetIdFromRel(rel: string): string;
11
+ /**
12
+ * Strips path components and unsafe characters from an uploaded model filename, keeping its
13
+ * `.glb`/`.gltf` extension — so an untrusted upload name can never escape its target folder.
14
+ * @internal
15
+ */
16
+ export declare function safeAssetFilename(filename: string): string;
@@ -0,0 +1,32 @@
1
+ import { extname } from "node:path";
2
+ /** File extensions the editor treats as placeable models. */
3
+ export const MODEL_EXT = /\.(glb|gltf)$/i;
4
+ /**
5
+ * Derives the stable catalog id a model's workspace-relative path resolves under — lowercased,
6
+ * extension-stripped, non-alphanumerics collapsed to single hyphens. The folder scan and every
7
+ * durable import share this so a placement referencing the id resolves regardless of how the
8
+ * asset entered the catalog.
9
+ * @internal
10
+ */
11
+ export function assetIdFromRel(rel) {
12
+ const cleaned = rel
13
+ .replace(MODEL_EXT, "")
14
+ .replace(/[^a-z0-9]+/gi, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .toLowerCase();
17
+ return cleaned.length > 0 ? cleaned : "asset";
18
+ }
19
+ /**
20
+ * Strips path components and unsafe characters from an uploaded model filename, keeping its
21
+ * `.glb`/`.gltf` extension — so an untrusted upload name can never escape its target folder.
22
+ * @internal
23
+ */
24
+ export function safeAssetFilename(filename) {
25
+ const base = (filename.split(/[\\/]/).pop() ?? "").trim();
26
+ const ext = extname(base).toLowerCase();
27
+ const stem = base
28
+ .slice(0, base.length - ext.length)
29
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
30
+ .replace(/^[-.]+|[-.]+$/g, "");
31
+ return (stem.length > 0 ? stem : "asset") + ext;
32
+ }
@@ -0,0 +1,32 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { SaveEndpointResponse } from "@jgengine/core/devtools/saveEndpoint";
3
+ /** Route the dev save middleware answers on; matches `installSaveEndpoint` defaults. */
4
+ export declare const SAVE_ENDPOINT_PATH = "/__jgengine/save";
5
+ /** Filename editor scene documents are saved under inside a game's `src/`. */
6
+ export declare const EDITOR_SCENE_FILENAME = "editor.scene.json";
7
+ type SrcDirResolver = (gameId: string) => string | null;
8
+ /** Applies one parsed save request against a resolved game `src/` directory. */
9
+ export declare function handleSaveRequest(resolveSrcDir: SrcDirResolver, body: string): SaveEndpointResponse;
10
+ interface DevSavePluginShape {
11
+ name: string;
12
+ apply: "serve";
13
+ configureServer(server: {
14
+ middlewares: {
15
+ use(route: string, handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): void;
16
+ };
17
+ }): void;
18
+ }
19
+ /**
20
+ * Dev-only Vite middleware that gives the F2 chord family its write path:
21
+ * editor mode's Save (Ctrl+S / `save_scene`) writes `editor.scene.json` and
22
+ * debug mode's Tune "Save to source" rewrites tunable literals, both into the
23
+ * directory `resolveSrcDir(gameId)` returns.
24
+ */
25
+ export declare function devSavePlugin(resolveSrcDir: SrcDirResolver): DevSavePluginShape;
26
+ /**
27
+ * `devSavePlugin` preset for a standalone game project (one game, `src/` at
28
+ * the project root): every gameId maps to `<rootDir>/src`. Drop into the
29
+ * scaffolded `vite.config.ts` plugins array.
30
+ */
31
+ export declare function standaloneSavePlugin(rootDir?: string): DevSavePluginShape;
32
+ export {};
@@ -0,0 +1,142 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { rewriteTunableExport } from "@jgengine/core/devtools/rewriteTunables";
4
+ import { splitTunablePath } from "@jgengine/core/devtools/tunableSchema";
5
+ import { importEditorDocumentJson } from "@jgengine/core/editor/document";
6
+ /** Route the dev save middleware answers on; matches `installSaveEndpoint` defaults. */
7
+ export const SAVE_ENDPOINT_PATH = "/__jgengine/save";
8
+ /** Filename editor scene documents are saved under inside a game's `src/`. */
9
+ export const EDITOR_SCENE_FILENAME = "editor.scene.json";
10
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
11
+ function saveEditorDocument(dir, json) {
12
+ const document = importEditorDocumentJson(json);
13
+ const path = join(dir, EDITOR_SCENE_FILENAME);
14
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
15
+ return { ok: true, path };
16
+ }
17
+ function listSourceFiles(dir) {
18
+ const out = [];
19
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
20
+ const full = join(dir, entry.name);
21
+ if (entry.isDirectory())
22
+ out.push(...listSourceFiles(full));
23
+ else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.includes(".test."))
24
+ out.push(full);
25
+ }
26
+ return out;
27
+ }
28
+ function moduleFileForTable(dir, table) {
29
+ if (table.includes(".."))
30
+ return null;
31
+ for (const extension of [".ts", ".tsx"]) {
32
+ const candidate = join(dir, `${table}${extension}`);
33
+ if (existsSync(candidate))
34
+ return candidate;
35
+ }
36
+ return null;
37
+ }
38
+ function fileExportingTable(files, table) {
39
+ const pattern = new RegExp(`export\\s+(?:const|let)\\s+${table.replace(/[$]/g, "\\$&")}\\b`);
40
+ for (const file of files) {
41
+ if (pattern.test(readFileSync(file, "utf8")))
42
+ return file;
43
+ }
44
+ return null;
45
+ }
46
+ function saveTunables(dir, deltas) {
47
+ const files = listSourceFiles(dir);
48
+ const pending = new Map();
49
+ const skipped = [];
50
+ let applied = 0;
51
+ for (const delta of deltas) {
52
+ const moduleFile = moduleFileForTable(dir, delta.table);
53
+ const file = moduleFile ?? fileExportingTable(files, delta.table);
54
+ if (file === null) {
55
+ skipped.push({ table: delta.table, key: delta.key, reason: "no source file found" });
56
+ continue;
57
+ }
58
+ const exportName = moduleFile !== null ? delta.key : delta.table;
59
+ const path = moduleFile !== null ? [] : splitTunablePath(delta.key);
60
+ const code = pending.get(file) ?? readFileSync(file, "utf8");
61
+ const rewritten = rewriteTunableExport(code, exportName, path, delta.value);
62
+ if (rewritten === null) {
63
+ skipped.push({ table: delta.table, key: delta.key, reason: "could not locate literal" });
64
+ continue;
65
+ }
66
+ pending.set(file, rewritten);
67
+ applied += 1;
68
+ }
69
+ for (const [file, code] of pending)
70
+ writeFileSync(file, code);
71
+ return { ok: true, applied, skipped };
72
+ }
73
+ function readBody(req) {
74
+ return new Promise((resolvePromise, reject) => {
75
+ let size = 0;
76
+ const chunks = [];
77
+ req.on("data", (chunk) => {
78
+ size += chunk.length;
79
+ if (size > MAX_BODY_BYTES) {
80
+ reject(new Error("request body too large"));
81
+ req.destroy();
82
+ return;
83
+ }
84
+ chunks.push(chunk);
85
+ });
86
+ req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
87
+ req.on("error", reject);
88
+ });
89
+ }
90
+ /** Applies one parsed save request against a resolved game `src/` directory. */
91
+ export function handleSaveRequest(resolveSrcDir, body) {
92
+ const request = JSON.parse(body);
93
+ const dir = resolveSrcDir(request.gameId);
94
+ if (dir === null)
95
+ return { ok: false, error: `unknown game: ${request.gameId}` };
96
+ if (request.kind === "editor-document")
97
+ return saveEditorDocument(dir, request.json);
98
+ if (request.kind === "tunables")
99
+ return saveTunables(dir, request.deltas);
100
+ return { ok: false, error: "unknown save kind" };
101
+ }
102
+ /**
103
+ * Dev-only Vite middleware that gives the F2 chord family its write path:
104
+ * editor mode's Save (Ctrl+S / `save_scene`) writes `editor.scene.json` and
105
+ * debug mode's Tune "Save to source" rewrites tunable literals, both into the
106
+ * directory `resolveSrcDir(gameId)` returns.
107
+ */
108
+ export function devSavePlugin(resolveSrcDir) {
109
+ return {
110
+ name: "jgengine-dev-save",
111
+ apply: "serve",
112
+ configureServer(server) {
113
+ server.middlewares.use(SAVE_ENDPOINT_PATH, (req, res, next) => {
114
+ if (req.method !== "POST") {
115
+ next();
116
+ return;
117
+ }
118
+ void readBody(req)
119
+ .then((body) => {
120
+ const response = handleSaveRequest(resolveSrcDir, body);
121
+ res.statusCode = response.ok ? 200 : 400;
122
+ res.setHeader("content-type", "application/json");
123
+ res.end(JSON.stringify(response));
124
+ })
125
+ .catch((error) => {
126
+ res.statusCode = 400;
127
+ res.setHeader("content-type", "application/json");
128
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
129
+ });
130
+ });
131
+ },
132
+ };
133
+ }
134
+ /**
135
+ * `devSavePlugin` preset for a standalone game project (one game, `src/` at
136
+ * the project root): every gameId maps to `<rootDir>/src`. Drop into the
137
+ * scaffolded `vite.config.ts` plugins array.
138
+ */
139
+ export function standaloneSavePlugin(rootDir = process.cwd()) {
140
+ const srcDir = resolve(rootDir, "src");
141
+ return devSavePlugin(() => (existsSync(srcDir) ? srcDir : null));
142
+ }
@@ -0,0 +1,51 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ /** One placeable model the standalone editor lists — a stable id and a URL the dev server serves it from. */
3
+ export interface EditorManifestAsset {
4
+ id: string;
5
+ url: string;
6
+ label: string;
7
+ }
8
+ /** What the standalone editor loads on boot: the on-disk scene document (if any) and every model in the asset folder. */
9
+ export interface EditorManifest {
10
+ scene: unknown | null;
11
+ assets: EditorManifestAsset[];
12
+ }
13
+ /**
14
+ * Reads the scene document and scans the asset folder — the pure core the dev server serves at `/__jgengine/manifest`.
15
+ * @internal
16
+ */
17
+ export declare function buildEditorManifest(sceneDir: string, assetsDir: string): EditorManifest;
18
+ /**
19
+ * Persists an uploaded model into the asset folder so it survives reload and the manifest scan re-lists it
20
+ * under the same id — the durable counterpart to the standalone editor's ephemeral blob imports. Returns the
21
+ * manifest entry (id/url/label) the scan would produce for the written file. The pure core `/__jgengine/import-asset` serves.
22
+ * @internal
23
+ */
24
+ export declare function importEditorAsset(assetsDir: string, filename: string, bytes: Uint8Array): EditorManifestAsset;
25
+ interface DevServerLike {
26
+ middlewares: {
27
+ use(route: string, handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): void;
28
+ };
29
+ }
30
+ interface EditorHostPluginShape {
31
+ name: string;
32
+ apply: "serve";
33
+ configureServer(server: DevServerLike): void;
34
+ }
35
+ /** Where the standalone editor reads and writes: the scene folder and the model folder it scans. */
36
+ export interface EditorHostOptions {
37
+ /** Directory the scene document lives in and Save writes back to. Default `process.cwd()`. */
38
+ dir?: string;
39
+ /** Directory scanned for placeable `.glb`/`.gltf` models. Default `<dir>/assets` if present, else `<dir>`. */
40
+ assetsDir?: string;
41
+ }
42
+ /**
43
+ * Dev-server plugin that turns any folder into a standalone-editor workspace: it serves the folder's
44
+ * `editor.scene.json` and every model under it at `/__jgengine/manifest`, streams the model bytes at
45
+ * `/__jgengine/assets/*`, persists uploaded models into the asset folder at `POST /__jgengine/import-asset`
46
+ * (so a dropped GLB survives reload as a durable catalog asset), and writes Save back to `editor.scene.json`
47
+ * — the server half of `jgengine editor`, drop-in for a Vite dev config.
48
+ * @internal
49
+ */
50
+ export declare function editorHostPlugin(options?: EditorHostOptions): EditorHostPluginShape;
51
+ export {};
@@ -0,0 +1,153 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { extname, join, relative, resolve, sep } from "node:path";
3
+ import { MODEL_EXT, assetIdFromRel, safeAssetFilename } from "./assetNaming.js";
4
+ import { EDITOR_SCENE_FILENAME, devSavePlugin } from "./devSavePlugin.js";
5
+ import { importPromotedAsset, isPromotedProject } from "./promotedAssetCatalog.js";
6
+ const ASSET_ROUTE = "/__jgengine/assets/";
7
+ const MANIFEST_ROUTE = "/__jgengine/manifest";
8
+ const IMPORT_ROUTE = "/__jgengine/import-asset";
9
+ const MAX_ASSET_BYTES = 128 * 1024 * 1024;
10
+ function walkModels(root, dir = root) {
11
+ if (!existsSync(dir))
12
+ return [];
13
+ const out = [];
14
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
15
+ const full = join(dir, entry.name);
16
+ if (entry.isDirectory())
17
+ out.push(...walkModels(root, full));
18
+ else if (MODEL_EXT.test(entry.name))
19
+ out.push(relative(root, full));
20
+ }
21
+ return out.sort();
22
+ }
23
+ /**
24
+ * Reads the scene document and scans the asset folder — the pure core the dev server serves at `/__jgengine/manifest`.
25
+ * @internal
26
+ */
27
+ export function buildEditorManifest(sceneDir, assetsDir) {
28
+ const scenePath = join(sceneDir, EDITOR_SCENE_FILENAME);
29
+ let scene = null;
30
+ if (existsSync(scenePath)) {
31
+ try {
32
+ scene = JSON.parse(readFileSync(scenePath, "utf8"));
33
+ }
34
+ catch {
35
+ scene = null;
36
+ }
37
+ }
38
+ const assets = walkModels(assetsDir).map((rel) => {
39
+ const web = rel.split(sep).join("/");
40
+ return { id: assetIdFromRel(web), url: ASSET_ROUTE + web, label: web };
41
+ });
42
+ return { scene, assets };
43
+ }
44
+ /**
45
+ * Persists an uploaded model into the asset folder so it survives reload and the manifest scan re-lists it
46
+ * under the same id — the durable counterpart to the standalone editor's ephemeral blob imports. Returns the
47
+ * manifest entry (id/url/label) the scan would produce for the written file. The pure core `/__jgengine/import-asset` serves.
48
+ * @internal
49
+ */
50
+ export function importEditorAsset(assetsDir, filename, bytes) {
51
+ if (!MODEL_EXT.test(filename)) {
52
+ throw new Error(`unsupported asset file (expected .glb/.gltf): ${filename}`);
53
+ }
54
+ const safe = safeAssetFilename(filename);
55
+ mkdirSync(assetsDir, { recursive: true });
56
+ writeFileSync(join(assetsDir, safe), bytes);
57
+ return { id: assetIdFromRel(safe), url: ASSET_ROUTE + safe, label: safe };
58
+ }
59
+ function readBodyBuffer(req) {
60
+ return new Promise((resolvePromise, reject) => {
61
+ let size = 0;
62
+ const chunks = [];
63
+ req.on("data", (chunk) => {
64
+ size += chunk.length;
65
+ if (size > MAX_ASSET_BYTES) {
66
+ reject(new Error("asset upload too large"));
67
+ req.destroy();
68
+ return;
69
+ }
70
+ chunks.push(chunk);
71
+ });
72
+ req.on("end", () => resolvePromise(Buffer.concat(chunks)));
73
+ req.on("error", reject);
74
+ });
75
+ }
76
+ /**
77
+ * Dev-server plugin that turns any folder into a standalone-editor workspace: it serves the folder's
78
+ * `editor.scene.json` and every model under it at `/__jgengine/manifest`, streams the model bytes at
79
+ * `/__jgengine/assets/*`, persists uploaded models into the asset folder at `POST /__jgengine/import-asset`
80
+ * (so a dropped GLB survives reload as a durable catalog asset), and writes Save back to `editor.scene.json`
81
+ * — the server half of `jgengine editor`, drop-in for a Vite dev config.
82
+ * @internal
83
+ */
84
+ export function editorHostPlugin(options = {}) {
85
+ const dir = resolve(options.dir ?? process.cwd());
86
+ const assetsDir = resolve(options.assetsDir ?? (existsSync(join(dir, "assets")) ? join(dir, "assets") : dir));
87
+ const save = devSavePlugin(() => dir);
88
+ return {
89
+ name: "jgengine-editor-host",
90
+ apply: "serve",
91
+ configureServer(server) {
92
+ save.configureServer(server);
93
+ server.middlewares.use(MANIFEST_ROUTE, (req, res, next) => {
94
+ if (req.method !== "GET") {
95
+ next();
96
+ return;
97
+ }
98
+ res.setHeader("content-type", "application/json");
99
+ res.end(JSON.stringify(buildEditorManifest(dir, assetsDir)));
100
+ });
101
+ server.middlewares.use(IMPORT_ROUTE, (req, res, next) => {
102
+ if (req.method !== "POST") {
103
+ next();
104
+ return;
105
+ }
106
+ const header = req.headers["x-jg-filename"];
107
+ const rawName = Array.isArray(header) ? header[0] : header;
108
+ void readBodyBuffer(req)
109
+ .then((bytes) => {
110
+ let filename;
111
+ try {
112
+ filename = decodeURIComponent(rawName ?? "");
113
+ }
114
+ catch {
115
+ filename = rawName ?? "";
116
+ }
117
+ let asset;
118
+ if (isPromotedProject(dir)) {
119
+ // Promoted projects resolve assets through a typed `src/game/assets.ts` catalog, so
120
+ // persist a durable `extras` entry there. A thrown transform error (unparseable or
121
+ // multi-call source) falls back to the folder-scan import, preserving the contract.
122
+ try {
123
+ asset = importPromotedAsset(dir, filename, bytes);
124
+ }
125
+ catch {
126
+ asset = importEditorAsset(assetsDir, filename, bytes);
127
+ }
128
+ }
129
+ else {
130
+ asset = importEditorAsset(assetsDir, filename, bytes);
131
+ }
132
+ res.setHeader("content-type", "application/json");
133
+ res.end(JSON.stringify(asset));
134
+ })
135
+ .catch((error) => {
136
+ res.statusCode = 400;
137
+ res.setHeader("content-type", "application/json");
138
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
139
+ });
140
+ });
141
+ server.middlewares.use(ASSET_ROUTE, (req, res, next) => {
142
+ const rel = decodeURIComponent((req.url ?? "").split("?")[0]).replace(/^\/+/, "");
143
+ const full = resolve(assetsDir, rel);
144
+ if (!full.startsWith(assetsDir) || !existsSync(full) || !statSync(full).isFile()) {
145
+ next();
146
+ return;
147
+ }
148
+ res.setHeader("content-type", extname(full).toLowerCase() === ".glb" ? "model/gltf-binary" : "model/gltf+json");
149
+ res.end(readFileSync(full));
150
+ });
151
+ },
152
+ };
153
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./host.js";
2
2
  export * from "./persistence.js";
3
+ export * from "./shutdown.js";
3
4
  export * from "./socketIoServer.js";
4
5
  export * from "./webHandler.js";
5
6
  export * from "./worldServer.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./host.js";
2
2
  export * from "./persistence.js";
3
+ export * from "./shutdown.js";
3
4
  export * from "./socketIoServer.js";
4
5
  export * from "./webHandler.js";
5
6
  export * from "./worldServer.js";
@@ -1,4 +1,21 @@
1
1
  import type { HostPersistence } from "@jgengine/core/runtime/hostPersistence";
2
+ import { type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
2
3
  export { memoryPersistence } from "@jgengine/ws/host";
4
+ /** Per-world key a {@link WorldPersistence} resolves a {@link HostedWorldStore} for. */
5
+ export interface WorldPersistenceKey {
6
+ gameId: string;
7
+ serverId: string;
8
+ }
9
+ /**
10
+ * The persistence plug-point for {@link createWorldGameServer}: resolves one {@link HostedWorldStore}
11
+ * per hosted world, called once when the world host session is created. Structural — a SQL, file, or
12
+ * Convex-backed store all conform without `node` importing a concrete driver; only a `store()` factory
13
+ * is required. Mirrors `HostPersistence` (the reducer host's persistence seam).
14
+ */
15
+ export interface WorldPersistence {
16
+ store(key: WorldPersistenceKey): HostedWorldStore;
17
+ }
18
+ /** Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. */
19
+ export declare function memoryWorldPersistence(): WorldPersistence;
3
20
  export declare function filePersistence(dir: string, now?: () => number): HostPersistence;
4
21
  export declare function clearFilePersistence(dir: string): Promise<void>;
@@ -2,7 +2,23 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { applyLeaderboardRows, leaderboardRowKey, profileLeaderboardStats, topLeaderboardRows, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
5
+ import { memoryWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
5
6
  export { memoryPersistence } from "@jgengine/ws/host";
7
+ /** Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. */
8
+ export function memoryWorldPersistence() {
9
+ const stores = new Map();
10
+ return {
11
+ store({ gameId, serverId }) {
12
+ const key = `${gameId} ${serverId}`;
13
+ const existing = stores.get(key);
14
+ if (existing !== undefined)
15
+ return existing;
16
+ const created = memoryWorldStore();
17
+ stores.set(key, created);
18
+ return created;
19
+ },
20
+ };
21
+ }
6
22
  function isMissing(error) {
7
23
  return error?.code === "ENOENT";
8
24
  }
@@ -26,7 +42,11 @@ async function stageJson(path, value) {
26
42
  async function writeJson(path, value) {
27
43
  await rename(await stageJson(path, value), path);
28
44
  }
29
- const enc = encodeURIComponent;
45
+ function encodeSegment(key) {
46
+ const encoded = encodeURIComponent(key);
47
+ return encoded === "." || encoded === ".." ? encoded.replaceAll(".", "%2E") : encoded;
48
+ }
49
+ const enc = encodeSegment;
30
50
  export function filePersistence(dir, now = Date.now) {
31
51
  const serversDir = join(dir, "servers");
32
52
  const profilesDir = join(dir, "profiles");
@@ -130,8 +150,15 @@ export function filePersistence(dir, now = Date.now) {
130
150
  await writeJson(path, entries);
131
151
  return entries;
132
152
  });
133
- feedLocks.set(path, run.catch(() => undefined));
134
- return run;
153
+ const tracked = run.catch(() => undefined);
154
+ feedLocks.set(path, tracked);
155
+ try {
156
+ return await run;
157
+ }
158
+ finally {
159
+ if (feedLocks.get(path) === tracked)
160
+ feedLocks.delete(path);
161
+ }
135
162
  },
136
163
  async applyLeaderboardIncrements(gameId, entries) {
137
164
  const run = leaderboardLock.then(async () => {
@@ -0,0 +1,40 @@
1
+ import type { EditorManifestAsset } from "./editorHostPlugin.js";
2
+ /**
3
+ * A durable catalog entry the editor writes into a promoted game's `src/game/assets.ts` — the
4
+ * `extras` literal `buildCatalog` consumes. Mirrors `@jgengine/assets`' `CatalogExtra`, but `label`
5
+ * is required here because the source literal always carries a human-facing name.
6
+ * @internal
7
+ */
8
+ export interface PromotedExtra {
9
+ id: string;
10
+ url: string;
11
+ label: string;
12
+ }
13
+ /**
14
+ * True when `dir` is an already-promoted game project: one whose assets resolve through a typed
15
+ * `src/game/assets.ts` catalog rather than a scanned model folder.
16
+ * @internal
17
+ */
18
+ export declare function isPromotedProject(dir: string): boolean;
19
+ /**
20
+ * Adds or replaces one `extras` entry in a promoted game's `src/game/assets.ts` and returns the
21
+ * rewritten text. PURE and deterministic — it does not touch the filesystem.
22
+ *
23
+ * A bounded, non-AST transform: it locates the single `buildCatalog({ ... })` call by brace-depth
24
+ * scan, then upserts an id-keyed entry into that call's `extras: [ ... ]` array (creating the array
25
+ * when absent, collapsing it back to no `extras` when it would become empty). Re-import of the same
26
+ * id replaces its entry in place, so the transform is idempotent and never duplicates an id. If the
27
+ * source has zero or more than one `buildCatalog(` call it THROWS, so the caller falls back to the
28
+ * legacy folder-scan import rather than risk corrupting the file.
29
+ * @internal
30
+ */
31
+ export declare function upsertCatalogExtra(source: string, extra: PromotedExtra): string;
32
+ /**
33
+ * Persists an imported model into a promoted game's `public/` and registers a durable `extras`
34
+ * entry in its `src/game/assets.ts`, so the shipped game resolves the asset through its typed
35
+ * catalog. Copies the bytes under `public/<basePath>/imported/<safe>` and rewrites the catalog
36
+ * source via {@link upsertCatalogExtra}. Returns the same `{ id, url, label }` shape the folder-scan
37
+ * `importEditorAsset` returns — but the url is the shipped `public/` path, not the dev asset route.
38
+ * @internal
39
+ */
40
+ export declare function importPromotedAsset(dir: string, filename: string, bytes: Uint8Array): EditorManifestAsset;