# Editor Architecture **D40** — the engine gains a **graphical creation tool**: a desktop-first (no more browser targets, D7/D8 overturned) single Electron application that is **editor AND runner in one** (RPG-Maker style), implemented as the `apps/editor` package in vertical slices. Design decisions (D40) are the source of truth for *why*; this document is the *how* — package structure, process responsibilities, the IPC contract, and the round roadmap. ## D40 Decisions (18-question grill summary) 1. **Desktop-first, no more browser targets**: distribution target is desktop (Electron — Electron IS Chromium; "no browser" means no pure-browser distribution goal, not abandoning Web tech). WS multi-viewport (D24), static-site packaging (D30), browser Mod execution (D36) stay **as-is, degraded to dev/debug aids, no longer extended**. 2. **One Electron app, edit + run together**: no separate runner app; press play → playtest in place. 3. **Editor v1 scope**: visual map editing (tile palette, ground painting, spawn point, walkable visualization); scene entity placement (buildings/animals/NPCs/plot prefabs); content forms for all content kinds; embedded playtesting. Behavior-language node-graph editing is **v2**. 4. **Engine integration**: embedded engine (local mode — zero protocol, synchronous preview) + the **shared authoring layer** (`packages/mcp/src/authoring.ts` — `validateDef`/`writeContentFile`/`updateContentFile`) so AI-via-MCP and human-via-GUI produce byte-identical content. This surfaced map writing (`map.*` validate/create/update). 5. **Embedded MCP server, same instance, same world**: an in-process MCP gateway (stdio + streamable HTTP) on the open project's engine instance; AI tools connect to view/create/debug/playtest the CURRENT project. The GUI never goes through the MCP protocol (it uses engine APIs + the shared authoring layer directly). Full exposure, no permission narrowing. 6. **Project = folder**: any folder with `content/` + `mods/` conventions is a project (examples/sample-game works out of the box). **`project.yaml` is packaging metadata ONLY** (name/icon/pack flags) — NOT a project definition. 7. **Schema-driven universal form generator**: content forms derive from the zod schemas — no hand-written per-kind forms (schema change → forms follow). 8. **Packaging reuses the nengine-pack pipeline**: the Package dialog calls `nengine-pack` with fields prefilled from `project.yaml`. 9. **Scene placement persistence**: the `scene` content kind (`content/scenes/.yaml`) — initial entity lists seeded at engine start (D40.9, built). 10. **Playtesting = same-process independent engine instance**: Play → save all content → spawn a SEPARATE `createEngine` (reads the latest contentDir) → take over rendering + input → exit destroys the instance. State isolation; the embedded MCP server stays bound to the EDIT instance. 11. **Mod management = display + lightweight enable/disable**: a Mods panel lists `mods/` entries with an enable/disable toggle (whether a Mod participates in playtesting/packaging is content policy). No code editing — "open in external editor" suffices. 12. **New project = built-in minimal playable template** (time + weather + map + scene + base item/crop + player) — built this round (E1). 13. **App layer = lightweight**: single-project editing (open = switch), recent-open list (Electron local storage, restore on launch), panel layout memory. No plugin market, no accounts. 14. **Engineering = new `apps/editor` package**: NOT a dev-app takeover (dev-app stays the game runner/pack source). Main process: project file IO (Node fs) + embedded engine + embedded MCP server; renderer: the editor UI. Key rejected candidates: CLI-only authoring, native rewrite, two separate Electron apps, dev-app takeover, editor-over-MCP (dogfooding), mandatory project file, hand-written per-kind forms, in-editor packaging reimplementation, scene entities folded into the map schema, playtest as a mode toggle of the shared instance, a full Mod IDE, IDE-ification (multi-tabs/rich layout), MCP permission narrowing, deleting the WS/static-site/browser capabilities. ## Package Structure ``` apps/editor/ package.json # @nengine/editor; deps @nengine/core + @nengine/mcp (E6 embedded gateway) + @nengine/pack (main compile) + @nengine/render (playtest viewport) + yaml + zod; devDeps @modelcontextprotocol/sdk (E6, main-process-only) + electron + typescript + vite + vitest + @types/node tsconfig.main.json # ESM emit → dist/main (main process + pure logic modules, incl. the mcp-stdio entry) tsconfig.preload.json # CJS emit → dist/preload (sandboxed preload + ipc runtime consts) tsconfig.renderer.json # type-check only (noEmit) — vite emits dist/renderer vite.config.ts # renderer build (root = apps/editor, outDir dist/renderer) + vitest (test/**) index.html # renderer shell (vanilla DOM + inline CSS, apps/dev precedent) src/ ipc.ts # shared channel names + strictly typed payload types (main/preload/renderer all import) main.ts # Electron main: BrowserWindow + local HTTP server for the built renderer + all ipcMain.handle (incl. E7 `pack:run` calling `runEditorPack`) + E6 editing-engine/MCP lifecycle + --mcp-stdio headless branch mcp-server.ts # pure E6 MCP wiring: createGateway/createNengineMcpServer (apps/mcp-server parity), createEditorMcpHttpServer (persistent streamable HTTP server, attach/detach per project), runStdioMcp, resolveEditorMcpDirs edit-engine.ts # pure E6 engine lifecycle: createEditEngine(root) (createEngine({contentDir, modsDir?})), contentKindForPath, syncContentWrite (GUI-written file → engine registry, dual-channel) mcp-stdio.ts # pure-node headless entry: `node dist/main/mcp-stdio.js --dir ` — the editor binary as a drop-in stdio MCP server preload.ts # contextBridge → window.nengine.project + window.nengine.playtest + window.nengine.mcp + window.nengine.pack (CJS, sandboxed — Electron requires CJS preloads with sandbox on) project.ts # pure: openProject/inspectProject, resolveWithin (traversal guard), readYaml/writeYaml, listContent, stats project-yaml.ts # pure: projectYamlSchema (zod strict) + readProjectYaml/writeProjectYaml pack-options.ts # pure E7 option merge: dialogDefaultsFromProject / projectYamlFromPack / resolvePackRequest / packHints (shared by renderer dialog + main pack step; no node imports) pack.ts # pure E7 main-side step: runEditorPack(root, options, hasMods) → packGame from @nengine/pack → {ok, outDir, stats} | {ok:false, error} recent.ts # pure: addRecent (cap 10, dedupe by root), loadRecent/saveRecent (tolerant of missing/corrupt files) template.ts # pure: newProject(name, dir) — generates the minimal playable template and boot-validates via createEngine shim/ fs.ts / fs-promises.ts / path.ts / url.ts # E5 renderer node:* shims (dev-app-proven): bundle mode does ZERO fs calls at runtime, so the stubs reject/no-op renderer/ main.ts # editor UI shell: home screen (recent/open/new) → editor screen (top bar incl. E6 MCP URL + Copy button + E7 Package button, content sidebar + editor panel); Play button + playtest screen switch pack-dialog.ts # thin E7 DOM wiring: Package dialog overlay (fields pre-filled from project.yaml, busy state, result/error + hints), save-back to project.yaml on run map-model.ts # pure E2 map model: createMapModel/paint/erase/setSpawn/toggleWalkable/addTileId/serialize/tileColor/collectTileIds (+ isWalkableTile fallback) map-editor.ts # thin E2 DOM wiring: toolbar (paint/erase/spawn/walkable + Save), palette, 32px canvas grid, mouse handlers; registers an E5 onFlush collector scene-model.ts # pure E4 scene model: createSceneModel + building/npc/animal CRUD (footprint-aware overlap), entityAt/serialize/colors scene-editor.ts # thin E4 DOM wiring: shared map+scene canvas, Edit Scene/Edit Map mode toggle, entity palette + list, Save; registers an E5 onFlush collector (scene + map) form-schema.ts # pure E3 derivation: zod schema → FormSchema (field types/enums/optional/refs/unions/records), reference heuristic, humanizeId form-value.ts # pure E3 value model: initialValue/applyValue/coerceForValidate (lossless overlay + roundtrip), union detection, getPath/setPath form-panel.ts # thin E3 DOM wiring: recursive form renderer (inputs/selects/checkboxes/refs/objects/arrays/unions/records) + validate → stringify → Save/Create via IPC; registers an E5 onFlush collector playtest-session.ts # pure E5 orchestration: PlaytestFlush/saveAllFlushes/createPlaytestSession (save-all → compile → boot → playing state machine) + keydownInput playtest-hud.ts # pure E5 HUD helpers: playerStatsFromSnapshot/inventorySummary (apps/dev ui.ts parity) playtest.ts # thin E5 DOM wiring: Playtest screen — createEngine({bundle}) + createViewport + connectLocal + keyboard/click input + HUD, exit → teardown nengine.d.ts # declare global Window.nengine typed from EditorProjectApi + EditorPlaytestApi + EditorMcpApi + EditorPackApi test/ # vitest (node env, mkdtemp tmp dirs): project/recent/project-yaml/template/map-model/form-schema/form-value/scene-model/playtest-session/playtest-hud/playtest-boot/mcp-server/mcp-stdio/pack-options/pack (+ fixtures/farm.yaml, scene-farm.yaml, buildings/) ``` Build: `pnpm build` = `tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json && tsc -p tsconfig.renderer.json && vite build`. Run: `pnpm dev` (builds then `electron .`). `@nengine/core` types resolve through the workspace symlink to the built `dist`; vite aliases it to `packages/core/src/index.ts` for test/dev speed. E1 imported core **types** only (the alias never entered the bundle); E2 (map editing) needs runtime `mapSchema`/`UNWALKABLE_TILES` — the full barrel is NOT browser-safe (core's loader/mods import `node:fs`), so E2–E4 import it from two **editor-only synthetic facets** — `@nengine/core-schema` and `@nengine/core-systems` — which vite aliases (and `tsconfig.renderer.json` mirrors via `paths`) to core's **pure** source modules (`schema/index.ts`, `systems/index.ts`; no node builtins), keeping the renderer bundle browser-clean. **E5 (playtest) brings the FULL `@nengine/core` barrel + `@nengine/render` into the renderer** and neutralizes the barrel's `node:fs`/`node:path`/`node:url` imports with the **dev-app-proven in-memory shim aliases** (`src/shim/` — `vite.config.ts` aliases the four `node:` builtins to stubs that reject/no-op; bundle mode performs ZERO fs calls at runtime, D30/D36, so the stubs are never invoked). The shim aliases are scoped to non-test builds (vitest runs in Node and needs the real builtins for `mkdtemp`/`writeFile` tmp fixtures). The emitted renderer chunk is verified `node:fs`-free by grepping the build output. ## Process Responsibilities | Process | Role | |---|---| | **Main** (`src/main.ts`) | **Owns ALL fs.** Creates the 960×640 `BrowserWindow` (`contextIsolation: true`, `nodeIntegration: false`, sandboxed preload), serves `dist/renderer/` over a local `node:http` server (127.0.0.1, ephemeral port, GET-only, MIME map, D32 path-traversal guard, exact files only) and loads `http://127.0.0.1:/index.html` (the D32-proven pattern — file:// module loading is unreliable; `NENGINE_EDITOR_RENDERER_URL` overrides with a dev URL). Registers the 11 `project:*` IPC handlers (E6 adds `project:close` — stops the editing engine), `playtest:start` (E5: `compileBundle` from `@nengine/pack`; the playtest engine is booted in the RENDERER), `mcp:status` (E6), and **`pack:run`** (E7: `runEditorPack` — `packGame` from `@nengine/pack` in-process, stateless). **E6: owns the editing engine instance** (`createEditEngine(root)` — `createEngine({contentDir, modsDir?})` full fs mode on the open project; created on open/new, stopped on switch/close/quit) **and the embedded MCP HTTP server** (persistent on 127.0.0.1, ephemeral port, `/mcp`, attach/detach per project). Also hosts the `--mcp-stdio` headless branch. | | **Preload** (`src/preload.ts`) | `contextBridge.exposeInMainWorld("nengine", { project, playtest, mcp })` — typed wrappers over the IPC channels. Sandboxed → must be **CJS** (`tsconfig.preload.json`, `module: CommonJS`; the sandboxed preload ignores the package's `"type": "module"`). | | **Renderer** (`src/renderer/`) | Vanilla TS + DOM editor UI. No Node, no engine — everything through `window.nengine.project` (and `window.nengine.playtest.start` for E5, `window.nengine.mcp.status` for E6, `window.nengine.pack.run` for E7). Home screen (recent list + Open/New buttons) → editor screen (top bar: name/path/**E6 MCP URL + Copy MCP URL button**/**E7 Package button**/Play/close; content sidebar: 18 kinds with counts, file lists, and a per-kind **+ (new)** button; status bar: totals). Clicking a **map** file opens the E2 map editor (`mountMapEditor` over the pure `map-model`); clicking a **scene** file opens the E4 scene placement editor (`mountSceneEditor` over the pure `scene-model`, with the project's first map rendered beneath — `Edit Scene`/`Edit Map` mode toggle on one shared canvas); clicking any other kind's file opens the E3 **schema-driven form** (`mountFormPanel` over `deriveFormSchema` + `form-value`); the **+** button opens a blank form for a new file (create flow; scenes open a blank placement editor instead). **Play** (E5) switches to the playtest screen, saves every dirty open model, boots an isolated `createEngine({bundle})` + Pixi viewport, and exits back to edit state. **Package** (E7) opens the package dialog (`openPackDialog` over the pure `pack-options` merge) and runs the nengine-pack pipeline in main. | ## IPC API (`window.nengine.project`, via `ipcMain.handle`) The IPC surface is the extension seam for later rounds — add channels, don't restructure. All payload types live in `src/ipc.ts` (imported by main + preload + renderer). | Channel | Args | Returns | Notes | |---|---|---|---| | `project:open` | `{ dir?: string }` | `{ project: EditorProjectInfo \| null }` | With `dir`: validate that folder (`openProject`). Without: `dialog.showOpenDialog` (openDirectory) → validate. `null` on cancel. On success, main persists to recent. | | `project:new` | — | `{ project: EditorProjectInfo \| null }` | `dialog.showOpenDialog` (openDirectory + createDirectory) → `newProject(folderName, dir)` (template + boot validation) → recent. | | `project:listRecent` | — | `{ entries: EditorRecentEntry[] }` | `{root, name}` from the `userData` JSON, newest first, ≤ 10. | | `project:addRecent` | `{ root, name }` | `{ entries }` | Dedupes by root, caps at 10. | | `project:close` | — | — | E6. Stops the editing engine (and detaches the embedded MCP server) when the GUI leaves a project (Close button). | | `project:readYaml` | `root, path` | `{ content: string }` | Path-scoped: `resolveWithin(root, path)` guard + `.yaml/.yml` only. | | `project:writeYaml` | `{ root, path, content }` | — | Traversal guard; creates parent dirs; `.yaml/.yml` only. **E6:** when a content path is written, main also re-registers the file into the editing engine's registry (`syncContentWrite`) so the MCP surface sees the GUI's save (dual-channel). | | `project:readProjectYaml` | `root` | `{ data: EditorProjectYaml \| null }` | `null` when absent; throws on malformed. | | `project:writeProjectYaml` | `{ root, data }` | — | Validates against the zod schema before writing. | | `project:listContent` | `root` | `{ listing }` | `{ kinds: [{kind, count, files: [{id, name, path}]}], mods: string[], assets: string[] }` — scans `content/` per `contentDirNames` mirroring the ContentLoader's dir-vs-single-file convention. | | `project:stats` | `root` | `{ stats }` | `{ kinds: Record, total, mods, assets }`. | | `playtest:start` | `root` | `{ bundle: ContentBundle } \| { error: string }` | E5. Main runs `compileBundle(/content, /mods)` from `@nengine/pack` (mods passed only when the dir exists) and returns the compiled inline bundle to the renderer, or a clear per-def error when content fails compilation. **Stateless** — no engine instance held in main; the renderer boots `createEngine({bundle})` itself. | | `mcp:status` | — | `{ running: boolean, url: string }` | E6. `running` = a project's editing engine is attached to the embedded MCP server; `url` = `http://127.0.0.1:/mcp` (stable for the whole session — the port is bound once at app start, the served engine swaps per project). | | `pack:run` | `{ root, options: EditorPackOptions }` | `{ ok: true, outDir, stats } \| { ok: false, error }` | E7. Main runs the nengine-pack pipeline in-process: `runEditorPack(root, options, hasMods)` merges the dialog options + project.yaml into a `PackArgs` (content `/content`, mods `/mods` when the dialog asks AND the dir exists, outDir default `/dist-pack`, installer implies desktop) and calls `packGame` from `@nengine/pack`. Returns the output dir + stats (`{defCount, kinds, assetCount, modCount, desktop, installer, desktopDir?}`) or a clear error message — content compile failures (`nengine-pack: content validation failed for ` with per-def lines), vite build failures, and desktop/installer generation errors all surface as `{ok:false, error}`. **Stateless + blocking** — `packGame` exposes no progress callbacks, so the call is single and the dialog shows a busy indicator while it runs. | `EditorProjectInfo`: `{ root, name, hasContent, hasMods, projectYaml, projectYamlError? }` — `name` = `project.yaml.name` or the folder basename; a malformed `project.yaml` does not block opening (surfaced via `projectYamlError`). ## project.yaml Schema ```yaml # Packaging metadata ONLY (D40.6) — NOT a project definition. Absent → editor infers from the folder name. name: Pinefield # display name override (optional) icon: icon.png # packaging icon path (optional) pack: # mirrors nengine-pack flags (optional) name: Pinefield Valley # --name (product name) installer: true # --installer mods: true # --mods splitAssets: true # --split-assets ``` Validated by `projectYamlSchema` (zod, **strict** — unknown keys rejected). ## New-Project Template (`src/template.ts`) `newProject(name, dir)`: sanitizes the name into ids (`"My Farm"` → `"my-farm"`, empty/unsanitizable → `"farm"`), writes the tree below, then **boot-validates** by `createEngine({contentDir})` start/stop — a broken template fails loudly (a template bug is a bug). ``` content/time.yaml # 1 season (spring), 14 days, 240 ticks/day content/weather.yaml # single-season table: sunny 70 / rain 30 content/interact.yaml # default order: harvest, water, sow, till content/maps/.yaml # 12×12 grass; 3×3 water pond (top-left); 4×2 tilled bed; spawn at (8,8); walkable: water false content/items/hoe.yaml # tool content/items/-seed.yaml # seed content/items/.yaml # the harvested crop (material) content/crops/.yaml # 3 stages (2/2/1 days, water required through grown), seasons: [spring], drops 1× content/scenes/.yaml # entities: [] (valid empty scene) mods/ # empty (optional) ``` Minimal but **playable**: till → sow the seed → water → grow → harvest; the map/spawn/walkability are valid; the scene demonstrates the D40.9 mechanism with zero entities. ## Map Editing (E2) **Click a map file under the `map` kind in the content sidebar** → the renderer loads `content/maps/.yaml` via `project:readYaml`, parses it (`yaml` `parse`), and `createMapModel` validates it with `mapSchema.safeParse` (invalid map → status-bar error, no editor). The editor panel becomes a 32px/tile canvas (plain HTML5 canvas 2D — no pixi in the editor renderer; dependency-light by design) with a palette column and a toolbar. ### Model (`src/renderer/map-model.ts`, pure + node-tested) The strict-typed `MapModel` owns a **deep-cloned working copy** of the def — grid ops never mutate the def handed to `createMapModel`. Operations: | Function | Semantics | |---|---| | `createMapModel(def)` | `mapSchema.safeParse` gate → `MapModel \| null` (corrupt defs rejected at load). Ground layer = the layer named `ground`, else the first layer (`terrainLayer` convention). | | `paint(model, x, y, tileId)` | Writes `ground.data[y][x]`; out-of-bounds no-op; registers new ids in the palette. | | `erase(model, x, y)` | Writes `DEFAULT_ERASE_TILE` (`"grass"`) when the map uses it, else the first palette id, else `""` (void). | | `setSpawn(model, x, y)` | Non-integer coordinates rejected; out-of-range **clamped** into the map. | | `toggleWalkable(model, tileId)` | `false` → key deleted (restores the D33 fallback); `true`/absent → set `false`. The record only ever carries `false` entries. **Caveat (documented):** a fallback-blocked id (e.g. `water`, in `UNWALKABLE_TILES`) toggled off the record stays non-walkable at runtime — making it walkable needs `walkable: { water: true }` in YAML (editor v1 doesn't expose `true`-setting). | | `addTileId(model, tileId)` | Registers a palette id that may not be on the map yet; no def change, no dirty flag. | | `serialize(model)` | Fresh plain `MapDef` re-validated by `mapSchema.safeParse`; **`null` when invalid — invalid edits never serialize**. | | `collectTileIds(model)` | Unique ids from all layers, row-major first-seen order (empty string excluded). | | `tileColor(tileId)` | grass/tilled/water keep the render package's `TILE_COLORS` values; every other id gets a stable hash-derived hue (same id → same color). | | `isWalkableTile(model, tileId)` | Mirrors the engine's D33 resolution: declared `walkable` value wins, else the `UNWALKABLE_TILES` fallback. | | `markSaved(model)` | Clears the dirty flag (unsaved-changes indicator on the Save button). | ### UI (`src/renderer/map-editor.ts`, thin DOM wiring) - **Toolbar**: Paint / Erase / Spawn / Walkable tool buttons + a **Save** button that shows `Save *` while the model is dirty (disabled when clean). - **Palette column**: a swatch per palette id (color chip + label; `(blocked)` suffix for non-walkable ids), an **add-tile-id** input (`addTileId`), all rendered from the pure model. - **Canvas**: tiles drawn from `tileColor`, 1px grid, the **spawn point** drawn as a distinct yellow ring/dot, and **walkable-false tiles hatched in red** when the Walkable tool is active. - **Interaction**: Paint = left-click/drag paints the selected swatch; Erase = left-drag, or **right-click erases anywhere**; Spawn = click moves the spawn; Walkable = click a cell toggles that cell's tile id (and clicking a swatch toggles the id) while showing the hatch. - **Save**: `serialize` → `stringify` (byte-identical to the MCP shared authoring layer) → `project:writeYaml` → `markSaved` → status-bar feedback; a `null` serialize (invalid map) shows an error and writes nothing. The editor canvas is a **palette-color approximation** — it does NOT render the map's real image assets (map `tiles` → assets are playtest territory, D31). No map resize in v1; undo/redo, copy/paste, and asset-accurate rendering are documented future refinements. ## Scene Placement (E4) **Click a scene file under the `scene` kind** (or the scene kind's **+ (new)** button for a fresh empty scene) → the renderer loads `content/scenes/.yaml` via `project:readYaml`, parses it, and `createSceneModel` validates it with `sceneSchema.safeParse`. The editor panel becomes the **same 32px/tile canvas the map editor uses** with a mode toggle — **Edit Scene** (entity placement, default) and **Edit Map** (the E2 paint/erase/spawn/walkable tools, so both files stay reachable on one shared canvas, each with its own dirty state). The map file rendered beneath is the **first map in the project** (`listContent` order); the scene's building/animals palettes come from the project's content (`building`/`animal` kinds, footprints read from each building def via `project:readYaml`). ### Model (`src/renderer/scene-model.ts`, pure + node-tested) The strict-typed `SceneModel` owns a **deep-cloned working copy** of the def plus the **map dimensions** and the **building footprint table** — every placement decision is footprint-accurate. Operations return `{ ok: true } | { ok: false, reason }` so invalid placements surface in the status bar: | Function | Semantics | |---|---| | `createSceneModel(input, {mapWidth, mapHeight, footprints, defaultId})` | `sceneSchema.safeParse` gate → `SceneModel \| null`; `undefined` input → a new empty scene (`{id: "main", entities: []}`, `defaultId` overrides). Footprints deep-cloned. | | `addBuilding(model, buildingId, x, y)` | **Unknown building id rejected** (no footprint table entry — the engine fails boot on it); non-integer coords rejected; coords **clamped footprint-aware** into `[0, map - footprint]`; duplicate building-at-coords and any overlap with another entity's footprint rejected. | | `addNpc(model, npcId, x, y, {dialogue?, label?})` | **Duplicate npc id rejected** (the engine's seed-time duplicate check fails boot); empty `dialogue`/`label` omitted (schema `min(1)`). | | `addAnimal(model, animalId, x, y)` | No id uniqueness (runtime ids are auto-numbered `animal::`); clamped + overlap-checked like everything else. | | `removeEntity(model, index)` / `moveEntity(model, index, x, y)` | Out-of-range index rejected; a move re-clamps and **re-validates overlap against every other entity** — a rejected move leaves the working copy untouched. | | `serialize(model)` | Fresh plain `SceneDef` re-validated by `sceneSchema.safeParse`; **`null` when invalid — invalid scenes never write**. | | `entityAt` / `entityIndexAt` | Footprint-contains point lookup (topmost wins for pre-existing overlapping defs). | | `entityFootprint(model, entity)` | Buildings footprint-aware; npc/animal 1×1; an unknown building already in a loaded scene falls back to 1×1. | | `entityColor(entity)` | Building = stable hash-derived hue, npc = amber, animal = red. | | `markSaved(model)` | Clears the dirty flag. | **Overlap rule**: every placement and move is checked against **all** entities' footprints (buildings footprint-aware, npc/animal 1×1). This is deliberately **stricter than `seedScenes`** (which only fails on building-building footprint overlap and duplicate npc ids) — scenes authored here always boot. ### UI (`src/renderer/scene-editor.ts`, thin DOM wiring) - **Toolbar**: `Edit Scene` / `Edit Map` mode toggle (one shared canvas + camera + TILE_SIZE), a Save button showing `Save *` while either model is dirty. - **Edit Scene palette**: buildings (name/id + `w×h` footprint badge, footprint tooltip), animals, and an NPC form (free-string npc id + optional dialogue/label). Selecting an entry **arms placement**; canvas clicks place it (clamped), with status feedback on the actual placed coords or the rejection reason (overlap/duplicate/unknown/out-of-bounds). - **Entity list**: every entity as a row (color dot, `building shed`/`npc mayor`/`animal chicken`, coords) with a confirm-guarded **delete**; clicking selects (highlighted on canvas + list). - **Selection + move** (E2-consistent click model): with nothing armed, clicking an entity selects it; clicking an empty tile then moves it (re-clamped + re-validated; a rejected move shows the reason and changes nothing); clicking the selected entity again deselects. - **Edit Map mode**: the same canvas with the E2 paint/erase/spawn/walkable tools + tile palette (entities still drawn for context). - **Save**: `serialize` → `stringify` (byte-identical to the MCP shared authoring layer) → `project:writeYaml` for the scene file and, when the map was edited, the map file — each dirty model written separately, `markSaved`, status feedback. A `null` serialize (invalid) shows an error and writes nothing. Entities render as **plain colored shapes** (footprint rects with a hash hue, amber/red circles) — **NO asset rendering in v1** (asset-accurate rendering is playtest territory, E5). An empty scene is valid (`entities: []`). ## Embedded Playtest (E5) D40 decision #13, implemented as **bundle-mode in the renderer via shims** (the chosen architecture over ws-broadcast — no network hop, dev-app-proven). Play = **save-all → compile in main → boot a fresh isolated engine in the renderer → take over input → exit destroys the instance and returns to edit mode**. ### Architecture ``` renderer main.ts --Play button--> playtest.ts --pure session--> saveAllFlushes() │ (flush collector from the open editor) ├─ window.nengine.playtest.start(root) [IPC playtest:start] │ └─ main: compileBundle(content, mods) → ContentBundle JSON ├─ createEngine({bundle}) (FULL core barrel — node:* shims neutralize fs) ├─ createViewport(gameEl, {map}) + connectLocal(engine, viewport, {sound}) └─ input: arrow keys → IntentSystem.move, click → tileAt + interact, HUD ``` - **`@nengine/core` barrel in the renderer** — enabled by the dev-app-proven shim aliases (`vite.config.ts`: `node:fs`/`node:fs/promises`/`node:path`/`node:url` → `src/shim/` stubs that reject/no-op; bundle mode performs ZERO fs calls at runtime, so the stubs are never invoked). The shim aliases are scoped to non-test builds — vitest runs in Node and needs the real builtins. - **Bundle from main** — `playtest:start` returns `{ bundle }` (inline assets, D30) or `{ error }` with a clear per-def message for content that fails compilation (`nengine-pack: content validation failed for `). Main is **stateless** — the playtest engine lives entirely in the renderer. - **Isolation** — each Play spawns a NEW `createEngine({bundle})`; **Exit** destroys the viewport + local-sync handle + input listeners + `engine.stop()`. The editor's own state (open models, dirty flags, flush collectors) is untouched — playtest edits never leak back; unsaved edits are flushed BEFORE boot so the playtest reflects exactly what will ship. - **Playtest-visible correctness** — the fixture test proves a scene-authored building/NPC/animal appear (scene kind seeds via `seedScenes`), a form-edited map change lands in the compiled bundle, and a project `mods/` Mod loads (`bundle.mods` via compileBundle) with a hook firing on `engine.tick(480)`. ### Pure orchestration (`src/renderer/playtest-session.ts`, node-tested) | Piece | Semantics | |---|---| | `PlaytestFlush` | `{ path, content }` (stringified YAML to persist) \| `{ path, error }` (a model that cannot serialize — invalid edits never write). | | `saveAllFlushes(flushes, write)` | Persists every dirty model; a rejecting write or an un-serializable model is **recorded as a failure (never thrown)** — a single bad file cannot half-save. | | `createPlaytestSession(deps)` | The lifecycle state machine: `saving → compiling → booting → playing`, with `stop()` → `stopping → idle`. Every I/O side (`collectFlushes`/`write`/`compile`/`boot`/`teardown`) is an injected dep, so the whole sequence is node-testable. A save/compile/boot failure returns `{ ok: false, error }` and returns to `idle` (teardown not needed — nothing was booted). | | `keydownInput(key)` | Arrow keys → raw `"key:Arrow*"` inputs for the local-sync mapping (dev-app `keys.ts` parity). | ### Flush collection Every editor mount (`map-editor`, `scene-editor`, `form-panel`) accepts an `onFlush` option and registers a **flush collector** with the renderer shell (`renderer/main.ts` holds the current collector; `disposeCurrentEditor` clears it). The collector returns the dirty models as `PlaytestFlush[]`: - map editor → `serialize(model)` when dirty (`null` → `{ error: "map invalid" }`); - scene editor → both the scene and the map models (each with its own dirty flag); - form panel → `coerceForValidate` + `contentSchemas[kind].safeParse` + `resolveTargetPath` (create flow included; validation failure / duplicate id / occupied single-file kind → `{ error }`). Play calls `saveAllFlushes(currentCollector(), writeYaml)` BEFORE compiling — this is the whole point of "see what ships": a form-edited crop or a scene-placed building is on disk, so the compiled bundle contains it. ### UI (`src/renderer/playtest.ts`, thin DOM wiring) - **Play button** (editor top bar) → hides the editor screen, shows the Playtest screen, runs the session (status: Saving edits… → Compiling content… → Booting engine… → Playing). The **Exit** button is disabled until the engine boots. - **Boot** (the dev-app bundle-mode flow): `createEngine({bundle})` → `engine.start()` → first map def → `createViewport(gameEl, {map})` → `connectLocal(engine, viewport, {sound})`. - **Input**: arrow keys → `localSync.input("key:Arrow*")` (IntentSystem move); click → `viewport.tileAt` + `localSync.intent("interact", {tile})`; pointerdown primes Web Audio. - **HUD** (`playtest-hud.ts`, dev-app `ui.ts` parity): time via `localSync.setTimeLabel` (Day X, season), stats via `playerStatsFromSnapshot` (gold · stamina · inventory), refreshed on every `player-intent`. - **Exit**: `localSync.destroy()` + remove listeners + `viewport.destroy()` + `engine.stop()` → screen returns to edit mode → sidebar re-renders (saved files may have changed). v1 playability: **move + interact + HUD time/inventory** (the D35 panels — seed picker/shop/dialogue — are not ported; reserved). No split-assets support in v1 (bundles are inline; assets render via `engine.assets` data URLs). A failed save/compile/boot surfaces the error in the editor status bar and returns to edit mode. ## Embedded MCP Server (E6) D40 decision #5, implemented per decision #16 (**full exposure, no permission narrowing**): the editor embeds an **in-process MCP gateway** serving **the editor's own engine instance** — the same instance the shared authoring layer operates on — so external AI tools (Claude/Cursor-style agents) connect, see the current project, and author/debug it. Dual transport per decision #7. ### Architecture ``` apps/editor/src/mcp-server.ts (pure, node-tested) createGateway(engine) # @nengine/mcp namespaces registered (22 / 124 tools / 36 resources / 17 prompts) createNengineMcpServer(engine) # McpServer (SDK) wrapping the gateway — apps/mcp-server parity createEditorMcpHttpServer() # persistent streamable HTTP server: 127.0.0.1, ephemeral port, /mcp # GET (SSE) + POST + DELETE + OPTIONS (CORS preflight) per the MCP spec # attach(engine) / detach() swap the served engine per project runStdioMcp(dirs) # headless stdio runner for --mcp-stdio resolveEditorMcpDirs(argv, cwd) # --dir (content=/content, mods=/mods when present) apps/editor/src/edit-engine.ts (pure, node-tested) createEditEngine(root) # createEngine({contentDir, modsDir?}) full fs mode — the editor's own instance syncContentWrite(engine, root, rel)# GUI-written content file → parse → schema-validate → registry re-register apps/editor/src/mcp-stdio.ts (thin entry — no electron import) node dist/main/mcp-stdio.js --dir # the editor binary as a drop-in stdio MCP server main.ts # engine lifecycle (open → create/start, switch/close → stop) + mcp:status IPC ``` - **The editing engine lives in main** — it needs fs (`createEngine({contentDir, modsDir})` full mode + the shared authoring layer's write paths). Created on `project:open`/`project:new`, stopped on `project:close`, project switch, and app quit. If the content fails to boot (broken project), the project still opens in the GUI and `mcp:status` reports `running: false` — a broken project costs MCP, never the GUI. - **One persistent HTTP server** is bound at app start (127.0.0.1, ephemeral port, `/mcp`), so the URL is **stable for the whole session**; `attach(engine)`/`detach()` swap the served engine (fresh gateway + streamable-HTTP transport) on project open/switch/close. The HTTP endpoint is the **primary channel** for external AI tools. - **`--mcp-stdio`** is a **secondary headless channel** for CLI/scripts: the editor binary launches as a drop-in stdio MCP server mirroring apps/mcp-server but with the editor's wiring. Two ways to invoke: `node apps/editor/dist/main/mcp-stdio.js --dir ` (pure Node, no Electron needed — this is what the tests spawn) or `electron . --mcp-stdio --dir ` (no BrowserWindow). The stdio runner loads its OWN fresh engine for the `--dir` project (it is a standalone server, not the GUI session's instance). - **Dual-channel, same world** (decision #16): - **AI edits → GUI refreshes**: MCP authoring (`item.create`, `map.update`, …) goes through the shared authoring layer — writes `content/…/.yaml` AND registers live in the engine registry; the GUI reads content from disk (`project:listContent`/`readYaml`), so AI-created content appears immediately. - **GUI edits → AI sees**: GUI saves (`project:writeYaml`) write YAML to disk as before AND main re-registers the written file into the editing engine's registry via `syncContentWrite` (parse → `contentSchemas[kind].safeParse` → `registry.register`). Invalid content leaves the registry untouched (the last valid def stays visible). `project.yaml` and non-content paths are ignored. - **Full exposure, no permission narrowing**: the GUI never goes through the MCP protocol (it uses the engine's direct APIs + the shared authoring layer), so an AI client toggling namespaces (`server.disable_subsystem`) can destabilize the MCP surface — not the GUI. - **Engine vs playtest isolation** (decision #10): the embedded MCP server stays bound to the EDIT instance. The playtest engine (E5) is a separate bundle-mode instance in the renderer — playtest edits never leak into the MCP surface, and MCP debug tools never mutate a running playtest. ### How an AI tool connects 1. Open (or create) a project in the editor. 2. The top bar shows the MCP URL (e.g. `http://127.0.0.1:43127/mcp`); press **Copy MCP URL**. 3. Paste the URL into the AI tool's MCP server configuration (streamable HTTP client — e.g. Claude Desktop / Cursor-style agent). Non-browser clients need no CORS; the OPTIONS preflight is answered for browser-based clients. 4. The AI tool now sees the project's 22 namespaces (124 tools / 36 resources / 17 prompts): browse content (`item.list`, `map.get`, resources), author (`item.create`…), and debug the live edit instance (`time.advance`, `crop.till`, `npc.spawn`, `player.intent`…). Changes reflect in the GUI on the next sidebar/status refresh; GUI saves reflect in MCP immediately. ### Lifecycle & transport table | Channel | Endpoint / invocation | When | Notes | |---|---|---|---| | Streamable HTTP (primary) | `http://127.0.0.1:/mcp` (GET SSE + POST + DELETE + OPTIONS) | Always on while the app runs; serves the current project's engine when a project is open, else 503 | Persistent server, stable URL; per-project transport swap | | stdio (secondary, headless) | `node apps/editor/dist/main/mcp-stdio.js --dir ` or `electron . --mcp-stdio --dir ` | Standalone CLI/script channel | Own fresh engine for the `--dir` project; mirrors apps/mcp-server | Engine instance lifecycle: `project:open`/`project:new` → `createEditEngine(root)` + `start()` + `mcpHttp.attach(engine)`; `project:close` / opening a different project / app quit → `mcpHttp.detach()` + `engine.stop()`. ## Package Dialog (E7) D40 decision #8 — **reuse the nengine-pack pipeline**: the Package button in the editor top bar opens a small DOM dialog that runs `packGame` (from `@nengine/pack`) **in the main process** on the open project, closing the creator loop: **new → edit → playtest → package** a distributable desktop app. No packaging logic is reimplemented in the editor — the dialog is an option merge over the verified pipeline. ### Fields and pre-fill | Field | Maps to | Pre-fill (D40.6 `project.yaml`) | |---|---|---| | Product name | `pack.name` (nengine-pack `--name`) | `pack.name` → top-level `name` → project display name | | Icon path | top-level `icon` | `project.yaml.icon` — **stored for future use only**; `packGame`/`generateDesktop` v1 ship the default Electron icon (D32.6) and this round does not change pack logic | | Desktop app | `desktop` (nengine-pack `--desktop`) | `pack.installer === true` (installer implies desktop; `desktop` alone is not persisted) | | Installer config | `installer` (nengine-pack `--installer`) | `pack.installer`; checking it auto-checks Desktop | | Include Mods | `mods` (nengine-pack `--mods /mods`) | `pack.mods`; the `mods/` dir is only included when it exists | | Split assets | `splitAssets` (nengine-pack `--split-assets`) | `pack.splitAssets` | | Output directory | `outDir` (nengine-pack `--out`) | `/dist-pack` (the `DEFAULT_OUT_DIR` convention) | Running the dialog **saves the field values back into `project.yaml`** first (`projectYamlFromPack` — persistent fields only: `pack.{name, installer, mods, splitAssets}` + `icon`; `desktop`/`outDir` are transient run options; the prev display `name` and other fields survive), so the next open pre-fills from the last run. ### Pure option merge (`src/pack-options.ts`, node-tested) | Function | Semantics | |---|---| | `dialogDefaultsFromProject(projectYaml, root, fallbackName)` | The pre-fill (`EditorPackOptions`) — pack.name → name → fallback chain, `icon`, booleans from `pack.*`, outDir `/dist-pack`. | | `projectYamlFromPack(options, prev)` | The save-back — writes `pack.{name, installer, mods, splitAssets}` + `icon`, deletes empty `name`/`icon`, keeps `desktop`/`outDir` out, preserves prev fields. | | `resolvePackRequest(root, options, hasMods)` | The effective pack request — `contentDir`/`outDir`, `installer || desktop`, `modsDir` only when the dialog asks AND `hasMods`, `name` only when non-empty. | | `packHints(stats)` | Post-run hint lines (site serving; `npm install && npm start` for desktop; `npm install && npm run dist` for installers). | The module imports **no node builtins** (outDir is `${root}/dist-pack` string concat — main normalizes with `resolve`), so the renderer dialog and the main pack step share one merge, and the whole merge is node-testable. ### Main-side step (`src/pack.ts`, node-tested) `runEditorPack(root, options, hasMods)` builds the `PackArgs` from `resolvePackRequest` and calls **`packGame`** (the library function — not the CLI, mirroring E5's `compileBundle`-in-main choice). Returns `{ok: true, outDir, stats: {defCount, kinds, assetCount, modCount, desktop, installer, desktopDir?}}` or `{ok: false, error}`. **How failures surface** (all through the same `{ok:false, error}`): - content compile failures → `nengine-pack: content validation failed for ` + per-def lines; - vite build failures → the vite error message; - desktop/installer generation failures → the throwing error (there is no `electron-builder` invocation at pack time — installer mode only *writes* `electron-builder.yml`, so a missing electron-builder surfaces later as `npm run dist` failing inside `desktop/`, documented in the result hints). **Progress**: `packGame` exposes no callbacks, so `pack:run` is a **single blocking call** — the dialog's Run button switches to a disabled "Packaging…" state while main builds (typically a few seconds) and the status bar shows "packaging…". ### Dialog UI (`src/renderer/pack-dialog.ts`, thin DOM) An overlay opened from the top bar **Package** button (only when a project is open; the current `project.yaml` is re-read fresh for the pre-fill). **Run** persists the pre-fill, then calls `window.nengine.pack.run({root, options})`; on success the result area shows `Package complete → ` + the hint lines (the installer hint: *"run `npm install && npm run dist` in the desktop folder for installers"*); on failure it shows the error. Close hides the overlay. ### Output layout `/` mirrors the nengine-pack output exactly (the pipeline is unchanged): `index.html` + `assets/` + `bundle.json` (the self-contained static site, D30), `nengine-assets/` when split (D37), and — with desktop/installer — `desktop/` (Electron shell `main.cjs` + `package.json` + `README-desktop.md`; installer mode adds `electron-builder.yml` + a site copy, D32/D36). ## Round Roadmap | Round | Scope | Status | |---|---|---| | **E1 — Project layer** | Package scaffold (main + preload + renderer); project OPEN / NEW (built-in minimal playable template) / RECENT-OPEN / `project.yaml` read-write; `project:listContent`/`project:stats`; pure-logic tests. | **BUILT** ✅ | | **E2 — Map editing** | Visual map canvas: tile palette, ground-layer painting, spawn point, walkable visualization; writes map YAML via serialize → validate → stringify → `project:writeYaml`. | **BUILT** ✅ | | **E3 — Content forms** | Schema-driven universal form generator (zod → form UI) for all content kinds; writes through the shared authoring layer. | **BUILT** ✅ | | **E4 — Scene placement** | Scene entity placement on the map canvas → `content/scenes/.yaml` via the shared authoring layer. | **BUILT** ✅ | | **E5 — Playtesting** | Embedded same-process independent engine instance (Play → save → compile → spawn → take over → exit), bundle-mode-in-renderer via node shims + `@nengine/pack` compile in main. | **BUILT** ✅ | | **E6 — Embedded MCP** | In-process MCP gateway (streamable HTTP primary + `--mcp-stdio` headless secondary) serving the editor's own engine instance (per-project lifecycle) for AI-assisted design; full exposure, dual-channel (AI↔GUI same world). | **BUILT** ✅ | | **E7 — Package dialog** | nengine-pack integration (fields prefilled from `project.yaml`, saved back on run; `pack:run` IPC → `packGame` in main; result + installer hints). The Mods enable/disable panel remains future work. | **BUILT** ✅ | Playwright `_electron` E2E (mirroring D32) is reserved for a later/final round — Electron needs a display, so E1 has no runtime E2E.