@forgeax/engine-project
v0.1.37
Published
Game-project SSOT — zod schema + injectable loader + resolve layer for forge.json, the authoritative game manifest contract.
Readme
@forgeax/engine-project
文件系统位置是 packages/project/,公共包身份仍是 @forgeax/engine-project。目录位置用于在仓库中定位 manifest 包;公共身份用于导入其 API,二者不会因目录迁移而互换。
Game-project SSOT — zod schema + injectable loader + resolve layer for forge.json, the authoritative game manifest contract.
plugins[] stores the project plugin Entry tree. Its id, name, config, group, disabled, and inject fields follow DeepSeek Harness EntryOptions; realm is the only ForgeaX build-placement extension. Entry ids are non-empty and unique across the tree.
| Realm | Placement |
|:--|:--|
| host | Resident Node backend plugins and tools |
| frontend | Browser DOM/UI, with Host-side App controls and assets |
| engine | World/gameplay in the selected Engine execution realm |
| build | Importers, cookers and build-time tools |
{
"id": "my-game",
"name": "My Game",
"schemaVersion": "2.0.0",
"defaultScene": "00000000-0000-4000-8000-000000000001",
"plugins": [
{ "id": "gameplay", "name": "./assets/plugin.ts", "realm": "engine" }
]
}The manifest is persistent authoring state. Devkit derives a static module Catalog from it; the player never scans packages.
See AGENTS.md § "Project model" for the conceptual model.
Usage
loadGameProject is the primary entry point. Inject a reader so the loader stays free of node:fs / fetch (the same loader runs in server, editor, and tests):
import { loadGameProject, FORGE_JSON } from '@forgeax/engine/project';
import { readFile } from 'node:fs/promises';
const result = await loadGameProject((path) => readFile(`/games/my-game/${path}`, 'utf-8'));
if (result.ok) {
const project = result.value; // typed GameProject (z.infer)
console.log(project.name, project.defaultScene);
} else {
// structured, actionable failure (charter P3)
console.error(result.error.code, result.error.hint);
}Synchronous consumers (e.g. a sync ContextSlot) use the companion loadGameProjectSync — identical injection contract with a sync reader (path) => string:
import { loadGameProjectSync } from '@forgeax/engine/project';
import { readFileSync } from 'node:fs';
const r = loadGameProjectSync((path) => readFileSync(`/games/my-game/${path}`, 'utf-8'));
const name = r.ok ? r.value.name : null;resolveDefaultScene({ read, resolveGuid }) is the two-layer resolve path: it loads the project, then resolves defaultScene through an injected GUID resolver (asserting kind === 'scene').
Error codes
Every failure returns a GameProjectError with .code / .expected / .hint / .detail. Switch exhaustively on .code (closed union, no default needed):
| code | when |
|:--|:--|
| forge-missing | reader threw / forge.json not found |
| forge-parse-failed | invalid JSON |
| forge-schema-invalid | valid JSON, fails schema (missing / wrong-typed required field) |
| forge-unknown-field | .strict() rejected a field outside the schema |
| forge-guid-malformed | defaultScene present but not a valid GUID |
| forge-scene-unresolved | resolveDefaultScene could not resolve the GUID to a kind: 'scene' asset |
Schema as contract
GameProjectSchema is the authoritative field list (charter P2) — read it instead of prose, and derive types via import type { GameProject } from '@forgeax/engine/project'.
Schema 2.0.0 uses plugins[] as the persistent Entry tree; Group children use group: true with nested config. realm is the only ForgeaX placement extension, and id is the stable Entry identity.
