Переглянути джерело

D40: graphical creation tool (apps/editor) — desktop-first editor, E1-E7 complete

Shared authoring layer (packages/mcp/src/authoring.ts): 12 authoring namespaces refactored onto one source of truth (zero behavior change); map.* gains validate/create/update + create-prompt (reverses the early no-map-authoring ruling).

scene content kind (#18): initial-entity list (buildings/npcs/animals) seeded at engine start via seedScenes after initSystems/before Mods; same runtime entity id formats; unknown refs/conflicts fail boot loudly; scene.* MCP namespace (22nd).

apps/editor (new): single Electron app, edit+run in one (RPG Maker mode).
  E1 project layer: open/new-template/recent-open, project.yaml read-write, 10 IPC channels
  E2 visual map editing: canvas-2D + palette + spawn + walkable (pure map-model, node-tested)
  E3 schema-driven form generator: zod -> form UI auto-derived, all 18 kinds, schema changes auto-follow
  E4 scene placement: footprint-aware entity placement (stricter than seedScenes so authored scenes always boot)
  E5 embedded playtest: save-all -> main compiles bundle -> renderer boots isolated engine (bundle mode + node shims) -> playable -> exit
  E6 embedded MCP server: edit engine instance in main, streamable HTTP (URL shown + copy button) + --mcp-stdio headless; AI and GUI dual-channel same world, full exposure
  E7 package dialog: project.yaml pre-filled -> nengine-pack pipeline -> distributable desktop app

961 tests green (727 -> 961); build: 7 packages clean; MCP surface 22 namespaces/124 tools/36 resources/17 prompts; 18 content kinds. Docs: docs/editor.md (E1-E7 roadmap), decisions.md D40, AGENTS.md.
nonvioce 2 місяців тому
батько
коміт
d8810ef789
87 змінених файлів з 11301 додано та 1220 видалено
  1. 2 0
      AGENTS.md
  2. 1 1
      README.md
  3. 883 0
      apps/editor/index.html
  4. 30 0
      apps/editor/package.json
  5. 66 0
      apps/editor/src/edit-engine.ts
  6. 165 0
      apps/editor/src/ipc.ts
  7. 299 0
      apps/editor/src/main.ts
  8. 263 0
      apps/editor/src/mcp-server.ts
  9. 14 0
      apps/editor/src/mcp-stdio.ts
  10. 82 0
      apps/editor/src/pack-options.ts
  11. 36 0
      apps/editor/src/pack.ts
  12. 41 0
      apps/editor/src/preload.ts
  13. 57 0
      apps/editor/src/project-yaml.ts
  14. 133 0
      apps/editor/src/project.ts
  15. 36 0
      apps/editor/src/recent.ts
  16. 718 0
      apps/editor/src/renderer/form-panel.ts
  17. 544 0
      apps/editor/src/renderer/form-schema.ts
  18. 378 0
      apps/editor/src/renderer/form-value.ts
  19. 446 0
      apps/editor/src/renderer/main.ts
  20. 308 0
      apps/editor/src/renderer/map-editor.ts
  21. 191 0
      apps/editor/src/renderer/map-model.ts
  22. 9 0
      apps/editor/src/renderer/nengine.d.ts
  23. 116 0
      apps/editor/src/renderer/pack-dialog.ts
  24. 47 0
      apps/editor/src/renderer/playtest-hud.ts
  25. 124 0
      apps/editor/src/renderer/playtest-session.ts
  26. 159 0
      apps/editor/src/renderer/playtest.ts
  27. 664 0
      apps/editor/src/renderer/scene-editor.ts
  28. 281 0
      apps/editor/src/renderer/scene-model.ts
  29. 20 0
      apps/editor/src/shim/fs-promises.ts
  30. 13 0
      apps/editor/src/shim/fs.ts
  31. 49 0
      apps/editor/src/shim/path.ts
  32. 5 0
      apps/editor/src/shim/url.ts
  33. 159 0
      apps/editor/src/template.ts
  34. 7 0
      apps/editor/test/fixtures/buildings/shed.yaml
  35. 6 0
      apps/editor/test/fixtures/buildings/well.yaml
  36. 36 0
      apps/editor/test/fixtures/farm.yaml
  37. 9 0
      apps/editor/test/fixtures/scene-farm.yaml
  38. 289 0
      apps/editor/test/form-schema.test.ts
  39. 224 0
      apps/editor/test/form-value.test.ts
  40. 370 0
      apps/editor/test/map-model.test.ts
  41. 272 0
      apps/editor/test/mcp-server.test.ts
  42. 68 0
      apps/editor/test/mcp-stdio.test.ts
  43. 130 0
      apps/editor/test/pack-options.test.ts
  44. 146 0
      apps/editor/test/pack.test.ts
  45. 291 0
      apps/editor/test/playtest-boot.test.ts
  46. 42 0
      apps/editor/test/playtest-hud.test.ts
  47. 206 0
      apps/editor/test/playtest-session.test.ts
  48. 67 0
      apps/editor/test/project-yaml.test.ts
  49. 175 0
      apps/editor/test/project.test.ts
  50. 65 0
      apps/editor/test/recent.test.ts
  51. 475 0
      apps/editor/test/scene-model.test.ts
  52. 118 0
      apps/editor/test/template.test.ts
  53. 22 0
      apps/editor/tsconfig.main.json
  54. 16 0
      apps/editor/tsconfig.preload.json
  55. 13 0
      apps/editor/tsconfig.renderer.json
  56. 51 0
      apps/editor/vite.config.ts
  57. 17 5
      apps/mcp-server/test/client-surface.test.ts
  58. 6 3
      docs/api.md
  59. 25 0
      docs/decisions.md
  60. 354 0
      docs/editor.md
  61. 26 5
      docs/mcp.md
  62. 52 1
      packages/core/src/engine.ts
  63. 10 0
      packages/core/src/index.ts
  64. 5 0
      packages/core/src/schema/index.ts
  65. 51 0
      packages/core/src/schema/scene.ts
  66. 284 0
      packages/core/test/scene.test.ts
  67. 133 0
      packages/mcp/src/authoring.ts
  68. 3 0
      packages/mcp/src/index.ts
  69. 10 97
      packages/mcp/src/namespaces/animal.ts
  70. 10 90
      packages/mcp/src/namespaces/animation.ts
  71. 21 180
      packages/mcp/src/namespaces/behavior.ts
  72. 10 99
      packages/mcp/src/namespaces/crop.ts
  73. 10 96
      packages/mcp/src/namespaces/dialogue.ts
  74. 10 90
      packages/mcp/src/namespaces/item.ts
  75. 54 2
      packages/mcp/src/namespaces/map.ts
  76. 10 90
      packages/mcp/src/namespaces/particle.ts
  77. 10 90
      packages/mcp/src/namespaces/placement.ts
  78. 124 0
      packages/mcp/src/namespaces/scene.ts
  79. 11 3
      packages/mcp/src/namespaces/server.ts
  80. 10 90
      packages/mcp/src/namespaces/shader.ts
  81. 10 96
      packages/mcp/src/namespaces/shop.ts
  82. 10 90
      packages/mcp/src/namespaces/sound.ts
  83. 10 90
      packages/mcp/src/namespaces/sprite.ts
  84. 252 0
      packages/mcp/test/map-namespace.test.ts
  85. 1 1
      packages/mcp/test/namespaces.test.ts
  86. 276 0
      packages/mcp/test/scene-namespace.test.ts
  87. 49 1
      pnpm-lock.yaml

Різницю між файлами не показано, бо вона завелика
+ 2 - 0
AGENTS.md


+ 1 - 1
README.md

@@ -22,7 +22,7 @@
 |---|---|
 | `docs/decisions.md` | 全部架构决策(D1–D39),**WHY 的事实来源** |
 | `docs/architecture.md` | 顶层架构、包边界、目录布局 |
-| `docs/mcp.md` | **MCP-first 接口规格**(21 命名空间 / 116 工具 / 34 资源 / 15 prompt) |
+| `docs/mcp.md` | **MCP-first 接口规格**(22 命名空间 / 124 工具 / 36 资源 / 17 prompt) |
 | `docs/api.md` | 引擎 API 参考(活的 API 文档) |
 | `docs/boundaries.md` | 引擎/游戏边界(三判据) |
 | `docs/engine-guide-zh.md` | 中文引擎指南:安装、游玩、制作游戏、打包发布 |

+ 883 - 0
apps/editor/index.html

@@ -0,0 +1,883 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>nEngine Editor</title>
+    <style>
+      html,
+      body {
+        margin: 0;
+        height: 100%;
+        background: #171b20;
+        color: #e6e9ec;
+        font-family: system-ui, sans-serif;
+        font-size: 14px;
+      }
+      #app {
+        display: flex;
+        flex-direction: column;
+        height: 100%;
+      }
+      .screen {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        padding: 24px;
+        gap: 12px;
+        overflow: auto;
+      }
+      .hidden {
+        display: none;
+      }
+      h1 {
+        margin: 0;
+        font-size: 28px;
+        color: #a5d6a7;
+      }
+      h2 {
+        margin: 24px 0 8px;
+        font-size: 15px;
+        color: #90a4ae;
+        text-transform: uppercase;
+        letter-spacing: 0.08em;
+      }
+      h3 {
+        margin: 0 0 8px;
+        font-size: 13px;
+        color: #90a4ae;
+        text-transform: uppercase;
+        letter-spacing: 0.08em;
+      }
+      button {
+        font: inherit;
+        padding: 7px 16px;
+        border: 1px solid #4a5560;
+        border-radius: 5px;
+        background: #262c34;
+        color: #e6e9ec;
+        cursor: pointer;
+      }
+      button:hover {
+        background: #313a45;
+      }
+      button.primary {
+        background: #2e7d32;
+        border-color: #66bb6a;
+      }
+      button.primary:hover {
+        background: #388e3c;
+      }
+      #home-actions {
+        display: flex;
+        gap: 10px;
+      }
+      #recent-list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+        max-width: 560px;
+      }
+      #recent-list button {
+        width: 100%;
+        text-align: left;
+        display: flex;
+        justify-content: space-between;
+        gap: 12px;
+      }
+      #recent-list .recent-path {
+        color: #7f8c99;
+        font-size: 12px;
+      }
+      .muted {
+        color: #7f8c99;
+      }
+      #home-error {
+        color: #ef5350;
+        min-height: 18px;
+      }
+      #topbar {
+        display: flex;
+        align-items: center;
+        gap: 14px;
+        padding: 10px 16px;
+        background: #1f242b;
+        border-bottom: 1px solid #2d343d;
+      }
+      #project-name {
+        font-weight: 600;
+        color: #a5d6a7;
+      }
+      #project-path {
+        color: #7f8c99;
+        font-size: 12px;
+        flex: 1;
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      #mcp-url {
+        color: #7f8c99;
+        font-size: 12px;
+        font-family: ui-monospace, monospace;
+        max-width: 280px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      #mcp-url.running {
+        color: #a5d6a7;
+      }
+      #workspace {
+        flex: 1;
+        display: flex;
+        min-height: 0;
+      }
+      #content-browser {
+        width: 240px;
+        border-right: 1px solid #2d343d;
+        padding: 14px;
+        overflow: auto;
+        background: #1c2127;
+      }
+      #content-list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 3px;
+      }
+      #content-list li {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 4px 8px;
+        border-radius: 4px;
+        font-size: 13px;
+      }
+      #content-list li:hover {
+        background: #262c34;
+      }
+      #content-list li.clickable {
+        cursor: pointer;
+      }
+      .kind-name {
+        color: #cfd8dc;
+      }
+      .kind-count {
+        color: #7f8c99;
+        font-variant-numeric: tabular-nums;
+      }
+      .kind-files {
+        list-style: none;
+        margin: 2px 0 0 10px;
+        padding: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+      }
+      .kind-file {
+        font-size: 12px;
+        padding: 3px 8px;
+        text-align: left;
+        width: 100%;
+        background: #1f242b;
+      }
+      .kind-file:hover {
+        background: #313a45;
+      }
+      .kind-new {
+        padding: 2px 9px;
+        font-size: 13px;
+        line-height: 1.2;
+        flex: none;
+      }
+      #editor-panel {
+        flex: 1;
+        padding: 16px;
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+        min-width: 0;
+      }
+      #editor-panel .placeholder {
+        color: #7f8c99;
+        border: 1px dashed #2d343d;
+        border-radius: 6px;
+        padding: 32px;
+        text-align: center;
+      }
+      .form-panel {
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+        flex: 1;
+        min-height: 0;
+      }
+      .form-header {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        flex-wrap: wrap;
+      }
+      .form-header h3 {
+        margin: 0;
+      }
+      .form-path {
+        color: #7f8c99;
+        font-size: 12px;
+        flex: 1;
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .form-errors {
+        color: #ef5350;
+        font-size: 12px;
+        max-height: 140px;
+        overflow: auto;
+      }
+      .form-errors ul {
+        margin: 0;
+        padding-left: 18px;
+      }
+      .form-body {
+        flex: 1;
+        min-height: 0;
+        overflow: auto;
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+        padding: 10px;
+        background: #1c2127;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+      }
+      .form-field {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+        max-width: 560px;
+      }
+      .form-field > label {
+        font-size: 12px;
+        color: #90a4ae;
+      }
+      .form-field input[type="text"],
+      .form-field input[type="number"],
+      .form-field select {
+        font: inherit;
+        padding: 5px 8px;
+        border: 1px solid #4a5560;
+        border-radius: 4px;
+        background: #262c34;
+        color: #e6e9ec;
+      }
+      .form-field input[type="text"].readonly,
+      .form-field select.readonly {
+        opacity: 0.7;
+      }
+      .form-boolean {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        font-size: 13px;
+        cursor: pointer;
+      }
+      .form-section {
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        padding: 8px 10px;
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+        margin: 0;
+        background: #171b20;
+      }
+      .form-section legend {
+        font-size: 12px;
+        color: #90a4ae;
+        text-transform: uppercase;
+        letter-spacing: 0.06em;
+        padding: 0 6px;
+      }
+      .form-section-body {
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+      }
+      .form-array,
+      .form-record {
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        padding: 8px 10px;
+        background: #171b20;
+      }
+      .form-array-header,
+      .form-record-header,
+      .form-union-header {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+      .form-array-label,
+      .form-record-label,
+      .form-union-label {
+        font-size: 12px;
+        color: #90a4ae;
+        text-transform: uppercase;
+        letter-spacing: 0.06em;
+        flex: 1;
+      }
+      .form-array-list,
+      .form-record-rows {
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+      }
+      .form-array-item {
+        display: flex;
+        align-items: flex-start;
+        gap: 8px;
+        padding: 6px;
+        border: 1px solid #2d343d;
+        border-radius: 4px;
+        background: #1c2127;
+      }
+      .form-array-item > input,
+      .form-array-item > select {
+        flex: 1;
+        min-width: 0;
+      }
+      .form-array-item-body {
+        flex: 1;
+        min-width: 0;
+      }
+      .form-array-controls {
+        display: flex;
+        gap: 4px;
+        flex: none;
+      }
+      .form-array-controls button {
+        padding: 4px 8px;
+        font-size: 12px;
+      }
+      .form-union {
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        padding: 8px 10px;
+        background: #171b20;
+      }
+      .form-union-body {
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+      }
+      .form-branch-select {
+        font: inherit;
+        padding: 5px 8px;
+        border: 1px solid #4a5560;
+        border-radius: 4px;
+        background: #262c34;
+        color: #e6e9ec;
+      }
+      .form-record-row {
+        display: flex;
+        align-items: flex-start;
+        gap: 8px;
+      }
+      .form-record-row > input {
+        flex: 1;
+        min-width: 0;
+        font: inherit;
+        padding: 5px 8px;
+        border: 1px solid #4a5560;
+        border-radius: 4px;
+        background: #262c34;
+        color: #e6e9ec;
+      }
+      .form-record-row > .form-record-value {
+        flex: 2;
+        min-width: 0;
+      }
+      #statusbar {
+        padding: 6px 16px;
+        background: #1f242b;
+        border-top: 1px solid #2d343d;
+        color: #7f8c99;
+        font-size: 12px;
+      }
+      .status-error {
+        color: #ef5350;
+      }
+      button:disabled {
+        opacity: 0.5;
+        cursor: default;
+      }
+      .map-editor {
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+        flex: 1;
+        min-height: 0;
+      }
+      .map-toolbar {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        flex-wrap: wrap;
+      }
+      .map-toolbar h3 {
+        margin: 0;
+      }
+      .map-path {
+        color: #7f8c99;
+        font-size: 12px;
+        flex: 1;
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .map-tools {
+        display: flex;
+        gap: 6px;
+      }
+      .map-tool-button {
+        padding: 5px 12px;
+        font-size: 12px;
+      }
+      .map-tool-button.selected {
+        background: #2e7d32;
+        border-color: #66bb6a;
+      }
+      .map-body {
+        display: flex;
+        gap: 12px;
+        flex: 1;
+        min-height: 0;
+      }
+      .map-palette {
+        width: 180px;
+        flex: none;
+        overflow: auto;
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+        padding: 10px;
+        background: #1c2127;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+      }
+      .palette-list {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+      }
+      .palette-swatch {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        padding: 5px 8px;
+        text-align: left;
+      }
+      .palette-swatch.selected {
+        border-color: #66bb6a;
+        background: #2e7d32;
+      }
+      .swatch-chip {
+        width: 22px;
+        height: 22px;
+        border-radius: 3px;
+        border: 1px solid rgba(0, 0, 0, 0.4);
+        flex: none;
+      }
+      .swatch-label {
+        font-size: 12px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .add-tile-form {
+        display: flex;
+        gap: 6px;
+      }
+      .add-tile-form input {
+        flex: 1;
+        min-width: 0;
+        font: inherit;
+        padding: 5px 8px;
+        border: 1px solid #4a5560;
+        border-radius: 4px;
+        background: #262c34;
+        color: #e6e9ec;
+      }
+      .map-canvas-wrap {
+        flex: 1;
+        min-width: 0;
+        overflow: auto;
+        background: #111417;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        padding: 8px;
+      }
+      #map-canvas,
+      .map-canvas-wrap canvas {
+        display: block;
+        image-rendering: pixelated;
+        cursor: crosshair;
+      }
+      .scene-editor {
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+        flex: 1;
+        min-height: 0;
+      }
+      .scene-toolbar {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        flex-wrap: wrap;
+      }
+      .scene-toolbar h3 {
+        margin: 0;
+      }
+      .scene-path {
+        color: #7f8c99;
+        font-size: 12px;
+        flex: 1;
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .scene-mode-toggle {
+        display: flex;
+        gap: 6px;
+      }
+      .scene-mode-button {
+        padding: 5px 12px;
+        font-size: 12px;
+      }
+      .scene-mode-button.selected {
+        background: #2e7d32;
+        border-color: #66bb6a;
+      }
+      .scene-body {
+        display: flex;
+        gap: 12px;
+        flex: 1;
+        min-height: 0;
+      }
+      .scene-palette {
+        width: 180px;
+        flex: none;
+        overflow: auto;
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+        padding: 10px;
+        background: #1c2127;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+      }
+      .scene-palette-section {
+        margin: 6px 0 0;
+        font-size: 11px;
+        color: #90a4ae;
+        text-transform: uppercase;
+        letter-spacing: 0.06em;
+      }
+      .scene-palette-list {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+      }
+      .scene-palette-item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 8px;
+        padding: 5px 8px;
+        text-align: left;
+      }
+      .scene-palette-item.selected {
+        border-color: #66bb6a;
+        background: #2e7d32;
+      }
+      .scene-palette-badge {
+        color: #90a4ae;
+        font-size: 11px;
+        font-variant-numeric: tabular-nums;
+        flex: none;
+      }
+      .scene-npc-form {
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+      }
+      .scene-npc-form input {
+        font: inherit;
+        padding: 5px 8px;
+        border: 1px solid #4a5560;
+        border-radius: 4px;
+        background: #262c34;
+        color: #e6e9ec;
+      }
+      .scene-main {
+        flex: 1;
+        min-width: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+      }
+      .scene-entity-list {
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        background: #1c2127;
+        padding: 8px 10px;
+        max-height: 180px;
+        overflow: auto;
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+      }
+      .scene-entity-list h4 {
+        margin: 0;
+        font-size: 11px;
+        color: #90a4ae;
+        text-transform: uppercase;
+        letter-spacing: 0.06em;
+      }
+      .scene-entity-rows {
+        display: flex;
+        flex-direction: column;
+        gap: 3px;
+      }
+      .scene-entity-row {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        padding: 4px 8px;
+        border-radius: 4px;
+        cursor: pointer;
+        font-size: 12px;
+      }
+      .scene-entity-row:hover {
+        background: #262c34;
+      }
+      .scene-entity-row.selected {
+        background: #2e7d32;
+      }
+      .scene-entity-dot {
+        width: 10px;
+        height: 10px;
+        border-radius: 50%;
+        border: 1px solid rgba(0, 0, 0, 0.4);
+        flex: none;
+      }
+      .scene-entity-coords {
+        color: #7f8c99;
+        font-variant-numeric: tabular-nums;
+        margin-left: auto;
+      }
+      .scene-entity-delete {
+        padding: 2px 7px;
+        font-size: 12px;
+        line-height: 1.1;
+        flex: none;
+      }
+      #playtest {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        min-height: 0;
+        background: #0d1013;
+      }
+      #playtest-topbar {
+        display: flex;
+        align-items: center;
+        gap: 14px;
+        padding: 8px 16px;
+        background: #1f242b;
+        border-bottom: 1px solid #2d343d;
+      }
+      #playtest-title {
+        font-weight: 600;
+        color: #a5d6a7;
+      }
+      #playtest-status {
+        color: #7f8c99;
+        font-size: 12px;
+        flex: 1;
+      }
+      #playtest-game {
+        flex: 1;
+        min-height: 0;
+        position: relative;
+      }
+      #playtest-hud {
+        display: flex;
+        align-items: center;
+        gap: 16px;
+        padding: 6px 16px;
+        background: #1f242b;
+        border-top: 1px solid #2d343d;
+        color: #e6e9ec;
+        font-size: 12px;
+      }
+      #playtest-time {
+        color: #a5d6a7;
+        font-variant-numeric: tabular-nums;
+      }
+      #playtest-stats {
+        color: #e6e9ec;
+        flex: 1;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      #playtest-feedback {
+        color: #8bc34a;
+        min-height: 14px;
+      }
+      .dialog-backdrop {
+        position: fixed;
+        inset: 0;
+        background: rgba(0, 0, 0, 0.5);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        z-index: 40;
+      }
+      .dialog {
+        width: 520px;
+        max-width: 92vw;
+        max-height: 82vh;
+        overflow: auto;
+        background: #1c2127;
+        border: 1px solid #2d343d;
+        border-radius: 8px;
+        padding: 18px;
+        display: flex;
+        flex-direction: column;
+        gap: 12px;
+      }
+      .dialog h3 {
+        margin: 0;
+      }
+      .pack-checks {
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+      }
+      .dialog-actions {
+        display: flex;
+        gap: 10px;
+      }
+      #pack-result {
+        white-space: pre-wrap;
+        font-family: ui-monospace, monospace;
+        font-size: 12px;
+        color: #8bc34a;
+        max-height: 180px;
+        overflow: auto;
+        border: 1px solid #2d343d;
+        border-radius: 4px;
+        padding: 8px;
+        background: #171b20;
+        margin: 0;
+      }
+      #pack-result.status-error {
+        color: #ef5350;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="app">
+      <section id="home" class="screen">
+        <h1>nEngine Editor</h1>
+        <p class="muted">Create or open an nEngine project — a folder with <code>content/</code> (and optional <code>mods/</code>).</p>
+        <div id="home-actions">
+          <button id="btn-open" type="button" class="primary">Open project…</button>
+          <button id="btn-new" type="button">New project…</button>
+        </div>
+        <h2>Recent projects</h2>
+        <ul id="recent-list"></ul>
+        <p id="home-error"></p>
+      </section>
+      <section id="editor" class="screen hidden">
+        <header id="topbar">
+          <span id="project-name"></span>
+          <span id="project-path"></span>
+          <span id="mcp-url" title="Embedded MCP endpoint — paste into your AI tool"></span>
+          <button id="btn-mcp-copy" type="button" disabled>Copy MCP URL</button>
+          <button id="btn-package" type="button">Package</button>
+          <button id="btn-play" type="button" class="primary">Play</button>
+          <button id="btn-close" type="button">Close project</button>
+        </header>
+        <div id="workspace">
+          <aside id="content-browser">
+            <h3>Content</h3>
+            <ul id="content-list"></ul>
+          </aside>
+          <main id="editor-panel">
+            <h3>Editor</h3>
+            <p class="placeholder">Select a content kind from the sidebar. Maps open the visual tile editor.</p>
+          </main>
+        </div>
+        <div id="statusbar">
+          <span id="status"></span>
+        </div>
+      </section>
+      <section id="playtest" class="screen hidden">
+        <header id="playtest-topbar">
+          <span id="playtest-title">Playtest</span>
+          <span id="playtest-status"></span>
+          <button id="btn-playtest-exit" type="button">Exit</button>
+        </header>
+        <div id="playtest-game"></div>
+        <div id="playtest-hud">
+          <span id="playtest-time"></span>
+          <span id="playtest-stats"></span>
+          <span id="playtest-feedback"></span>
+        </div>
+      </section>
+      <section id="pack-dialog" class="hidden">
+        <div class="dialog-backdrop">
+          <div class="dialog">
+            <h3>Package</h3>
+            <p class="muted">Run the nengine-pack pipeline on this project: content → bundle.json + static site, plus optional desktop app and installer config. Fields pre-fill from project.yaml and are saved back on run.</p>
+            <label class="form-field">Product name<input id="pack-name" type="text"></label>
+            <label class="form-field">Icon path<input id="pack-icon" type="text" placeholder="icon.png (stored for future use — v1 ships the default Electron icon)"></label>
+            <div class="pack-checks">
+              <label class="form-boolean"><input id="pack-desktop" type="checkbox">Desktop app (Electron shell)</label>
+              <label class="form-boolean"><input id="pack-installer" type="checkbox">Installer config (electron-builder)</label>
+              <label class="form-boolean"><input id="pack-mods" type="checkbox">Include Mods (<code>mods/</code>)</label>
+              <label class="form-boolean"><input id="pack-split" type="checkbox">Split assets into <code>nengine-assets/</code></label>
+            </div>
+            <label class="form-field">Output directory<input id="pack-outdir" type="text"></label>
+            <pre id="pack-result"></pre>
+            <div class="dialog-actions">
+              <button id="btn-pack-run" type="button" class="primary">Run package</button>
+              <button id="btn-pack-close" type="button">Close</button>
+            </div>
+          </div>
+        </div>
+      </section>
+    </div>
+    <script type="module" src="/src/renderer/main.ts"></script>
+  </body>
+</html>

+ 30 - 0
apps/editor/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "@nengine/editor",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Desktop editor (D40): a single Electron app, editor + runner in one. E1 = project layer (open/new/recent + project.yaml + content browsing); E2 = visual map editing (paint/erase/spawn/walkable + save via IPC).",
+  "main": "dist/main/main.js",
+  "scripts": {
+    "build": "tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json && tsc -p tsconfig.renderer.json && vite build",
+    "dev": "pnpm build && electron .",
+    "start": "electron .",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "@nengine/core": "workspace:*",
+    "@nengine/mcp": "workspace:*",
+    "@nengine/pack": "workspace:*",
+    "@nengine/render": "workspace:*",
+    "yaml": "^2.6.0",
+    "zod": "^3.23.8"
+  },
+  "devDependencies": {
+    "@modelcontextprotocol/sdk": "^1.30.0",
+    "@types/node": "^22.0.0",
+    "electron": "^43.4.0",
+    "typescript": "^5.6.0",
+    "vite": "^7.0.0",
+    "vitest": "^3.0.0"
+  }
+}

+ 66 - 0
apps/editor/src/edit-engine.ts

@@ -0,0 +1,66 @@
+import { readFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { isAbsolute, join, relative, sep } from "node:path";
+import { contentDirNames, contentSchemas, createEngine, isContentKind } from "@nengine/core";
+import type { ContentKind, Engine } from "@nengine/core";
+import { parse } from "yaml";
+import { resolveWithin } from "./project.js";
+
+/** Maps a project-relative content path (e.g. `content/items/hoe.yaml` or the
+ * single-file `content/time.yaml`) to its content kind, mirroring the
+ * ContentLoader's dir-vs-single-file convention; `undefined` for non-content
+ * paths (project.yaml, stray yaml outside content/). */
+export function contentKindForPath(root: string, rel: string): ContentKind | undefined {
+  const abs = resolveWithin(root, rel);
+  const relToContent = relative(resolveWithin(root, "content"), abs);
+  if (relToContent.startsWith("..") || isAbsolute(relToContent) || relToContent === "") return undefined;
+  const parts = relToContent.split(sep).filter((part) => part !== "");
+  if (parts.length === 0) return undefined;
+  if (parts.length === 1) {
+    const base = parts[0]?.replace(/\.(yaml|yml)$/i, "");
+    if (base !== undefined && isContentKind(base)) return base;
+    return undefined;
+  }
+  if (parts.length !== 2) return undefined;
+  const dir = parts[0];
+  const kind = (Object.keys(contentDirNames) as ContentKind[]).find((k) => contentDirNames[k] === dir);
+  return kind;
+}
+
+/** Re-registers a GUI-written content file into the editing engine's registry
+ * (parse → schema-validate → register). This is the GUI→AI half of the
+ * dual-channel: the GUI writes YAML to disk (its normal path), and the engine's
+ * MCP surface must see the same def the GUI just saved. Invalid content leaves
+ * the registry untouched (the last valid def stays visible, matching the
+ * authoring layer's write-validated-only semantics). */
+export async function syncContentWrite(engine: Engine, root: string, rel: string): Promise<void> {
+  const kind = contentKindForPath(root, rel);
+  if (kind === undefined) return;
+  let text: string;
+  try {
+    text = await readFile(resolveWithin(root, rel), "utf8");
+  } catch {
+    return;
+  }
+  let doc: unknown;
+  try {
+    doc = parse(text);
+  } catch {
+    return;
+  }
+  const validated = contentSchemas[kind].safeParse(doc);
+  if (!validated.success) return;
+  engine.registry.register(kind, validated.data);
+}
+
+/** Full-fs editing engine for an open project (the instance the embedded MCP
+ * surface and the shared authoring layer operate on). `modsDir` is passed only
+ * when the folder exists (the mod loader needs a real dir). */
+export function createEditEngine(root: string): Engine {
+  const contentDir = resolveWithin(root, "content");
+  const modsPath = join(root, "mods");
+  return createEngine({
+    contentDir,
+    ...(existsSync(modsPath) ? { modsDir: modsPath } : {}),
+  });
+}

+ 165 - 0
apps/editor/src/ipc.ts

@@ -0,0 +1,165 @@
+import type { ContentKind, ContentBundle } from "@nengine/core";
+import type { EditorProjectYaml } from "./project-yaml.js";
+
+export const IpcChannels = {
+  open: "project:open",
+  new: "project:new",
+  close: "project:close",
+  listRecent: "project:listRecent",
+  addRecent: "project:addRecent",
+  readYaml: "project:readYaml",
+  writeYaml: "project:writeYaml",
+  readProjectYaml: "project:readProjectYaml",
+  writeProjectYaml: "project:writeProjectYaml",
+  listContent: "project:listContent",
+  stats: "project:stats",
+  playtestStart: "playtest:start",
+  mcpStatus: "mcp:status",
+  packRun: "pack:run",
+} as const;
+
+export interface EditorProjectInfo {
+  root: string;
+  name: string;
+  hasContent: boolean;
+  hasMods: boolean;
+  projectYaml: EditorProjectYaml | null;
+  projectYamlError?: string;
+}
+
+export interface EditorRecentEntry {
+  root: string;
+  name: string;
+}
+
+export interface EditorContentFile {
+  id: string;
+  name: string;
+  path: string;
+}
+
+export interface EditorKindListing {
+  kind: ContentKind;
+  count: number;
+  files: EditorContentFile[];
+}
+
+export interface EditorContentListing {
+  kinds: EditorKindListing[];
+  mods: string[];
+  assets: string[];
+}
+
+export interface EditorProjectStats {
+  kinds: Record<string, number>;
+  total: number;
+  mods: number;
+  assets: number;
+}
+
+export interface EditorOpenArgs {
+  dir?: string;
+}
+
+export interface EditorProjectResult {
+  project: EditorProjectInfo | null;
+}
+
+export interface EditorRecentResult {
+  entries: EditorRecentEntry[];
+}
+
+export interface EditorYamlReadResult {
+  content: string;
+}
+
+export interface EditorYamlWriteArgs {
+  root: string;
+  path: string;
+  content: string;
+}
+
+export interface EditorProjectYamlReadResult {
+  data: EditorProjectYaml | null;
+}
+
+export interface EditorProjectYamlWriteArgs {
+  root: string;
+  data: EditorProjectYaml;
+}
+
+export interface EditorListContentResult {
+  listing: EditorContentListing;
+}
+
+export interface EditorStatsResult {
+  stats: EditorProjectStats;
+}
+
+/** E5: a compiled ContentBundle (inline assets) for the playtest engine, or a
+ * clear error for content that fails compilation in the main process. */
+export type EditorPlaytestStartResult = { bundle: ContentBundle } | { error: string };
+
+/** E6: the embedded MCP server's status — `running` when a project's editing
+ * engine is attached to the persistent streamable HTTP endpoint at `url`
+ * (`http://127.0.0.1:<port>/mcp`). The URL is stable for the whole session. */
+export interface EditorMcpStatusResult {
+  running: boolean;
+  url: string;
+}
+
+export interface EditorPlaytestApi {
+  start(root: string): Promise<EditorPlaytestStartResult>;
+}
+
+export interface EditorMcpApi {
+  status(): Promise<EditorMcpStatusResult>;
+}
+
+/** E7: the Package dialog's fields (nengine-pack flags + icon/outDir). */
+export interface EditorPackOptions {
+  name: string;
+  icon: string;
+  desktop: boolean;
+  installer: boolean;
+  mods: boolean;
+  splitAssets: boolean;
+  outDir: string;
+}
+
+export interface EditorPackRunArgs {
+  root: string;
+  options: EditorPackOptions;
+}
+
+export interface EditorPackStats {
+  defCount: number;
+  kinds: number;
+  assetCount: number;
+  modCount: number;
+  desktop: boolean;
+  installer: boolean;
+  desktopDir?: string;
+}
+
+export type EditorPackRunResult =
+  | { ok: true; outDir: string; stats: EditorPackStats }
+  | { ok: false; error: string };
+
+export interface EditorPackApi {
+  run(args: EditorPackRunArgs): Promise<EditorPackRunResult>;
+}
+
+export interface EditorProjectApi {
+  open(args?: EditorOpenArgs): Promise<EditorProjectResult>;
+  "new"(): Promise<EditorProjectResult>;
+  close(): Promise<void>;
+  listRecent(): Promise<EditorRecentResult>;
+  addRecent(entry: EditorRecentEntry): Promise<EditorRecentResult>;
+  readYaml(root: string, path: string): Promise<EditorYamlReadResult>;
+  writeYaml(args: EditorYamlWriteArgs): Promise<void>;
+  readProjectYaml(root: string): Promise<EditorProjectYamlReadResult>;
+  writeProjectYaml(args: EditorProjectYamlWriteArgs): Promise<void>;
+  listContent(root: string): Promise<EditorListContentResult>;
+  stats(root: string): Promise<EditorStatsResult>;
+}

+ 299 - 0
apps/editor/src/main.ts

@@ -0,0 +1,299 @@
+import { app, BrowserWindow, dialog, ipcMain } from "electron";
+import { createServer } from "node:http";
+import type { Server } from "node:http";
+import { existsSync, createReadStream, statSync } from "node:fs";
+import { basename, extname, join, normalize, resolve, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+import type { Engine } from "@nengine/core";
+import { compileBundle } from "@nengine/pack";
+import { createEditEngine, syncContentWrite } from "./edit-engine.js";
+import { IpcChannels } from "./ipc.js";
+import type {
+  EditorMcpStatusResult,
+  EditorOpenArgs,
+  EditorPackRunArgs,
+  EditorPackRunResult,
+  EditorPlaytestStartResult,
+  EditorProjectInfo,
+  EditorProjectResult,
+  EditorProjectYamlWriteArgs,
+  EditorRecentResult,
+  EditorYamlWriteArgs,
+} from "./ipc.js";
+import { createEditorMcpHttpServer, resolveEditorMcpDirs, runStdioMcp } from "./mcp-server.js";
+import type { EditorMcpHttpHandle } from "./mcp-server.js";
+import { runEditorPack } from "./pack.js";
+import { listContent, openProject, readYaml, resolveWithin, stats, writeYaml } from "./project.js";
+import { readProjectYaml, writeProjectYaml } from "./project-yaml.js";
+import { addRecent, loadRecent, saveRecent } from "./recent.js";
+import type { EditorRecentEntry } from "./ipc.js";
+import { newProject } from "./template.js";
+
+const here = fileURLToPath(new URL(".", import.meta.url));
+const RENDERER_ROOT = resolve(here, "../renderer");
+
+function recentFile(): string {
+  return join(app.getPath("userData"), "recent-projects.json");
+}
+
+const MIME: Record<string, string> = {
+  ".html": "text/html; charset=utf-8",
+  ".js": "text/javascript; charset=utf-8",
+  ".css": "text/css; charset=utf-8",
+  ".json": "application/json; charset=utf-8",
+  ".png": "image/png",
+  ".svg": "image/svg+xml",
+  ".ico": "image/x-icon",
+  ".map": "application/json; charset=utf-8",
+};
+
+function contentType(path: string): string {
+  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
+}
+
+function serve(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse): void {
+  if (req.method !== "GET") {
+    res.writeHead(405, { "Content-Type": "text/plain" });
+    res.end("method not allowed");
+    return;
+  }
+  let pathname: string;
+  try {
+    pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
+  } catch {
+    res.writeHead(400, { "Content-Type": "text/plain" });
+    res.end("bad request");
+    return;
+  }
+  const file = resolve(RENDERER_ROOT, "." + normalize(pathname));
+  if (file !== RENDERER_ROOT && !file.startsWith(`${RENDERER_ROOT}${sep}`)) {
+    res.writeHead(403, { "Content-Type": "text/plain" });
+    res.end("forbidden");
+    return;
+  }
+  try {
+    if (statSync(file).isFile()) {
+      res.writeHead(200, { "Content-Type": contentType(file) });
+      createReadStream(file).pipe(res);
+      return;
+    }
+  } catch {
+    // fall through to 404 (exact files only)
+  }
+  res.writeHead(404, { "Content-Type": "text/plain" });
+  res.end("not found");
+}
+
+function startServer(): Promise<{ server: Server; port: number }> {
+  return new Promise((resolveStart, reject) => {
+    const server = createServer(serve);
+    server.on("error", reject);
+    server.listen(0, "127.0.0.1", () => {
+      const address = server.address();
+      const port = typeof address === "object" && address !== null ? address.port : 0;
+      resolveStart({ server, port });
+    });
+  });
+}
+
+async function pushRecent(project: EditorProjectInfo): Promise<void> {
+  const file = recentFile();
+  const entries = await loadRecent(file);
+  await saveRecent(file, addRecent(entries, { root: project.root, name: project.name }));
+}
+
+/** E6 editing-engine lifecycle: one engine instance per open project, owned by
+ * main (it needs fs — `createEngine({contentDir, modsDir})` full mode + the
+ * shared authoring layer's write paths). The embedded MCP server follows it:
+ * attach on open, detach on switch/close, always on while a project is open. */
+let editingEngine: Engine | null = null;
+let mcpHttp: EditorMcpHttpHandle | null = null;
+
+async function startEditingEngine(root: string): Promise<void> {
+  await stopEditingEngine();
+  try {
+    const engine = createEditEngine(root);
+    await engine.start();
+    editingEngine = engine;
+    if (mcpHttp !== null) await mcpHttp.attach(engine);
+  } catch (err) {
+    console.error(`editing engine failed to start for ${root}:`, err);
+  }
+}
+
+async function stopEditingEngine(): Promise<void> {
+  if (mcpHttp !== null) await mcpHttp.detach();
+  if (editingEngine !== null) {
+    await editingEngine.stop();
+    editingEngine = null;
+  }
+}
+
+function registerProjectIpc(): void {
+  ipcMain.handle(IpcChannels.open, async (_event, args?: EditorOpenArgs): Promise<EditorProjectResult> => {
+    if (args?.dir !== undefined) {
+      const project = await openProject(args.dir);
+      if (project !== null) {
+        await pushRecent(project);
+        await startEditingEngine(project.root);
+      }
+      return { project };
+    }
+    const picked = await dialog.showOpenDialog({
+      title: "Open nEngine project",
+      properties: ["openDirectory"],
+      buttonLabel: "Open",
+    });
+    if (picked.canceled || picked.filePaths.length === 0) return { project: null };
+    const project = await openProject(picked.filePaths[0] ?? "");
+    await pushRecent(project);
+    await startEditingEngine(project.root);
+    return { project };
+  });
+
+  ipcMain.handle(IpcChannels.new, async (): Promise<EditorProjectResult> => {
+    const picked = await dialog.showOpenDialog({
+      title: "New nEngine project",
+      properties: ["openDirectory", "createDirectory"],
+      buttonLabel: "Create here",
+    });
+    if (picked.canceled || picked.filePaths.length === 0) return { project: null };
+    const dir = picked.filePaths[0] ?? "";
+    const project = await newProject(basename(dir), dir);
+    await pushRecent(project);
+    await startEditingEngine(project.root);
+    return { project };
+  });
+
+  ipcMain.handle(IpcChannels.listRecent, async (): Promise<EditorRecentResult> => {
+    return { entries: await loadRecent(recentFile()) };
+  });
+
+  ipcMain.handle(IpcChannels.close, async (): Promise<void> => {
+    await stopEditingEngine();
+  });
+
+  ipcMain.handle(IpcChannels.addRecent, async (_event, entry: EditorRecentEntry): Promise<EditorRecentResult> => {
+    const file = recentFile();
+    const entries = await loadRecent(file);
+    await saveRecent(file, addRecent(entries, entry));
+    return { entries: await loadRecent(file) };
+  });
+
+  ipcMain.handle(IpcChannels.readYaml, async (_event, root: string, path: string) => {
+    return { content: await readYaml(root, path) };
+  });
+
+  ipcMain.handle(IpcChannels.writeYaml, async (_event, args: EditorYamlWriteArgs) => {
+    await writeYaml(args.root, args.path, args.content);
+    // Dual-channel (D40 #16): GUI writes land on disk AND re-register into the
+    // editing engine's registry so the MCP surface sees the same def.
+    if (editingEngine !== null) await syncContentWrite(editingEngine, args.root, args.path);
+  });
+
+  ipcMain.handle(IpcChannels.readProjectYaml, async (_event, root: string) => {
+    return { data: await readProjectYaml(root) };
+  });
+
+  ipcMain.handle(IpcChannels.writeProjectYaml, async (_event, args: EditorProjectYamlWriteArgs) => {
+    await writeProjectYaml(args.root, args.data);
+  });
+
+  ipcMain.handle(IpcChannels.listContent, async (_event, root: string) => {
+    return { listing: await listContent(root) };
+  });
+
+  ipcMain.handle(IpcChannels.stats, async (_event, root: string) => {
+    return { stats: await stats(root) };
+  });
+
+  ipcMain.handle(IpcChannels.mcpStatus, async (): Promise<EditorMcpStatusResult> => {
+    return { running: mcpHttp?.running ?? false, url: mcpHttp?.url ?? "" };
+  });
+
+  ipcMain.handle(
+    IpcChannels.playtestStart,
+    async (_event, root: string): Promise<EditorPlaytestStartResult> => {
+      try {
+        const contentDir = resolveWithin(root, "content");
+        const modsDir = join(root, "mods");
+        const bundle = await compileBundle(contentDir, existsSync(modsDir) ? modsDir : undefined);
+        return { bundle };
+      } catch (err) {
+        return { error: err instanceof Error ? err.message : String(err) };
+      }
+    },
+  );
+
+  ipcMain.handle(
+    IpcChannels.packRun,
+    async (_event, args: EditorPackRunArgs): Promise<EditorPackRunResult> => {
+      return runEditorPack(args.root, args.options, existsSync(join(args.root, "mods")));
+    },
+  );
+}
+
+function createWindow(): BrowserWindow {
+  const win = new BrowserWindow({
+    width: 960,
+    height: 640,
+    resizable: true,
+    title: "nEngine Editor",
+    autoHideMenuBar: true,
+    webPreferences: {
+      preload: join(here, "../preload/preload.js"),
+      contextIsolation: true,
+      nodeIntegration: false,
+    },
+  });
+  win.on("page-title-updated", (event) => event.preventDefault());
+  return win;
+}
+
+let server: Server | undefined;
+let port = 0;
+
+function isMcpStdioLaunch(): boolean {
+  return process.argv.includes("--mcp-stdio");
+}
+
+app.whenReady().then(async () => {
+  if (isMcpStdioLaunch()) {
+    try {
+      await runStdioMcp(resolveEditorMcpDirs({ argv: process.argv.slice(1) }));
+    } catch (err) {
+      console.error("nengine editor --mcp-stdio failed:", err);
+      app.quit();
+    }
+    return;
+  }
+  registerProjectIpc();
+  mcpHttp = await createEditorMcpHttpServer();
+  const started = await startServer();
+  server = started.server;
+  port = started.port;
+  const devUrl = process.env.NENGINE_EDITOR_RENDERER_URL;
+  const win = createWindow();
+  if (devUrl) {
+    void win.loadURL(devUrl);
+  } else {
+    void win.loadURL(`http://127.0.0.1:${port}/index.html`);
+  }
+  win.on("closed", () => server?.close());
+  app.on("activate", () => {
+    if (BrowserWindow.getAllWindows().length === 0) createWindow();
+  });
+});
+
+app.on("will-quit", () => {
+  void stopEditingEngine();
+  if (mcpHttp !== null) {
+    const handle = mcpHttp;
+    mcpHttp = null;
+    void handle.close();
+  }
+});
+
+app.on("window-all-closed", () => {
+  app.quit();
+});

+ 263 - 0
apps/editor/src/mcp-server.ts

@@ -0,0 +1,263 @@
+import { randomUUID } from "node:crypto";
+import { existsSync } from "node:fs";
+import { createServer } from "node:http";
+import type { IncomingMessage, Server, ServerResponse } from "node:http";
+import { resolve } from "node:path";
+import { parseArgs } from "node:util";
+import type { Engine } from "@nengine/core";
+import { createEngine } from "@nengine/core";
+import { createEngineNamespaces, McpGateway } from "@nengine/mcp";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import {
+  CallToolRequestSchema,
+  ErrorCode,
+  GetPromptRequestSchema,
+  ListPromptsRequestSchema,
+  ListResourcesRequestSchema,
+  ListToolsRequestSchema,
+  McpError,
+  ReadResourceRequestSchema,
+} from "@modelcontextprotocol/sdk/types.js";
+import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
+
+export const MCP_SERVER_NAME = "nengine-editor-mcp";
+export const MCP_SERVER_VERSION = "0.1.0";
+export const MCP_PATH = "/mcp";
+
+export function createGateway(engine: Engine): McpGateway {
+  const gateway = new McpGateway();
+  for (const namespace of createEngineNamespaces(engine, { gateway })) {
+    gateway.register(namespace);
+  }
+  return gateway;
+}
+
+export function createNengineMcpServer(engine: Engine, gateway: McpGateway = createGateway(engine)): McpServer {
+  const server = new McpServer(
+    { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
+    { capabilities: { tools: {}, resources: {}, prompts: {} } },
+  );
+  server.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: gateway.listTools() }));
+  server.server.setRequestHandler(CallToolRequestSchema, async (request) =>
+    gateway.callTool(request.params.name, request.params.arguments ?? {}),
+  );
+  server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: gateway.listResources() }));
+  server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
+    try {
+      return await gateway.readResource(request.params.uri);
+    } catch (err) {
+      throw toMcpError(err);
+    }
+  });
+  server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: gateway.listPrompts() }));
+  server.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
+    try {
+      return await gateway.getPrompt(request.params.name, request.params.arguments ?? {});
+    } catch (err) {
+      throw toMcpError(err);
+    }
+  });
+  return server;
+}
+
+function toMcpError(err: unknown): McpError {
+  if (err instanceof McpError) return err;
+  const message = err instanceof Error ? err.message : String(err);
+  return new McpError(ErrorCode.InvalidRequest, message);
+}
+
+export interface EditorMcpDirs {
+  contentDir: string;
+  modsDir: string | undefined;
+}
+
+export interface ResolveMcpDirsInput {
+  argv?: string[];
+  cwd?: string;
+}
+
+/** Resolve the editing-engine roots for headless stdio mode: `--dir <project>`
+ * (content = `<dir>/content`, mods = `<dir>/mods` when present) or explicit
+ * `--content-dir`/`--mods-dir` (mirroring apps/mcp-server's CLI, minus env). */
+export function resolveEditorMcpDirs(input: ResolveMcpDirsInput = {}): EditorMcpDirs {
+  const argv = input.argv ?? process.argv.slice(2);
+  const cwd = input.cwd ?? process.cwd();
+  const parsed = parseArgs({
+    args: argv,
+    options: {
+      dir: { type: "string" },
+      "content-dir": { type: "string" },
+      "mods-dir": { type: "string" },
+    },
+    strict: false,
+  });
+  const dir = cliString(parsed.values, "dir");
+  if (dir !== undefined) {
+    const root = resolve(cwd, dir);
+    const modsPath = resolve(root, "mods");
+    const explicitMods = cliString(parsed.values, "mods-dir");
+    return {
+      contentDir: resolve(root, "content"),
+      modsDir: explicitMods !== undefined ? resolve(cwd, explicitMods) : existsSync(modsPath) ? modsPath : undefined,
+    };
+  }
+  const contentDir = cliString(parsed.values, "content-dir");
+  if (contentDir === undefined) {
+    throw new Error("--mcp-stdio requires --dir <project> (or --content-dir <dir>)");
+  }
+  const modsRaw = cliString(parsed.values, "mods-dir");
+  return {
+    contentDir: resolve(cwd, contentDir),
+    modsDir: modsRaw !== undefined ? resolve(cwd, modsRaw) : undefined,
+  };
+}
+
+function cliString(values: Record<string, string | boolean | undefined>, key: string): string | undefined {
+  const value = values[key];
+  return typeof value === "string" && value !== "" ? value : undefined;
+}
+
+/** Headless stdio MCP server over a fresh engine for `--mcp-stdio` mode: the
+ * editor binary doubles as a drop-in MCP server for CLI/scripts, mirroring
+ * apps/mcp-server but with the editor's gateway wiring. Keeps the process
+ * alive until SIGINT/SIGTERM (the parent MCP client usually closes us). */
+export async function runStdioMcp(dirs: EditorMcpDirs): Promise<void> {
+  const engine = createEngine({ contentDir: dirs.contentDir, ...(dirs.modsDir !== undefined ? { modsDir: dirs.modsDir } : {}) });
+  await engine.start();
+  const server = createNengineMcpServer(engine);
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
+  const shutdown = async (): Promise<void> => {
+    await server.close();
+    await engine.stop();
+    process.exit(0);
+  };
+  process.on("SIGINT", () => void shutdown());
+  process.on("SIGTERM", () => void shutdown());
+}
+
+/** Embedded MCP surface for the editing session: a single persistent streamable
+ * HTTP server (127.0.0.1, ephemeral port) whose `/mcp` endpoint is bound to the
+ * CURRENT project's engine gateway. The URL is stable for the whole session;
+ * `attach`/`detach` swap the served engine on project open/switch/close. */
+export interface EditorMcpHttpHandle {
+  readonly url: string;
+  readonly running: boolean;
+  attach(engine: Engine): Promise<void>;
+  detach(): Promise<void>;
+  close(): Promise<void>;
+}
+
+export async function createEditorMcpHttpServer(): Promise<EditorMcpHttpHandle> {
+  let mcpServer: McpServer | undefined;
+  let transport: StreamableHTTPServerTransport | undefined;
+  let httpServer: Server | undefined;
+  let url = "";
+
+  async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
+    let pathname = "/";
+    try {
+      pathname = decodeURIComponent(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
+    } catch {
+      res.writeHead(400, { "Content-Type": "text/plain" });
+      res.end("bad request");
+      return;
+    }
+    if (pathname !== MCP_PATH) {
+      res.writeHead(404, { "Content-Type": "text/plain" });
+      res.end("not found");
+      return;
+    }
+    if (req.method === "OPTIONS") {
+      res.writeHead(204, {
+        "Access-Control-Allow-Origin": "*",
+        "Access-Control-Allow-Methods": "GET, POST, OPTIONS, DELETE",
+        "Access-Control-Allow-Headers": "content-type, mcp-session-id, authorization, last-event-id",
+        "Access-Control-Max-Age": "86400",
+      });
+      res.end();
+      return;
+    }
+    if (transport === undefined) {
+      res.writeHead(503, { "Content-Type": "application/json" });
+      res.end(JSON.stringify({ error: "no project open" }));
+      return;
+    }
+    try {
+      await transport.handleRequest(req, res);
+    } catch (err) {
+      if (!res.headersSent) {
+        res.writeHead(400, { "Content-Type": "application/json" });
+        res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
+      } else {
+        res.destroy();
+      }
+    }
+  }
+
+  httpServer = createServer((req, res) => {
+    void handle(req, res).catch(() => {
+      if (!res.headersSent) {
+        res.writeHead(500, { "Content-Type": "application/json" });
+        res.end(JSON.stringify({ error: "internal error" }));
+      } else {
+        res.destroy();
+      }
+    });
+  });
+  await new Promise<void>((resolveStart, reject) => {
+    const server = httpServer;
+    if (server === undefined) {
+      reject(new Error("mcp http server not created"));
+      return;
+    }
+    server.once("error", reject);
+    server.listen(0, "127.0.0.1", () => resolveStart());
+  });
+  const address = httpServer.address();
+  const port = typeof address === "object" && address !== null ? address.port : 0;
+  url = `http://127.0.0.1:${port}${MCP_PATH}`;
+
+  return {
+    get url(): string {
+      return url;
+    },
+    get running(): boolean {
+      return mcpServer !== undefined;
+    },
+    async attach(engine: Engine): Promise<void> {
+      await detachImpl();
+      const server = createNengineMcpServer(engine);
+      const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
+      // The SDK's StreamableHTTPServerTransport exposes onclose/onerror/onmessage
+      // as get/set accessors typed `(...) | undefined`, which does not satisfy its
+      // own `Transport` interface under `exactOptionalPropertyTypes`; the runtime
+      // shape is identical, so the assertion is to the SDK's declared interface.
+      await server.connect(t as Transport);
+      mcpServer = server;
+      transport = t;
+    },
+    async detach(): Promise<void> {
+      await detachImpl();
+    },
+    async close(): Promise<void> {
+      await detachImpl();
+      const server = httpServer;
+      httpServer = undefined;
+      if (server !== undefined) {
+        await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
+      }
+    },
+  };
+
+  async function detachImpl(): Promise<void> {
+    const server = mcpServer;
+    const t = transport;
+    mcpServer = undefined;
+    transport = undefined;
+    if (server !== undefined) await server.close();
+    if (t !== undefined) await t.close();
+  }
+}

+ 14 - 0
apps/editor/src/mcp-stdio.ts

@@ -0,0 +1,14 @@
+import { pathToFileURL } from "node:url";
+import { resolveEditorMcpDirs, runStdioMcp } from "./mcp-server.js";
+
+export async function main(): Promise<void> {
+  await runStdioMcp(resolveEditorMcpDirs());
+}
+
+const entry = process.argv[1];
+if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
+  main().catch((err: unknown) => {
+    console.error("nengine editor --mcp-stdio failed to start:", err);
+    process.exit(1);
+  });
+}

+ 82 - 0
apps/editor/src/pack-options.ts

@@ -0,0 +1,82 @@
+import type { EditorPackOptions, EditorPackStats } from "./ipc.js";
+import type { EditorProjectYaml } from "./project-yaml.js";
+
+export const DEFAULT_PACK_SUBDIR = "dist-pack";
+
+export function defaultPackOutDir(root: string): string {
+  return `${root}/${DEFAULT_PACK_SUBDIR}`;
+}
+
+export function dialogDefaultsFromProject(
+  projectYaml: EditorProjectYaml | null,
+  root: string,
+  fallbackName: string,
+): EditorPackOptions {
+  return {
+    name: projectYaml?.pack?.name ?? projectYaml?.name ?? fallbackName ?? "",
+    icon: projectYaml?.icon ?? "",
+    desktop: projectYaml?.pack?.installer === true,
+    installer: projectYaml?.pack?.installer === true,
+    mods: projectYaml?.pack?.mods === true,
+    splitAssets: projectYaml?.pack?.splitAssets === true,
+    outDir: defaultPackOutDir(root),
+  };
+}
+
+export function projectYamlFromPack(
+  options: EditorPackOptions,
+  prev: EditorProjectYaml | null,
+): EditorProjectYaml {
+  const result: EditorProjectYaml = { ...(prev ?? {}) };
+  const pack = { ...(result.pack ?? {}) };
+  const name = options.name.trim();
+  if (name !== "") pack.name = name;
+  else delete pack.name;
+  pack.installer = options.installer;
+  pack.mods = options.mods;
+  pack.splitAssets = options.splitAssets;
+  result.pack = pack;
+  const icon = options.icon.trim();
+  if (icon !== "") result.icon = icon;
+  else delete result.icon;
+  return result;
+}
+
+export interface EditorPackRequest {
+  contentDir: string;
+  outDir: string;
+  desktop: boolean;
+  installer: boolean;
+  splitAssets: boolean;
+  modsDir?: string;
+  name?: string;
+}
+
+export function resolvePackRequest(
+  root: string,
+  options: EditorPackOptions,
+  hasMods: boolean,
+): EditorPackRequest {
+  const request: EditorPackRequest = {
+    contentDir: `${root}/content`,
+    outDir: options.outDir.trim() !== "" ? options.outDir : defaultPackOutDir(root),
+    desktop: options.installer || options.desktop,
+    installer: options.installer,
+    splitAssets: options.splitAssets,
+  };
+  const name = options.name.trim();
+  if (name !== "") request.name = name;
+  if (options.mods && hasMods) request.modsDir = `${root}/mods`;
+  return request;
+}
+
+export function packHints(stats: EditorPackStats): string[] {
+  const hints: string[] = ["site: serve the output folder statically and open index.html"];
+  if (stats.desktop) {
+    hints.push('desktop: cd into the desktop folder, then run "npm install && npm start"');
+  }
+  if (stats.installer) {
+    hints.push('installer: run "npm install && npm run dist" in the desktop folder (Linux AppImage; NSIS needs a Windows host)');
+  }
+  return hints;
+}

+ 36 - 0
apps/editor/src/pack.ts

@@ -0,0 +1,36 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { packGame } from "@nengine/pack";
+import type { EditorPackOptions, EditorPackRunResult, EditorPackStats } from "./ipc.js";
+import { resolvePackRequest } from "./pack-options.js";
+
+export async function runEditorPack(
+  root: string,
+  options: EditorPackOptions,
+  hasMods: boolean,
+): Promise<EditorPackRunResult> {
+  const request = resolvePackRequest(root, options, hasMods);
+  try {
+    const result = await packGame({
+      contentDir: request.contentDir,
+      outDir: request.outDir,
+      desktop: request.desktop,
+      installer: request.installer,
+      splitAssets: request.splitAssets,
+      ...(request.modsDir !== undefined ? { modsDir: request.modsDir } : {}),
+      ...(request.name !== undefined ? { name: request.name } : {}),
+    });
+    const stats: EditorPackStats = {
+      defCount: Object.values(result.bundle.content).reduce((sum, defs) => sum + defs.length, 0),
+      kinds: Object.keys(result.bundle.content).length,
+      assetCount: Object.keys(result.bundle.assets ?? result.bundle.assetsIndex ?? {}).length,
+      modCount: result.modCount,
+      desktop: request.desktop,
+      installer: request.installer,
+      ...(result.desktopDir !== undefined ? { desktopDir: result.desktopDir } : {}),
+    };
+    return { ok: true, outDir: result.outDir, stats };
+  } catch (err) {
+    return { ok: false, error: err instanceof Error ? err.message : String(err) };
+  }
+}

+ 41 - 0
apps/editor/src/preload.ts

@@ -0,0 +1,41 @@
+import { contextBridge, ipcRenderer } from "electron";
+import { IpcChannels } from "./ipc.js";
+import type {
+  EditorMcpApi,
+  EditorOpenArgs,
+  EditorPackApi,
+  EditorPackRunArgs,
+  EditorPlaytestApi,
+  EditorProjectApi,
+  EditorProjectYamlWriteArgs,
+  EditorRecentEntry,
+  EditorYamlWriteArgs,
+} from "./ipc.js";
+
+const api: EditorProjectApi = {
+  open: (args?: EditorOpenArgs) => ipcRenderer.invoke(IpcChannels.open, args),
+  "new": () => ipcRenderer.invoke(IpcChannels.new),
+  close: () => ipcRenderer.invoke(IpcChannels.close),
+  listRecent: () => ipcRenderer.invoke(IpcChannels.listRecent),
+  addRecent: (entry: EditorRecentEntry) => ipcRenderer.invoke(IpcChannels.addRecent, entry),
+  readYaml: (root: string, path: string) => ipcRenderer.invoke(IpcChannels.readYaml, root, path),
+  writeYaml: (args: EditorYamlWriteArgs) => ipcRenderer.invoke(IpcChannels.writeYaml, args),
+  readProjectYaml: (root: string) => ipcRenderer.invoke(IpcChannels.readProjectYaml, root),
+  writeProjectYaml: (args: EditorProjectYamlWriteArgs) => ipcRenderer.invoke(IpcChannels.writeProjectYaml, args),
+  listContent: (root: string) => ipcRenderer.invoke(IpcChannels.listContent, root),
+  stats: (root: string) => ipcRenderer.invoke(IpcChannels.stats, root),
+};
+
+const playtest: EditorPlaytestApi = {
+  start: (root: string) => ipcRenderer.invoke(IpcChannels.playtestStart, root),
+};
+
+const mcp: EditorMcpApi = {
+  status: () => ipcRenderer.invoke(IpcChannels.mcpStatus),
+};
+
+const pack: EditorPackApi = {
+  run: (args: EditorPackRunArgs) => ipcRenderer.invoke(IpcChannels.packRun, args),
+};
+
+contextBridge.exposeInMainWorld("nengine", { project: api, playtest, mcp, pack });

+ 57 - 0
apps/editor/src/project-yaml.ts

@@ -0,0 +1,57 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { parse, stringify } from "yaml";
+import { z } from "zod";
+
+export const PROJECT_YAML_NAME = "project.yaml";
+
+export const projectYamlSchema = z
+  .object({
+    name: z.string().min(1).optional(),
+    icon: z.string().min(1).optional(),
+    pack: z
+      .object({
+        name: z.string().min(1).optional(),
+        installer: z.boolean().optional(),
+        mods: z.boolean().optional(),
+        splitAssets: z.boolean().optional(),
+      })
+      .strict()
+      .optional(),
+  })
+  .strict();
+
+export type EditorProjectYaml = z.infer<typeof projectYamlSchema>;
+
+function formatIssues(issues: z.ZodIssue[]): string {
+  return issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
+}
+
+export async function readProjectYaml(root: string): Promise<EditorProjectYaml | null> {
+  let text: string;
+  try {
+    text = await readFile(join(root, PROJECT_YAML_NAME), "utf8");
+  } catch (err) {
+    if (err instanceof Error && "code" in err && err.code === "ENOENT") return null;
+    throw err;
+  }
+  let doc: unknown;
+  try {
+    doc = parse(text);
+  } catch (err) {
+    throw new Error(`invalid project.yaml: ${err instanceof Error ? err.message : String(err)}`);
+  }
+  const parsed = projectYamlSchema.safeParse(doc);
+  if (!parsed.success) {
+    throw new Error(`invalid project.yaml: ${formatIssues(parsed.error.issues)}`);
+  }
+  return parsed.data;
+}
+
+export async function writeProjectYaml(root: string, data: EditorProjectYaml): Promise<void> {
+  const parsed = projectYamlSchema.safeParse(data);
+  if (!parsed.success) {
+    throw new Error(`invalid project.yaml: ${formatIssues(parsed.error.issues)}`);
+  }
+  await writeFile(join(root, PROJECT_YAML_NAME), `${stringify(parsed.data)}`, "utf8");
+}

+ 133 - 0
apps/editor/src/project.ts

@@ -0,0 +1,133 @@
+import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
+import { basename, dirname, join, resolve, sep } from "node:path";
+import { contentDirNames } from "@nengine/core";
+import type { ContentKind } from "@nengine/core";
+import type {
+  EditorContentFile,
+  EditorContentListing,
+  EditorKindListing,
+  EditorProjectInfo,
+  EditorProjectStats,
+} from "./ipc.js";
+import { readProjectYaml } from "./project-yaml.js";
+
+export async function openProject(dir: string): Promise<EditorProjectInfo> {
+  const info = await inspectProject(dir);
+  if (!info.hasContent) {
+    throw new Error("not an nEngine project: missing content/ directory");
+  }
+  return info;
+}
+
+export async function inspectProject(dir: string): Promise<EditorProjectInfo> {
+  const root = resolve(dir);
+  const hasContent = await isDirectory(join(root, "content"));
+  const hasMods = await isDirectory(join(root, "mods"));
+  let projectYaml: EditorProjectInfo["projectYaml"] = null;
+  let projectYamlError: string | undefined;
+  try {
+    projectYaml = await readProjectYaml(root);
+  } catch (err) {
+    projectYamlError = err instanceof Error ? err.message : String(err);
+  }
+  const name = projectYaml?.name ?? basename(root);
+  const info: EditorProjectInfo = { root, name, hasContent, hasMods, projectYaml };
+  if (projectYamlError !== undefined) info.projectYamlError = projectYamlError;
+  return info;
+}
+
+export function resolveWithin(root: string, rel: string): string {
+  const rootResolved = resolve(root);
+  const target = resolve(rootResolved, rel);
+  if (target !== rootResolved && !target.startsWith(`${rootResolved}${sep}`)) {
+    throw new Error(`path escapes project root: ${rel}`);
+  }
+  return target;
+}
+
+export async function readYaml(root: string, rel: string): Promise<string> {
+  assertYamlPath(rel);
+  return readFile(resolveWithin(root, rel), "utf8");
+}
+
+export async function writeYaml(root: string, rel: string, content: string): Promise<void> {
+  assertYamlPath(rel);
+  const target = resolveWithin(root, rel);
+  await mkdir(dirname(target), { recursive: true });
+  await writeFile(target, content, "utf8");
+}
+
+export async function listContent(root: string): Promise<EditorContentListing> {
+  const contentRoot = resolveWithin(root, "content");
+  const kinds: EditorKindListing[] = [];
+  for (const kind of Object.keys(contentDirNames) as ContentKind[]) {
+    const dirName = contentDirNames[kind];
+    const kindDir = join(contentRoot, dirName);
+    let files: { name: string; base: string }[] = [];
+    if (await isDirectory(kindDir)) {
+      const names = (await readdir(kindDir)).filter(isYamlFile).sort();
+      files = names.map((name) => ({ name, base: `content/${dirName}` }));
+    } else {
+      const single = join(contentRoot, `${kind}.yaml`);
+      if (await isFile(single)) {
+        files = [{ name: basename(single), base: "content" }];
+      }
+    }
+    const fileEntries: EditorContentFile[] = files.map((file) => ({
+      id: file.name.replace(/\.(yaml|yml)$/i, ""),
+      name: file.name,
+      path: `${file.base}/${file.name}`,
+    }));
+    kinds.push({ kind, count: files.length, files: fileEntries });
+  }
+  const mods = await listNames(join(root, "mods"));
+  const assets = await listNames(join(contentRoot, "assets"));
+  return { kinds, mods, assets };
+}
+
+export async function stats(root: string): Promise<EditorProjectStats> {
+  const listing = await listContent(root);
+  const kinds: Record<string, number> = {};
+  for (const kind of listing.kinds) kinds[kind.kind] = kind.count;
+  return {
+    kinds,
+    total: listing.kinds.reduce((total, kind) => total + kind.count, 0),
+    mods: listing.mods.length,
+    assets: listing.assets.length,
+  };
+}
+
+function assertYamlPath(rel: string): void {
+  if (!/\.ya?ml$/i.test(rel)) {
+    throw new Error(`not a yaml path: ${rel}`);
+  }
+}
+
+function isYamlFile(name: string): boolean {
+  return /\.(yaml|yml)$/i.test(name);
+}
+
+async function isDirectory(dir: string): Promise<boolean> {
+  try {
+    return (await stat(dir)).isDirectory();
+  } catch {
+    return false;
+  }
+}
+
+async function isFile(file: string): Promise<boolean> {
+  try {
+    return (await stat(file)).isFile();
+  } catch {
+    return false;
+  }
+}
+
+async function listNames(dir: string): Promise<string[]> {
+  try {
+    if (!(await isDirectory(dir))) return [];
+    return (await readdir(dir)).sort();
+  } catch {
+    return [];
+  }
+}

+ 36 - 0
apps/editor/src/recent.ts

@@ -0,0 +1,36 @@
+import { readFile, writeFile } from "node:fs/promises";
+import type { EditorRecentEntry } from "./ipc.js";
+
+export const RECENT_CAP = 10;
+
+export function addRecent(entries: EditorRecentEntry[], project: EditorRecentEntry): EditorRecentEntry[] {
+  const next = entries.filter((entry) => entry.root !== project.root);
+  next.unshift(project);
+  return next.slice(0, RECENT_CAP);
+}
+
+export async function loadRecent(file: string): Promise<EditorRecentEntry[]> {
+  let text: string;
+  try {
+    text = await readFile(file, "utf8");
+  } catch {
+    return [];
+  }
+  try {
+    const parsed: unknown = JSON.parse(text);
+    if (!Array.isArray(parsed)) return [];
+    return parsed.filter(isRecentEntry);
+  } catch {
+    return [];
+  }
+}
+
+export async function saveRecent(file: string, entries: EditorRecentEntry[]): Promise<void> {
+  await writeFile(file, `${JSON.stringify(entries.slice(0, RECENT_CAP), null, 2)}\n`, "utf8");
+}
+
+function isRecentEntry(value: unknown): value is EditorRecentEntry {
+  if (value === null || typeof value !== "object") return false;
+  const entry = value as { root?: unknown; name?: unknown };
+  return typeof entry.root === "string" && typeof entry.name === "string";
+}

+ 718 - 0
apps/editor/src/renderer/form-panel.ts

@@ -0,0 +1,718 @@
+import { stringify } from "yaml";
+import { contentDirNames, contentSchemas } from "@nengine/core-schema";
+import type { ContentKind } from "@nengine/core-schema";
+import { deriveFormSchema } from "./form-schema.js";
+import type { FormBranch, FormFieldSchema, FormSchema } from "./form-schema.js";
+import {
+  applyValue,
+  branchInitial,
+  coerceForValidate,
+  detectUnionBranch,
+  getPath,
+  initialItemValue,
+  initialValue,
+  isPlainObject,
+  setPath,
+  unionBranchValue,
+} from "./form-value.js";
+import type { EditorContentListing } from "../ipc.js";
+import type { PlaytestFlush } from "./playtest-session.js";
+
+export interface FormPanelOptions {
+  root: string;
+  kind: ContentKind;
+  /** Path of the file being edited; null when creating a new def. */
+  path: string | null;
+  /** True when an existing file is open (id field read-only). */
+  editing: boolean;
+  /** The project's content listing snapshot (kind→files; assets). */
+  listing: EditorContentListing;
+  onStatus: (message: string, isError?: boolean) => void;
+  onSaved?: () => void;
+  onFlush?: (collect: () => PlaytestFlush[]) => void;
+}
+
+type Path = readonly (string | number)[];
+
+interface Ctx {
+  working: Record<string, unknown>;
+  schema: FormSchema;
+  options: FormPanelOptions;
+  listing: EditorContentListing;
+  markDirty: () => void;
+  refreshSave: () => void;
+  showErrors: (issues: string[]) => void;
+}
+
+/** Render a full form for `kind` into `container`. `def` is the parsed YAML
+ * (editing) or null (create). Returns a dispose function. */
+export function mountFormPanel(
+  container: HTMLElement,
+  def: unknown,
+  options: FormPanelOptions,
+): () => void {
+  const schema = deriveFormSchema(contentSchemas[options.kind]);
+  const working = options.editing ? applyValue(schema, def) : initialValue(schema);
+  let dirty = !options.editing;
+  let disposed = false;
+
+  const root = document.createElement("div");
+  root.className = "form-panel";
+  container.replaceChildren(root);
+
+  const header = document.createElement("div");
+  header.className = "form-header";
+  const title = document.createElement("h3");
+  title.textContent = options.editing ? `Edit ${options.kind}` : `New ${options.kind}`;
+  const pathEl = document.createElement("span");
+  pathEl.className = "form-path";
+  const displayPath = options.path ?? `content/${contentDirNames[options.kind]}/<id>.yaml`;
+  pathEl.textContent = displayPath;
+  pathEl.title = displayPath;
+  const save = document.createElement("button");
+  save.type = "button";
+  save.className = "primary";
+  save.addEventListener("click", () => void saveForm());
+  header.append(title, pathEl, save);
+  root.appendChild(header);
+
+  const errors = document.createElement("div");
+  errors.className = "form-errors";
+  root.appendChild(errors);
+
+  const body = document.createElement("div");
+  body.className = "form-body";
+  root.appendChild(body);
+
+  const refreshSaveButton = (): void => {
+    save.textContent = dirty ? "Save *" : "Save";
+    save.disabled = !dirty;
+  };
+
+  const ctx: Ctx = {
+    working,
+    schema,
+    options,
+    listing: options.listing,
+    markDirty: () => {
+      if (disposed) return;
+      dirty = true;
+      refreshSaveButton();
+    },
+    refreshSave: refreshSaveButton,
+    showErrors: (issues) => {
+      errors.replaceChildren();
+      if (issues.length === 0) return;
+      const list = document.createElement("ul");
+      for (const issue of issues) {
+        const li = document.createElement("li");
+        li.textContent = issue;
+        list.appendChild(li);
+      }
+      errors.appendChild(list);
+    },
+  };
+
+  const idField = schema.fields.find((f) => f.name === "id");
+  for (const field of schema.fields) {
+    if (field.name === "id") continue;
+    body.appendChild(buildField(field, [], ctx));
+  }
+  if (idField !== undefined) {
+    const idWrap = buildField(idField, [], ctx);
+    const input = idWrap.querySelector<HTMLInputElement>("input, select");
+    if (input !== null && options.editing) {
+      input.readOnly = true;
+      input.classList.add("readonly");
+    }
+    body.prepend(idWrap);
+  }
+
+  ctx.refreshSave();
+
+  if (options.onFlush !== undefined) {
+    options.onFlush(() => {
+      if (!dirty) return [];
+      const json = coerceForValidate(schema, working);
+      const result = contentSchemas[options.kind].safeParse(json);
+      if (!result.success) {
+        return [{ path: displayPath, error: "validation failed — not saved" }];
+      }
+      const defData = result.data as { id: string };
+      const id = defData.id;
+      const target = resolveTargetPath(id);
+      if (target === null) {
+        return [{ path: displayPath, error: "cannot create: single-file kind already occupied" }];
+      }
+      if (
+        !options.editing &&
+        options.listing.kinds.find((k) => k.kind === options.kind)?.files.some((f) => f.id === id) === true
+      ) {
+        return [{ path: displayPath, error: `duplicate id "${id}"` }];
+      }
+      return [{ path: target, content: stringify(result.data) }];
+    });
+  }
+
+  async function saveForm(): Promise<void> {
+    const json = coerceForValidate(schema, working);
+    const result = contentSchemas[options.kind].safeParse(json);
+    if (!result.success) {
+      const issues = result.error.issues.map(
+        (issue) => `${issue.path.length > 0 ? issue.path.join(".") : "(root)"}: ${issue.message}`,
+      );
+      ctx.showErrors(issues);
+      options.onStatus(`${displayPath}: validation failed`, true);
+      return;
+    }
+    const defData = result.data as { id: string };
+    const id = defData.id;
+    const target = resolveTargetPath(id);
+    if (target === null) {
+      ctx.showErrors([`cannot create a new ${options.kind}: the single-file form content/${options.kind}.yaml is already in use`]);
+      options.onStatus("cannot create: single-file kind already occupied", true);
+      return;
+    }
+    if (!options.editing && options.listing.kinds.find((k) => k.kind === options.kind)?.files.some((f) => f.id === id) === true) {
+      ctx.showErrors([`duplicate id "${id}": a ${options.kind} with this id already exists`]);
+      options.onStatus(`duplicate id "${id}"`, true);
+      return;
+    }
+    try {
+      await window.nengine.project.writeYaml({ root: options.root, path: target, content: stringify(result.data) });
+      const { listing } = await window.nengine.project.listContent(options.root);
+      ctx.listing = listing;
+      dirty = false;
+      ctx.refreshSave();
+      ctx.showErrors([]);
+      options.onStatus(`saved ${target}`);
+      options.onSaved?.();
+    } catch (err) {
+      options.onStatus(err instanceof Error ? err.message : String(err), true);
+    }
+  }
+
+  function resolveTargetPath(id: string): string | null {
+    if (options.editing && options.path !== null) return options.path;
+    const existing = options.listing.kinds.find((k) => k.kind === options.kind)?.files ?? [];
+    if (existing.length > 0 && existing.every((f) => f.path === `content/${options.kind}.yaml`)) {
+      return null;
+    }
+    return `content/${contentDirNames[options.kind]}/${id}.yaml`;
+  }
+
+  return () => {
+    disposed = true;
+  };
+}
+
+function buildField(field: FormFieldSchema, path: Path, ctx: Ctx): HTMLElement {
+  const wrap = document.createElement("div");
+  wrap.className = "form-field";
+  switch (field.type) {
+    case "string":
+      appendLabel(wrap, field);
+      wrap.appendChild(textControl(field, [...path, field.name], ctx));
+      break;
+    case "ref":
+      appendLabel(wrap, field);
+      wrap.appendChild(refControl(field, [...path, field.name], ctx));
+      break;
+    case "number":
+      appendLabel(wrap, field);
+      wrap.appendChild(numberControl(field, [...path, field.name], ctx));
+      break;
+    case "boolean":
+      wrap.appendChild(booleanControl(field, [...path, field.name], ctx));
+      break;
+    case "enum":
+      appendLabel(wrap, field);
+      wrap.appendChild(enumControl(field, [...path, field.name], ctx));
+      break;
+    case "any":
+      appendLabel(wrap, field);
+      wrap.appendChild(textControl(field, [...path, field.name], ctx));
+      break;
+    case "object":
+      wrap.appendChild(objectSection(field, path, ctx));
+      break;
+    case "array":
+      wrap.appendChild(arrayEditor(field, path, ctx));
+      break;
+    case "union":
+      wrap.appendChild(unionEditor(field, path, ctx));
+      break;
+    case "record":
+      wrap.appendChild(recordEditor(field, path, ctx));
+      break;
+  }
+  return wrap;
+}
+
+function appendLabel(container: HTMLElement, field: FormFieldSchema): void {
+  const label = document.createElement("label");
+  label.textContent = field.optional ? `${field.label} (optional)` : field.label;
+  container.appendChild(label);
+}
+
+function textControl(field: FormFieldSchema, valuePath: Path, ctx: Ctx): HTMLInputElement {
+  const input = document.createElement("input");
+  input.type = "text";
+  input.spellcheck = false;
+  input.value = typeof getPath(ctx.working, valuePath) === "string" ? String(getPath(ctx.working, valuePath)) : "";
+  if (field.pattern !== undefined) input.placeholder = `pattern: ${field.pattern}`;
+  input.addEventListener("input", () => {
+    setPath(ctx.working, valuePath, input.value);
+    ctx.markDirty();
+  });
+  return input;
+}
+
+function refControl(field: FormFieldSchema, valuePath: Path, ctx: Ctx): HTMLElement {
+  const refKind = field.refKind ?? "";
+  const ids = refIdList(ctx.listing, refKind);
+  const current = getPath(ctx.working, valuePath);
+  const currentValue = typeof current === "string" ? current : "";
+  if (ids.length === 0) {
+    const input = document.createElement("input");
+    input.type = "text";
+    input.spellcheck = false;
+    input.placeholder = `type a ${refKind} id…`;
+    input.value = currentValue;
+    input.addEventListener("input", () => {
+      setPath(ctx.working, valuePath, input.value);
+      ctx.markDirty();
+    });
+    return input;
+  }
+  const select = document.createElement("select");
+  const empty = document.createElement("option");
+  empty.value = "";
+  empty.textContent = field.optional ? "(none)" : "(pick an id)";
+  select.appendChild(empty);
+  let currentIncluded = currentValue === "";
+  for (const id of ids) {
+    if (id === currentValue) currentIncluded = true;
+    const option = document.createElement("option");
+    option.value = id;
+    option.textContent = id;
+    select.appendChild(option);
+  }
+  if (!currentIncluded && currentValue !== "") {
+    const option = document.createElement("option");
+    option.value = currentValue;
+    option.textContent = `${currentValue} (unlisted)`;
+    select.appendChild(option);
+  }
+  select.value = currentValue;
+  select.addEventListener("change", () => {
+    setPath(ctx.working, valuePath, select.value);
+    ctx.markDirty();
+  });
+  return select;
+}
+
+function numberControl(field: FormFieldSchema, valuePath: Path, ctx: Ctx): HTMLInputElement {
+  const input = document.createElement("input");
+  input.type = "number";
+  input.step = field.integer === true ? "1" : "any";
+  if (field.numberMin !== undefined) input.min = String(field.numberMin);
+  if (field.numberMax !== undefined) input.max = String(field.numberMax);
+  const current = getPath(ctx.working, valuePath);
+  input.value = current === undefined || current === "" ? "" : String(current);
+  input.addEventListener("input", () => {
+    const raw = input.value;
+    if (raw === "") {
+      setPath(ctx.working, valuePath, "");
+      ctx.markDirty();
+      return;
+    }
+    const n = Number(raw);
+    setPath(ctx.working, valuePath, Number.isFinite(n) ? n : raw);
+    ctx.markDirty();
+  });
+  return input;
+}
+
+function booleanControl(field: FormFieldSchema, valuePath: Path, ctx: Ctx): HTMLElement {
+  const wrap = document.createElement("label");
+  wrap.className = "form-boolean";
+  const checkbox = document.createElement("input");
+  checkbox.type = "checkbox";
+  checkbox.checked = Boolean(getPath(ctx.working, valuePath));
+  checkbox.addEventListener("change", () => {
+    setPath(ctx.working, valuePath, checkbox.checked);
+    ctx.markDirty();
+  });
+  const label = document.createElement("span");
+  label.textContent = field.optional ? `${field.label} (optional)` : field.label;
+  wrap.append(checkbox, label);
+  return wrap;
+}
+
+function enumControl(field: FormFieldSchema, valuePath: Path, ctx: Ctx): HTMLSelectElement {
+  const values = field.enumValues ?? [];
+  const current = getPath(ctx.working, valuePath);
+  const currentValue = typeof current === "string" ? current : "";
+  const select = document.createElement("select");
+  const included = currentValue === "" || values.includes(currentValue);
+  if (!included) {
+    const option = document.createElement("option");
+    option.value = currentValue;
+    option.textContent = `${currentValue} (unlisted)`;
+    select.appendChild(option);
+  }
+  for (const value of values) {
+    const option = document.createElement("option");
+    option.value = value;
+    option.textContent = value;
+    select.appendChild(option);
+  }
+  select.value = currentValue;
+  select.addEventListener("change", () => {
+    setPath(ctx.working, valuePath, select.value);
+    ctx.markDirty();
+  });
+  return select;
+}
+
+function objectSection(field: FormFieldSchema, path: Path, ctx: Ctx): HTMLElement {
+  const section = document.createElement("fieldset");
+  section.className = "form-section";
+  const legend = document.createElement("legend");
+  legend.textContent = field.optional ? `${field.label} (optional)` : field.label;
+  section.appendChild(legend);
+  const body = document.createElement("div");
+  body.className = "form-section-body";
+  section.appendChild(body);
+  const render = () => {
+    body.replaceChildren();
+    for (const nested of field.fields ?? []) {
+      body.appendChild(buildField(nested, [...path, field.name], ctx));
+    }
+  };
+  render();
+  return section;
+}
+
+function arrayEditor(field: FormFieldSchema, path: Path, ctx: Ctx): HTMLElement {
+  const wrap = document.createElement("div");
+  wrap.className = "form-array";
+  const header = document.createElement("div");
+  header.className = "form-array-header";
+  const label = document.createElement("span");
+  label.className = "form-array-label";
+  label.textContent = field.optional ? `${field.label} (optional)` : field.label;
+  const add = document.createElement("button");
+  add.type = "button";
+  add.textContent = "Add";
+  header.append(label, add);
+  wrap.appendChild(header);
+  const list = document.createElement("div");
+  list.className = "form-array-list";
+  wrap.appendChild(list);
+
+  const render = () => {
+    list.replaceChildren();
+    const current = getPath(ctx.working, path);
+    const items = Array.isArray(current) ? current : [];
+    items.forEach((_item, index) => {
+      const row = document.createElement("div");
+      row.className = "form-array-item";
+      const itemPath = [...path, index];
+      if (isSimpleElement(field.element)) {
+        const simple = field.element as FormFieldSchema;
+        row.appendChild(simpleControl(simple, itemPath, ctx));
+      } else if (field.element !== undefined) {
+        const body = document.createElement("div");
+        body.className = "form-array-item-body";
+        body.appendChild(buildField(field.element, itemPath, ctx));
+        row.appendChild(body);
+      }
+      const controls = document.createElement("div");
+      controls.className = "form-array-controls";
+      const up = document.createElement("button");
+      up.type = "button";
+      up.textContent = "↑";
+      up.title = "Move up";
+      up.disabled = index === 0;
+      up.addEventListener("click", () => moveItem(index, -1));
+      const down = document.createElement("button");
+      down.type = "button";
+      down.textContent = "↓";
+      down.title = "Move down";
+      down.disabled = index === items.length - 1;
+      down.addEventListener("click", () => moveItem(index, 1));
+      const remove = document.createElement("button");
+      remove.type = "button";
+      remove.textContent = "Remove";
+      remove.addEventListener("click", () => removeItem(index));
+      controls.append(up, down, remove);
+      row.appendChild(controls);
+      list.appendChild(row);
+    });
+  };
+
+  const applyItems = (next: unknown[]): void => {
+    setPath(ctx.working, path, next);
+    ctx.markDirty();
+    render();
+  };
+
+  const removeItem = (index: number): void => {
+    const items = Array.isArray(getPath(ctx.working, path)) ? (getPath(ctx.working, path) as unknown[]) : [];
+    applyItems(items.filter((_item, i) => i !== index));
+  };
+
+  const moveItem = (index: number, delta: number): void => {
+    const items = Array.isArray(getPath(ctx.working, path)) ? (getPath(ctx.working, path) as unknown[]) : [];
+    const target = index + delta;
+    if (target < 0 || target >= items.length) return;
+    const next = [...items];
+    const moving = next[index];
+    if (moving === undefined) return;
+    next[index] = next[target] as unknown;
+    next[target] = moving;
+    applyItems(next);
+  };
+
+  add.addEventListener("click", () => {
+    const items = Array.isArray(getPath(ctx.working, path)) ? (getPath(ctx.working, path) as unknown[]) : [];
+    applyItems([...items, field.element !== undefined ? initialItemValue(field.element) : null]);
+  });
+
+  render();
+  return wrap;
+}
+
+/** Primitive array elements get a single inline control per item. */
+function isSimpleElement(element: FormFieldSchema | undefined): boolean {
+  if (element === undefined) return false;
+  return (
+    element.type === "string" ||
+    element.type === "number" ||
+    element.type === "boolean" ||
+    element.type === "enum" ||
+    element.type === "ref" ||
+    element.type === "any"
+  );
+}
+
+function simpleControl(element: FormFieldSchema, valuePath: Path, ctx: Ctx): HTMLElement {
+  switch (element.type) {
+    case "boolean":
+      return booleanControl(element, valuePath, ctx);
+    case "number":
+      return numberControl(element, valuePath, ctx);
+    case "enum":
+      return enumControl(element, valuePath, ctx);
+    case "ref":
+      return refControl(element, valuePath, ctx);
+    default:
+      return textControl(element, valuePath, ctx);
+  }
+}
+
+function unionEditor(field: FormFieldSchema, path: Path, ctx: Ctx): HTMLElement {
+  const wrap = document.createElement("div");
+  wrap.className = "form-union";
+  const header = document.createElement("div");
+  header.className = "form-union-header";
+  const label = document.createElement("span");
+  label.className = "form-union-label";
+  label.textContent = field.optional ? `${field.label} (optional)` : field.label;
+  const select = document.createElement("select");
+  select.className = "form-branch-select";
+  header.append(label, select);
+  wrap.appendChild(header);
+  const body = document.createElement("div");
+  body.className = "form-union-body";
+  wrap.appendChild(body);
+
+  const branches = field.branches ?? [];
+
+  const populate = () => {
+    select.replaceChildren();
+    if (field.discriminator !== undefined) {
+      for (const branch of branches) {
+        for (const value of branch.discriminatorValues ?? [branch.id]) {
+          const option = document.createElement("option");
+          option.value = value;
+          option.textContent = branch.label;
+          select.appendChild(option);
+        }
+      }
+    } else {
+      for (const branch of branches) {
+        const option = document.createElement("option");
+        option.value = branch.id;
+        option.textContent = branch.label;
+        select.appendChild(option);
+      }
+    }
+  };
+
+  const renderBranch = () => {
+    body.replaceChildren();
+    const current = getPath(ctx.working, path);
+    const branch = detectUnionBranch(field, current);
+    if (branch === null) return;
+    if (branch.primitive !== undefined) {
+      const note = document.createElement("span");
+      note.className = "muted";
+      note.textContent = "Value selected by the branch above.";
+      body.appendChild(note);
+      return;
+    }
+    if (branch.nested !== undefined) {
+      body.appendChild(buildField(branch.nested, path, ctx));
+      return;
+    }
+    for (const nested of branch.fields ?? []) {
+      body.appendChild(buildField(nested, path, ctx));
+    }
+  };
+
+  const render = () => {
+    populate();
+    const current = getPath(ctx.working, path);
+    const branch = detectUnionBranch(field, current);
+    if (field.discriminator !== undefined) {
+      const discriminatorValue =
+        isPlainObject(current) && typeof current[field.discriminator] === "string"
+          ? (current[field.discriminator] as string)
+          : (branch?.discriminatorValues?.[0] ?? branch?.id ?? "");
+      select.value = discriminatorValue;
+    } else {
+      select.value = branch?.id ?? branches[0]?.id ?? "";
+    }
+    renderBranch();
+  };
+
+  select.addEventListener("change", () => {
+    const selected = select.value;
+    const nextBranch = branches.find((b) => (b.discriminatorValues ?? [b.id]).includes(selected)) ?? null;
+    if (nextBranch === null) return;
+    const current = getPath(ctx.working, path);
+    const currentBranch = detectUnionBranch(field, current);
+    if (field.discriminator !== undefined && currentBranch !== null && nextBranch.id === currentBranch.id) {
+      if (isPlainObject(current)) {
+        setPath(ctx.working, [...path, field.discriminator], selected);
+      }
+    } else {
+      setPath(ctx.working, path, unionBranchValue(field, nextBranch));
+    }
+    ctx.markDirty();
+    render();
+  });
+
+  render();
+  return wrap;
+}
+
+function recordEditor(field: FormFieldSchema, path: Path, ctx: Ctx): HTMLElement {
+  const wrap = document.createElement("div");
+  wrap.className = "form-record";
+  const header = document.createElement("div");
+  header.className = "form-record-header";
+  const label = document.createElement("span");
+  label.className = "form-record-label";
+  label.textContent = field.optional ? `${field.label} (optional)` : field.label;
+  const add = document.createElement("button");
+  add.type = "button";
+  add.textContent = "Add entry";
+  header.append(label, add);
+  wrap.appendChild(header);
+  const rows = document.createElement("div");
+  rows.className = "form-record-rows";
+  wrap.appendChild(rows);
+
+  const render = () => {
+    rows.replaceChildren();
+    const current = getPath(ctx.working, path);
+    if (!isPlainObject(current)) return;
+    for (const [key, value] of Object.entries(current)) {
+      const row = document.createElement("div");
+      row.className = "form-record-row";
+      const keyInput = document.createElement("input");
+      keyInput.type = "text";
+      keyInput.value = key;
+      keyInput.spellcheck = false;
+      keyInput.title = "record key";
+      keyInput.addEventListener("change", () => {
+        const record = getPath(ctx.working, path);
+        if (!isPlainObject(record)) return;
+        const next: Record<string, unknown> = {};
+        for (const [k, v] of Object.entries(record)) {
+          if (k === key) {
+            if (keyInput.value.trim() !== "") next[keyInput.value] = v;
+          } else {
+            next[k] = v;
+          }
+        }
+        setPath(ctx.working, path, next);
+        ctx.markDirty();
+        render();
+      });
+      const valueEl = buildRecordValue(field.recordValue, [...path, key], ctx);
+      const remove = document.createElement("button");
+      remove.type = "button";
+      remove.textContent = "Remove";
+      remove.addEventListener("click", () => {
+        const record = getPath(ctx.working, path);
+        if (!isPlainObject(record)) return;
+        const next: Record<string, unknown> = {};
+        for (const [k, v] of Object.entries(record)) {
+          if (k !== key) next[k] = v;
+        }
+        setPath(ctx.working, path, next);
+        ctx.markDirty();
+        render();
+      });
+      row.append(keyInput, valueEl, remove);
+      rows.appendChild(row);
+    }
+  };
+
+  add.addEventListener("click", () => {
+    const record = getPath(ctx.working, path);
+    const next = isPlainObject(record) ? { ...record } : {};
+    let index = 0;
+    while (`key-${index}` in next) index++;
+    next[`key-${index}`] = field.recordValue !== undefined ? initialItemValue(field.recordValue) : "";
+    setPath(ctx.working, path, next);
+    ctx.markDirty();
+    render();
+  });
+
+  render();
+  return wrap;
+}
+
+function buildRecordValue(valueField: FormFieldSchema | undefined, valuePath: Path, ctx: Ctx): HTMLElement {
+  if (valueField === undefined) {
+    return textControl({ name: "value", label: "Value", type: "any", optional: false }, valuePath, ctx);
+  }
+  if (valueField.type === "object") {
+    const section = document.createElement("fieldset");
+    section.className = "form-section form-record-value";
+    const body = document.createElement("div");
+    body.className = "form-section-body";
+    section.appendChild(body);
+    const render = () => {
+      body.replaceChildren();
+      for (const nested of valueField.fields ?? []) {
+        body.appendChild(buildField(nested, valuePath, ctx));
+      }
+    };
+    render();
+    return section;
+  }
+  return buildField(valueField, valuePath, ctx);
+}
+
+function refIdList(listing: EditorContentListing, refKind: string): string[] {
+  if (refKind === "asset") return listing.assets;
+  const kind = listing.kinds.find((k) => k.kind === refKind);
+  return kind !== undefined ? kind.files.map((f) => f.id) : [];
+}

+ 544 - 0
apps/editor/src/renderer/form-schema.ts

@@ -0,0 +1,544 @@
+import type { z } from "zod";
+import { isContentKind } from "@nengine/core-schema";
+
+/**
+ * Zod → form UI derivation (D40.7 / E3). A content kind's zod schema is
+ * translated into a declarative `FormSchema` that a generic DOM renderer can
+ * draw without knowing the kind. Schema change → forms follow automatically;
+ * a new content kind → its forms appear with no new code.
+ *
+ * Pure logic — node-testable, no DOM. Zod is introspected through its stable
+ * `_def` shape; an unsupported zod construct raises `FormSchemaError` instead
+ * of being silently mis-rendered.
+ */
+
+export type FormFieldType =
+  | "string"
+  | "number"
+  | "boolean"
+  | "enum"
+  | "ref"
+  | "object"
+  | "array"
+  | "union"
+  | "record"
+  | "any";
+
+export interface FormBranch {
+  /** Stable branch id. For discriminated unions this is the discriminator
+   * literal (unique per branch); otherwise a generated `branch-<n>`. */
+  id: string;
+  /** Human label shown in the branch selector. */
+  label: string;
+  /** Discriminator literal values that select this branch (only when the
+   * parent union has a discriminator; an enum discriminator contributes all
+   * its values). */
+  discriminatorValues?: string[];
+  /** Object-branch fields (the discriminator field itself excluded). */
+  fields?: FormFieldSchema[];
+  /** Primitive branch (union of literals/enums/primitive types) — the branch
+   * selector itself carries the value, no extra control needed. */
+  primitive?: { type: "string" | "number" | "boolean" | "enum"; enumValues?: string[] };
+  /** Nested union branch (preserved when a union option is itself a union). */
+  nested?: FormFieldSchema;
+}
+
+export interface FormFieldSchema {
+  /** Field name as it appears in the def (and the zod shape). */
+  name: string;
+  /** Humanized label (see `humanizeId`). */
+  label: string;
+  type: FormFieldType;
+  /** True when the zod schema allows the field to be absent (z.optional/.optional()). */
+  optional: boolean;
+  /** True when the zod schema allows an explicit null (z.nullable). */
+  nullable?: boolean;
+  /** enum: the allowed literal values. */
+  enumValues?: string[];
+  /** ref: the referenced id domain ("item" | "sprite" | … | "asset"). */
+  refKind?: string;
+  /** number: integer-only hint (validation stays authoritative). */
+  integer?: boolean;
+  /** number: optional min/max hints from zod `.min`/`.max`/`.positive()` checks. */
+  numberMin?: number;
+  numberMax?: number;
+  /** string: regex source hint (e.g. the #rrggbb hex pattern). */
+  pattern?: string;
+  /** array: the element schema. */
+  element?: FormFieldSchema;
+  /** object: nested fields. */
+  fields?: FormFieldSchema[];
+  /** union: the shared literal-keyed discriminator field name, when every
+   * object branch carries the same literal key (e.g. "action", "shape"). */
+  discriminator?: string;
+  /** union: the selectable branches. */
+  branches?: FormBranch[];
+  /** record: the value field schema (record keys are always strings). */
+  recordValue?: FormFieldSchema;
+}
+
+export interface FormSchema {
+  fields: FormFieldSchema[];
+}
+
+/** Raised for zod constructs that cannot be rendered as a form. Reported
+ * loudly rather than silently mis-rendered (D40.7). */
+export class FormSchemaError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "FormSchemaError";
+  }
+}
+
+/** Recursion cap for self-referential schemas (ConditionExpr, whose z.lazy
+ * builds fresh schema objects per getter call — identity memoization is
+ * impossible, so derivation work grows as ~3^depth). Real content nests a
+ * handful of levels; deeper recursion becomes a free-text field. */
+const MAX_DERIVE_DEPTH = 10;
+
+/** The subset of zod's `_def` this derivation reads. Zod's own `_def` is typed
+ * `any` on `ZodTypeAny`, so a single structural cast keeps the introspection
+ * typed without per-access `as any`. */
+interface ZodDef {
+  typeName: string;
+  innerType?: z.ZodTypeAny;
+  schema?: z.ZodTypeAny;
+  getter?: () => z.ZodTypeAny;
+  shape?: () => Record<string, z.ZodTypeAny>;
+  values?: readonly string[];
+  value?: unknown;
+  checks?: readonly { kind: string; value?: number; inclusive?: boolean; regex?: { source: string } }[];
+  type?: z.ZodTypeAny;
+  options?: readonly z.ZodTypeAny[];
+  discriminator?: string;
+  keyType?: z.ZodTypeAny;
+  valueType?: z.ZodTypeAny;
+}
+
+function defOf(schema: z.ZodTypeAny): ZodDef {
+  return schema._def as ZodDef;
+}
+
+/** Field names whose value references a content kind even though the name does
+ * not match the Id/Ref suffix rules (documented in docs/editor.md, E3). */
+const REF_KIND_BY_NAME: Record<string, string> = {
+  seedItem: "item",
+  sound: "sound",
+  shop: "shop",
+  dialogue: "dialogue",
+  src: "asset",
+  filters: "shader",
+};
+
+/**
+ * Cross-kind reference heuristic: a string field whose name ends in `Id`/`Ref`/
+ * `Src` references the content kind named by the base (`itemId` → item,
+ * `spriteRef` → sprite, `soundId` → sound), with an exact-name table for the
+ * irregular names (`seedItem`, `src` → asset, …). Names with no matching kind
+ * (`tilesetRef`, `npcId`) stay plain strings — this is a table, so new kinds
+ * and new naming patterns are added here, not in the DOM layer.
+ */
+export function refKindForField(fieldName: string): string | null {
+  const exact = REF_KIND_BY_NAME[fieldName];
+  if (exact !== undefined) return exact;
+  for (const suffix of ["Id", "Ref", "Src"]) {
+    if (!fieldName.endsWith(suffix)) continue;
+    const base = fieldName.slice(0, -suffix.length);
+    if (suffix === "Src") return "asset";
+    if (isContentKind(base)) return base;
+  }
+  return null;
+}
+
+/** "spriteRef" → "Sprite ref", "dayLengthTicks" → "Day length ticks". */
+export function humanizeId(id: string): string {
+  const words = id
+    .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+    .replace(/[_-]+/g, " ")
+    .trim()
+    .toLowerCase()
+    .split(/\s+/)
+    .filter((word) => word.length > 0);
+  if (words.length === 0) return id;
+  const head = words[0] ?? "";
+  words[0] = head.charAt(0).toUpperCase() + head.slice(1);
+  return words.join(" ");
+}
+
+export function deriveFormSchema(schema: z.ZodTypeAny): FormSchema {
+  return { fields: objectFields(schema, 0) };
+}
+
+function objectFields(schema: z.ZodTypeAny, depth: number): FormFieldSchema[] {
+  const core = unwrap(schema);
+  const shape = defOf(core).shape?.();
+  if (shape === undefined) {
+    throw new FormSchemaError(`expected a zod object schema, got ${defOf(core).typeName}`);
+  }
+  const fields: FormFieldSchema[] = [];
+  for (const [name, fieldSchema] of Object.entries(shape)) {
+    fields.push(deriveField(name, fieldSchema, depth + 1));
+  }
+  return fields;
+}
+
+/** The schema a wrapper (optional/nullable/effects/lazy/default) wraps, or
+ * null when the schema is not a wrapper. */
+function innerOf(schema: z.ZodTypeAny): z.ZodTypeAny | null {
+  const def = defOf(schema);
+  switch (def.typeName) {
+    case "ZodOptional":
+    case "ZodNullable":
+    case "ZodDefault":
+      return def.innerType ?? null;
+    case "ZodEffects":
+      return def.schema ?? null;
+    case "ZodLazy":
+      return def.getter?.() ?? null;
+    default:
+      return null;
+  }
+}
+
+function unwrap(schema: z.ZodTypeAny): z.ZodTypeAny {
+  let s: z.ZodTypeAny = schema;
+  for (;;) {
+    const inner = innerOf(s);
+    if (inner === null) return s;
+    s = inner;
+  }
+}
+
+function deriveField(name: string, schema: z.ZodTypeAny, depth: number): FormFieldSchema {
+  let optional = false;
+  let nullable = false;
+  let s: z.ZodTypeAny = schema;
+  for (;;) {
+    const def = defOf(s);
+    if (def.typeName === "ZodOptional") {
+      const inner = def.innerType;
+      if (inner === undefined) break;
+      optional = true;
+      s = inner;
+      continue;
+    }
+    if (def.typeName === "ZodNullable") {
+      const inner = def.innerType;
+      if (inner === undefined) break;
+      nullable = true;
+      s = inner;
+      continue;
+    }
+    if (def.typeName === "ZodEffects" || def.typeName === "ZodLazy" || def.typeName === "ZodDefault") {
+      const inner = innerOf(s);
+      if (inner === null) break;
+      if (def.typeName === "ZodDefault") optional = true;
+      s = inner;
+      continue;
+    }
+    break;
+  }
+  if (depth > MAX_DERIVE_DEPTH) {
+    return { name, label: humanizeId(name), type: "any", optional, nullable };
+  }
+  const label = humanizeId(name);
+  const def = defOf(s);
+  switch (def.typeName) {
+    case "ZodString":
+      return stringField(name, label, s, optional, nullable);
+    case "ZodNumber":
+      return numberField(name, label, s, optional, nullable);
+    case "ZodBoolean":
+      return { name, label, type: "boolean", optional, nullable };
+    case "ZodEnum":
+      return { name, label, type: "enum", optional, nullable, enumValues: [...(def.values ?? [])] };
+    case "ZodLiteral":
+      if (typeof def.value === "string") {
+        return { name, label, type: "enum", optional, nullable, enumValues: [def.value] };
+      }
+      return { name, label, type: "any", optional, nullable };
+    case "ZodObject":
+      return objectField(name, label, s, optional, nullable, depth);
+    case "ZodArray":
+      return arrayField(name, label, s, optional, nullable, depth);
+    case "ZodDiscriminatedUnion":
+      return unionField(name, label, def.discriminator, def.options ?? [], optional, nullable, depth);
+    case "ZodUnion":
+      return unionField(name, label, undefined, def.options ?? [], optional, nullable, depth);
+    case "ZodRecord":
+      return recordField(name, label, s, optional, nullable, depth);
+    case "ZodAny":
+    case "ZodUnknown":
+      return { name, label, type: "any", optional, nullable };
+    default:
+      throw new FormSchemaError(`unsupported zod construct ${def.typeName} for field "${name}"`);
+  }
+}
+
+function stringField(
+  name: string,
+  label: string,
+  schema: z.ZodTypeAny,
+  optional: boolean,
+  nullable: boolean,
+): FormFieldSchema {
+  const field: FormFieldSchema = { name, label, type: "string", optional, nullable };
+  const refKind = refKindForField(name);
+  if (refKind !== null) {
+    field.type = "ref";
+    field.refKind = refKind;
+  }
+  for (const check of defOf(schema).checks ?? []) {
+    if (check.kind === "regex" && check.regex !== undefined) {
+      field.pattern = check.regex.source;
+      break;
+    }
+  }
+  return field;
+}
+
+function numberField(
+  name: string,
+  label: string,
+  schema: z.ZodTypeAny,
+  optional: boolean,
+  nullable: boolean,
+): FormFieldSchema {
+  const field: FormFieldSchema = { name, label, type: "number", optional, nullable };
+  for (const check of defOf(schema).checks ?? []) {
+    if (check.kind === "int") {
+      field.integer = true;
+    } else if (check.kind === "min" && check.value !== undefined) {
+      field.numberMin = check.inclusive === true ? check.value : check.value + 1;
+    } else if (check.kind === "max" && check.value !== undefined) {
+      field.numberMax = check.inclusive === true ? check.value : check.value - 1;
+    }
+  }
+  return field;
+}
+
+function objectField(
+  name: string,
+  label: string,
+  schema: z.ZodTypeAny,
+  optional: boolean,
+  nullable: boolean,
+  depth: number,
+): FormFieldSchema {
+  const fields: FormFieldSchema[] = [];
+  for (const [fieldName, fieldSchema] of Object.entries(defOf(schema).shape?.() ?? {})) {
+    fields.push(deriveField(fieldName, fieldSchema, depth + 1));
+  }
+  return { name, label, type: "object", optional, nullable, fields };
+}
+
+function arrayField(
+  name: string,
+  label: string,
+  schema: z.ZodTypeAny,
+  optional: boolean,
+  nullable: boolean,
+  depth: number,
+): FormFieldSchema {
+  const elementSchema = defOf(schema).type;
+  if (elementSchema === undefined) {
+    throw new FormSchemaError(`array field "${name}" has no element schema`);
+  }
+  const element = deriveElement(name, label, elementSchema, depth + 1);
+  return { name, label, type: "array", optional, nullable, element };
+}
+
+/** Array element derivation. Primitive string elements inherit the array's
+ * field name for the reference heuristic (sprite `filters` → shader ids). */
+function deriveElement(arrayName: string, arrayLabel: string, elementSchema: z.ZodTypeAny, depth: number): FormFieldSchema {
+  const core = unwrap(elementSchema);
+  if (defOf(core).typeName === "ZodString") {
+    const refKind = refKindForField(arrayName);
+    if (refKind !== null) {
+      return { name: arrayName, label: arrayLabel, type: "ref", optional: false, refKind };
+    }
+    return { name: arrayName, label: arrayLabel, type: "string", optional: false };
+  }
+  return deriveField(arrayName, elementSchema, depth);
+}
+
+function recordField(
+  name: string,
+  label: string,
+  schema: z.ZodTypeAny,
+  optional: boolean,
+  nullable: boolean,
+  depth: number,
+): FormFieldSchema {
+  const valueSchema = defOf(schema).valueType;
+  if (valueSchema === undefined) {
+    throw new FormSchemaError(`record field "${name}" has no value schema`);
+  }
+  const value = deriveField(name, valueSchema, depth + 1);
+  return { name, label, type: "record", optional, nullable, recordValue: value };
+}
+
+function unionField(
+  name: string,
+  label: string,
+  discriminator: string | undefined,
+  options: readonly z.ZodTypeAny[],
+  optional: boolean,
+  nullable: boolean,
+  depth: number,
+): FormFieldSchema {
+  if (options.every(isPrimitiveUnionOption)) {
+    const enumValues: string[] = [];
+    let allLiterals = true;
+    for (const option of options) {
+      const core = unwrap(option);
+      const def = defOf(core);
+      if (def.typeName === "ZodLiteral" && typeof def.value === "string") enumValues.push(def.value);
+      else if (def.typeName === "ZodEnum") enumValues.push(...(def.values ?? []));
+      else allLiterals = false;
+    }
+    if (allLiterals) {
+      return { name, label, type: "enum", optional, nullable, enumValues };
+    }
+    return { name, label, type: "any", optional, nullable };
+  }
+  const flat = flattenUnionOptions(options);
+  let branchDiscriminator = discriminator;
+  if (branchDiscriminator === undefined) {
+    branchDiscriminator = detectImplicitDiscriminator(flat);
+  }
+  const branches: FormBranch[] = [];
+  for (const option of flat) {
+    branches.push(deriveBranch(name, label, option, branchDiscriminator, branches.length, depth));
+  }
+  const field: FormFieldSchema = { name, label, type: "union", optional, nullable, branches };
+  if (branchDiscriminator !== undefined) field.discriminator = branchDiscriminator;
+  return field;
+}
+
+/** A plain z.union gets a discriminator when every object branch shares a
+ * literal/enum-keyed field (e.g. behavior actions all carry `action`). */
+function detectImplicitDiscriminator(options: readonly z.ZodTypeAny[]): string | undefined {
+  const objects = options.filter((option) => defOf(unwrap(option)).typeName === "ZodObject");
+  if (objects.length !== options.length) return undefined;
+  const first = objects[0] ?? options[0];
+  if (first === undefined) return undefined;
+  const firstShape = defOf(unwrap(first)).shape?.() ?? {};
+  for (const candidate of Object.keys(firstShape)) {
+    const allLiteral = objects.every((option) => {
+      const fieldSchema = defOf(unwrap(option)).shape?.()?.[candidate];
+      if (fieldSchema === undefined) return false;
+      const typeName = defOf(unwrap(fieldSchema)).typeName;
+      return typeName === "ZodLiteral" || typeName === "ZodEnum";
+    });
+    if (allLiteral) return candidate;
+  }
+  return undefined;
+}
+
+function isPrimitiveUnionOption(schema: z.ZodTypeAny): boolean {
+  const typeName = defOf(unwrap(schema)).typeName;
+  return (
+    typeName === "ZodString" ||
+    typeName === "ZodNumber" ||
+    typeName === "ZodBoolean" ||
+    typeName === "ZodLiteral" ||
+    typeName === "ZodEnum" ||
+    typeName === "ZodAny" ||
+    typeName === "ZodUnknown"
+  );
+}
+
+/** Expand nested unions into the parent's option list (ConditionExpr's inner
+ * op-discriminated-union flattens into the outer and/or/not union). */
+function flattenUnionOptions(options: readonly z.ZodTypeAny[]): z.ZodTypeAny[] {
+  const out: z.ZodTypeAny[] = [];
+  for (const option of options) {
+    const core = unwrap(option);
+    const def = defOf(core);
+    if (def.typeName === "ZodUnion" || def.typeName === "ZodDiscriminatedUnion") {
+      out.push(...flattenUnionOptions(def.options ?? []));
+    } else {
+      out.push(option);
+    }
+  }
+  return out;
+}
+
+function deriveBranch(
+  unionName: string,
+  unionLabel: string,
+  option: z.ZodTypeAny,
+  unionDiscriminator: string | undefined,
+  index: number,
+  depth: number,
+): FormBranch {
+  const core = unwrap(option);
+  const def = defOf(core);
+  switch (def.typeName) {
+    case "ZodLiteral": {
+      const value = typeof def.value === "string" ? def.value : String(def.value);
+      return { id: value, label: humanizeId(value), primitive: { type: "enum", enumValues: [value] } };
+    }
+    case "ZodEnum": {
+      const values = [...(def.values ?? [])];
+      return {
+        id: values[0] ?? `branch-${index}`,
+        label: values.join(" / "),
+        primitive: { type: "enum", enumValues: values },
+      };
+    }
+    case "ZodString":
+      return { id: `branch-${index}`, label: "Text", primitive: { type: "string" } };
+    case "ZodNumber":
+      return { id: `branch-${index}`, label: "Number", primitive: { type: "number" } };
+    case "ZodBoolean":
+      return { id: `branch-${index}`, label: "Boolean", primitive: { type: "boolean" } };
+    case "ZodAny":
+    case "ZodUnknown":
+      return { id: `branch-${index}`, label: "Value", primitive: { type: "string" } };
+    case "ZodObject": {
+      const fields: FormFieldSchema[] = [];
+      const discriminatorValues: string[] = [];
+      for (const [fieldName, fieldSchema] of Object.entries(def.shape?.() ?? {})) {
+        if (fieldName === unionDiscriminator) {
+          const discriminatorDef = defOf(unwrap(fieldSchema));
+          if (discriminatorDef.typeName === "ZodLiteral" && typeof discriminatorDef.value === "string") {
+            discriminatorValues.push(discriminatorDef.value);
+          } else if (discriminatorDef.typeName === "ZodEnum") {
+            discriminatorValues.push(...(discriminatorDef.values ?? []));
+          }
+          continue;
+        }
+        fields.push(deriveField(fieldName, fieldSchema, depth + 1));
+      }
+      const branch: FormBranch = {
+        id: discriminatorValues[0] ?? `branch-${index}`,
+        label: branchLabel(fields, discriminatorValues),
+        fields,
+      };
+      if (discriminatorValues.length > 0) branch.discriminatorValues = discriminatorValues;
+      return branch;
+    }
+    case "ZodUnion":
+    case "ZodDiscriminatedUnion": {
+      const nested = unionField(unionName, unionLabel, undefined, def.options ?? [], false, false, depth);
+      return { id: `branch-${index}`, label: "Value", nested };
+    }
+    default:
+      throw new FormSchemaError(`unsupported union branch ${def.typeName}`);
+  }
+}
+
+function branchLabel(fields: FormFieldSchema[], discriminatorValues: string[]): string {
+  if (discriminatorValues.length > 0) return humanizeId(discriminatorValues.join(" / "));
+  const first = fields[0];
+  if (first !== undefined && first.type === "enum" && first.enumValues !== undefined && first.enumValues.length > 0) {
+    return first.enumValues.join(" / ");
+  }
+  if (fields.length === 1) {
+    const only = fields[0];
+    return only !== undefined ? humanizeId(only.name) : "Custom";
+  }
+  return "Custom";
+}

+ 378 - 0
apps/editor/src/renderer/form-value.ts

@@ -0,0 +1,378 @@
+import type { FormBranch, FormFieldSchema, FormSchema } from "./form-schema.js";
+
+/**
+ * Form value model (E3). The working value of a form is a plain JSON object.
+ * Load: `applyValue` coerces a parsed def into the working shape while keeping
+ * unknown fields (lossless — saving must not drop data). Create: `initialValue`
+ * builds a blank def. Save: `coerceForValidate` produces the JSON handed to
+ * `contentSchemas[kind].safeParse` — optional fields emptied by the user are
+ * dropped, `any`-typed strings are parsed back to numbers/booleans.
+ *
+ * Roundtrip guarantee: for a valid def, `coerceForValidate(applyValue(def))`
+ * is JSON-identical to `def` when nothing was edited.
+ */
+
+const DROP: unique symbol = Symbol("drop");
+
+/** A missing form field is omitted from the saved def (documented E3 rule:
+ * optional fields emptied by the form stay absent). */
+type CoerceResult = unknown | typeof DROP;
+
+export function initialValue(schema: FormSchema): Record<string, unknown> {
+  const out: Record<string, unknown> = {};
+  for (const field of schema.fields) {
+    if (!field.optional) out[field.name] = initialFieldValue(field);
+  }
+  return out;
+}
+
+export function initialItemValue(element: FormFieldSchema): unknown {
+  return initialFieldValue(element);
+}
+
+function initialFieldValue(field: FormFieldSchema): unknown {
+  switch (field.type) {
+    case "string":
+    case "ref":
+    case "any":
+      return "";
+    case "enum": {
+      const values = field.enumValues ?? [];
+      return values[0] ?? "";
+    }
+    case "number":
+      return 0;
+    case "boolean":
+      return false;
+    case "array":
+      return [];
+    case "object": {
+      const out: Record<string, unknown> = {};
+      for (const f of field.fields ?? []) {
+        if (!f.optional) out[f.name] = initialFieldValue(f);
+      }
+      return out;
+    }
+    case "union": {
+      const first = field.branches?.[0];
+      return first !== undefined ? branchInitial(field, first) : {};
+    }
+    case "record":
+      return {};
+  }
+}
+
+/** Initial JSON for a union branch (used when the branch selector changes). */
+export function branchInitial(field: FormFieldSchema, branch: FormBranch): Record<string, unknown> {
+  const out: Record<string, unknown> = {};
+  if (field.discriminator !== undefined) {
+    out[field.discriminator] = branch.discriminatorValues?.[0] ?? branch.id;
+  }
+  for (const f of branch.fields ?? []) {
+    if (!f.optional) out[f.name] = initialFieldValue(f);
+  }
+  return out;
+}
+
+/** The standalone value for a branch: a literal string for primitive branches,
+ * the branch object for object branches. */
+export function unionBranchValue(field: FormFieldSchema, branch: FormBranch): unknown {
+  if (branch.primitive !== undefined) {
+    if (branch.primitive.type === "enum") return branch.primitive.enumValues?.[0] ?? "";
+    if (branch.primitive.type === "number") return 0;
+    if (branch.primitive.type === "boolean") return false;
+    return "";
+  }
+  if (branch.nested !== undefined) return initialFieldValue(branch.nested);
+  return branchInitial(field, branch);
+}
+
+/** Which union branch a value currently selects (first matching branch; for
+ * discriminated unions the discriminator field's value wins). */
+export function detectUnionBranch(field: FormFieldSchema, value: unknown): FormBranch | null {
+  const branches = field.branches ?? [];
+  if (branches.length === 0) return null;
+  if (field.discriminator !== undefined) {
+    if (isPlainObject(value)) {
+      const discriminator = value[field.discriminator];
+      if (typeof discriminator === "string") {
+        for (const branch of branches) {
+          if ((branch.discriminatorValues ?? [branch.id]).includes(discriminator)) return branch;
+        }
+      }
+    }
+    return branches[0] ?? null;
+  }
+  if (!isPlainObject(value)) {
+    for (const branch of branches) {
+      if (branch.primitive !== undefined && primitiveMatches(branch.primitive, value)) return branch;
+    }
+    return branches[0] ?? null;
+  }
+  for (const branch of branches) {
+    if (branchMatches(branch, value)) return branch;
+  }
+  return branches[0] ?? null;
+}
+
+export function applyValue(schema: FormSchema, json: unknown): Record<string, unknown> {
+  if (!isPlainObject(json)) return initialValue(schema);
+  return applyObject(schema.fields, json);
+}
+
+function applyObject(fields: FormFieldSchema[], json: Record<string, unknown>): Record<string, unknown> {
+  const out: Record<string, unknown> = {};
+  const byName = new Map<string, FormFieldSchema>();
+  for (const f of fields) byName.set(f.name, f);
+  for (const [key, value] of Object.entries(json)) {
+    const field = byName.get(key);
+    if (field === undefined) {
+      out[key] = value;
+    } else {
+      out[key] = applyField(field, value);
+    }
+  }
+  for (const field of fields) {
+    if (!field.optional && !(field.name in out)) out[field.name] = initialFieldValue(field);
+  }
+  return out;
+}
+
+function applyField(field: FormFieldSchema, value: unknown): unknown {
+  switch (field.type) {
+    case "string":
+    case "ref":
+      if (value === null && field.nullable === true) return null;
+      return coerceString(value);
+    case "enum":
+      return value;
+    case "number":
+      return coerceNumber(value);
+    case "boolean":
+      return coerceBoolean(value);
+    case "any":
+      return value;
+    case "array": {
+      if (!Array.isArray(value)) return [];
+      const element = field.element;
+      if (element === undefined) return value;
+      return value.map((item) => applyField(element, item));
+    }
+    case "object": {
+      if (!isPlainObject(value)) return initialFieldValue(field);
+      return applyObject(field.fields ?? [], value);
+    }
+    case "union": {
+      const branch = detectUnionBranch(field, value);
+      if (branch === null) {
+        const first = field.branches?.[0];
+        return first !== undefined ? unionBranchValue(field, first) : value;
+      }
+      if (branch.primitive !== undefined) return value;
+      if (branch.nested !== undefined) return applyField(branch.nested, value);
+      if (!isPlainObject(value)) return branchInitial(field, branch);
+      return applyObject(branch.fields ?? [], value);
+    }
+    case "record": {
+      if (!isPlainObject(value)) return {};
+      const valueField = field.recordValue;
+      if (valueField === undefined) return value;
+      const out: Record<string, unknown> = {};
+      for (const [key, entry] of Object.entries(value)) out[key] = applyField(valueField, entry);
+      return out;
+    }
+  }
+}
+
+export function coerceForValidate(schema: FormSchema, value: Record<string, unknown>): Record<string, unknown> {
+  return coerceObject(schema.fields, value);
+}
+
+function coerceObject(fields: FormFieldSchema[], value: Record<string, unknown>): Record<string, unknown> {
+  const out: Record<string, unknown> = {};
+  const byName = new Map<string, FormFieldSchema>();
+  for (const f of fields) byName.set(f.name, f);
+  for (const [key, raw] of Object.entries(value)) {
+    const field = byName.get(key);
+    if (field === undefined) {
+      out[key] = raw;
+      continue;
+    }
+    const coerced = coerceField(field, raw);
+    if (coerced !== DROP) out[key] = coerced;
+  }
+  return out;
+}
+
+function coerceField(field: FormFieldSchema, raw: unknown): CoerceResult {
+  if (raw === undefined) return field.optional ? DROP : undefined;
+  switch (field.type) {
+    case "string":
+    case "ref":
+      if (raw === "") return field.optional ? DROP : raw;
+      if (raw === null) return field.nullable === true ? null : field.optional ? DROP : "";
+      if (typeof raw === "string") return raw;
+      return String(raw);
+    case "enum":
+      if (raw === "") return field.optional ? DROP : raw;
+      return typeof raw === "string" ? raw : (field.enumValues?.[0] ?? "");
+    case "number": {
+      if (raw === null) return field.optional ? DROP : raw;
+      if (typeof raw === "number") return Number.isFinite(raw) ? raw : field.optional ? DROP : raw;
+      if (typeof raw === "string") {
+        const trimmed = raw.trim();
+        if (trimmed === "") return field.optional ? DROP : raw;
+        const n = Number(trimmed);
+        return Number.isFinite(n) ? n : raw;
+      }
+      if (typeof raw === "boolean") return raw ? 1 : 0;
+      return raw;
+    }
+    case "boolean":
+      if (raw === "true") return true;
+      if (raw === "false") return false;
+      return Boolean(raw);
+    case "any":
+      return coerceAny(raw);
+    case "array": {
+      if (!Array.isArray(raw)) return [];
+      const element = field.element;
+      if (element === undefined) return raw;
+      const out: unknown[] = [];
+      for (const item of raw) {
+        const coerced = coerceField(element, item);
+        if (coerced !== DROP) out.push(coerced);
+      }
+      return out;
+    }
+    case "object":
+      return isPlainObject(raw) ? coerceObject(field.fields ?? [], raw) : {};
+    case "union": {
+      const branch = detectUnionBranch(field, raw);
+      if (branch === null) return raw;
+      if (branch.primitive !== undefined) {
+        if (typeof raw === "string") return raw;
+        return unionBranchValue(field, branch);
+      }
+      if (branch.nested !== undefined) return coerceField(branch.nested, raw);
+      if (!isPlainObject(raw)) return branchInitial(field, branch);
+      return coerceObject(branch.fields ?? [], raw);
+    }
+    case "record": {
+      if (!isPlainObject(raw)) return {};
+      const valueField = field.recordValue;
+      if (valueField === undefined) return raw;
+      const out: Record<string, unknown> = {};
+      for (const [key, entry] of Object.entries(raw)) {
+        const coerced = coerceField(valueField, entry);
+        if (coerced !== DROP) out[key] = coerced;
+      }
+      return out;
+    }
+  }
+}
+
+function coerceAny(value: unknown): unknown {
+  if (typeof value !== "string") return value;
+  const trimmed = value.trim();
+  if (trimmed === "") return value;
+  if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
+    const n = Number(trimmed);
+    if (Number.isFinite(n)) return n;
+  }
+  if (trimmed === "true") return true;
+  if (trimmed === "false") return false;
+  return value;
+}
+
+/** Read a value at a path (arrays index by number). */
+export function getPath(value: Record<string, unknown>, path: readonly (string | number)[]): unknown {
+  let current: unknown = value;
+  for (const key of path) {
+    if (typeof current !== "object" || current === null) return undefined;
+    const container = current as Record<string | number, unknown>;
+    const next = container[key];
+    if (next === undefined) return undefined;
+    current = next;
+  }
+  return current;
+}
+
+/** Mutate the working value at a path, creating intermediate objects/arrays. */
+export function setPath(value: Record<string, unknown>, path: readonly (string | number)[], next: unknown): void {
+  if (path.length === 0) return;
+  let current: Record<string | number, unknown> = value;
+  for (let i = 0; i < path.length - 1; i++) {
+    const key = path[i];
+    if (key === undefined) return;
+    const existing = current[key];
+    if (typeof existing === "object" && existing !== null) {
+      current = existing as Record<string | number, unknown>;
+    } else {
+      const fresh = containerFor(path[i + 1]);
+      current[key] = fresh;
+      current = fresh;
+    }
+  }
+  const last = path[path.length - 1];
+  if (last !== undefined) current[last] = next;
+}
+
+/** A path segment that indexes into an array needs an array container; an
+ * object field name needs an object. */
+function containerFor(nextKey: string | number | undefined): Record<string | number, unknown> {
+  return (typeof nextKey === "number" ? [] : {}) as unknown as Record<string | number, unknown>;
+}
+
+function branchMatches(branch: FormBranch, value: Record<string, unknown>): boolean {
+  if (branch.nested !== undefined) return detectUnionBranch(branch.nested, value) !== null;
+  const fields = branch.fields ?? [];
+  if (fields.length === 0) return Object.keys(value).length === 0;
+  for (const f of fields) {
+    if (f.optional) continue;
+    const entry = value[f.name];
+    if (entry === undefined) return false;
+    if (f.type === "enum") {
+      const values = f.enumValues ?? [];
+      if (typeof entry !== "string" || !values.includes(entry)) return false;
+    }
+  }
+  return true;
+}
+
+function primitiveMatches(primitive: NonNullable<FormBranch["primitive"]>, value: unknown): boolean {
+  if (primitive.type === "enum") {
+    const values = primitive.enumValues ?? [];
+    return typeof value === "string" && values.includes(value);
+  }
+  if (primitive.type === "number") return typeof value === "number";
+  if (primitive.type === "boolean") return typeof value === "boolean";
+  return typeof value === "string";
+}
+
+function coerceString(value: unknown): string {
+  if (typeof value === "string") return value;
+  if (value === null || value === undefined) return "";
+  return String(value);
+}
+
+function coerceNumber(value: unknown): number {
+  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
+  if (typeof value === "string") {
+    const n = Number(value.trim());
+    return Number.isFinite(n) ? n : 0;
+  }
+  if (typeof value === "boolean") return value ? 1 : 0;
+  return 0;
+}
+
+function coerceBoolean(value: unknown): boolean {
+  if (typeof value === "boolean") return value;
+  if (value === "true") return true;
+  if (value === "false") return false;
+  return Boolean(value);
+}
+
+export function isPlainObject(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}

+ 446 - 0
apps/editor/src/renderer/main.ts

@@ -0,0 +1,446 @@
+import { parse } from "yaml";
+import { buildingSchema } from "@nengine/core-schema";
+import type { ContentKind } from "@nengine/core-schema";
+import type { EditorContentFile, EditorContentListing } from "../ipc.js";
+import type { EditorProjectInfo } from "../ipc.js";
+import { createMapModel } from "./map-model.js";
+import { mountMapEditor } from "./map-editor.js";
+import { mountFormPanel } from "./form-panel.js";
+import { DEFAULT_SCENE_ID, createSceneModel } from "./scene-model.js";
+import type { SceneFootprint } from "./scene-model.js";
+import { mountSceneEditor } from "./scene-editor.js";
+import type { SceneBuildingPaletteEntry } from "./scene-editor.js";
+import type { PlaytestFlush } from "./playtest-session.js";
+import { startPlaytest, stopPlaytest } from "./playtest.js";
+import { mountPackDialog, openPackDialog } from "./pack-dialog.js";
+
+function element(id: string): HTMLElement {
+  const el = document.getElementById(id);
+  if (el === null) throw new Error(`missing element #${id}`);
+  return el;
+}
+
+const home = element("home");
+const editor = element("editor");
+const recentList = element("recent-list");
+const homeError = element("home-error");
+const projectName = element("project-name");
+const projectPath = element("project-path");
+const mcpUrl = element("mcp-url");
+const btnMcpCopy = element("btn-mcp-copy") as HTMLButtonElement;
+const contentList = element("content-list");
+const editorPanel = element("editor-panel");
+const status = element("status");
+
+let currentProject: EditorProjectInfo | null = null;
+let currentListing: EditorContentListing | null = null;
+let disposeEditor: (() => void) | null = null;
+let flushCollector: (() => PlaytestFlush[]) | null = null;
+let playtestActive = false;
+
+function registerFlush(collect: () => PlaytestFlush[]): void {
+  flushCollector = collect;
+}
+
+function showScreen(screen: "home" | "editor" | "playtest"): void {
+  home.classList.toggle("hidden", screen !== "home");
+  editor.classList.toggle("hidden", screen !== "editor");
+  const playtest = element("playtest");
+  playtest.classList.toggle("hidden", screen !== "playtest");
+}
+
+function showError(message: unknown): void {
+  homeError.textContent = message instanceof Error ? message.message : String(message);
+}
+
+function setStatus(message: string, isError = false): void {
+  status.textContent = message;
+  status.classList.toggle("status-error", isError);
+}
+
+function enterProject(project: EditorProjectInfo): void {
+  currentProject = project;
+  projectName.textContent = project.name;
+  projectPath.textContent = project.root;
+  projectPath.title = project.root;
+  showScreen("editor");
+  void refreshMcpStatus();
+  void renderContent();
+}
+
+async function refreshMcpStatus(): Promise<void> {
+  const { running, url } = await window.nengine.mcp.status();
+  if (running && url !== "") {
+    mcpUrl.textContent = url;
+    mcpUrl.classList.add("running");
+    btnMcpCopy.disabled = false;
+  } else {
+    mcpUrl.textContent = "MCP: no project open";
+    mcpUrl.classList.remove("running");
+    btnMcpCopy.disabled = true;
+  }
+}
+
+async function copyMcpUrl(): Promise<void> {
+  if (btnMcpCopy.disabled) return;
+  const target = mcpUrl.textContent ?? "";
+  try {
+    await navigator.clipboard.writeText(target);
+  } catch {
+    const ta = document.createElement("textarea");
+    ta.value = target;
+    ta.style.position = "fixed";
+    ta.style.opacity = "0";
+    document.body.appendChild(ta);
+    ta.select();
+    document.execCommand("copy");
+    ta.remove();
+  }
+  setStatus("MCP URL copied — paste into your AI tool");
+}
+
+function goHome(): void {
+  currentProject = null;
+  currentListing = null;
+  disposeCurrentEditor();
+  homeError.textContent = "";
+  showScreen("home");
+  void window.nengine.project.close();
+  void refreshMcpStatus();
+  void renderRecent();
+}
+
+async function renderRecent(): Promise<void> {
+  recentList.replaceChildren();
+  const { entries } = await window.nengine.project.listRecent();
+  if (entries.length === 0) {
+    const li = document.createElement("li");
+    li.className = "muted";
+    li.textContent = "No recent projects";
+    recentList.appendChild(li);
+    return;
+  }
+  for (const entry of entries) {
+    const li = document.createElement("li");
+    const button = document.createElement("button");
+    button.type = "button";
+    button.addEventListener("click", () => void openPath(entry.root));
+    const name = document.createElement("span");
+    name.textContent = entry.name;
+    const path = document.createElement("span");
+    path.className = "recent-path";
+    path.textContent = entry.root;
+    button.append(name, path);
+    li.appendChild(button);
+    recentList.appendChild(li);
+  }
+}
+
+async function openPath(dir: string): Promise<void> {
+  try {
+    const { project } = await window.nengine.project.open({ dir });
+    if (project !== null) enterProject(project);
+  } catch (err) {
+    showError(err);
+  }
+}
+
+async function openViaDialog(): Promise<void> {
+  try {
+    const { project } = await window.nengine.project.open();
+    if (project !== null) enterProject(project);
+  } catch (err) {
+    showError(err);
+  }
+}
+
+async function newViaDialog(): Promise<void> {
+  try {
+    const { project } = await window.nengine.project.new();
+    if (project !== null) enterProject(project);
+  } catch (err) {
+    showError(err);
+  }
+}
+
+async function renderContent(): Promise<void> {
+  if (currentProject === null) return;
+  contentList.replaceChildren();
+  const { listing } = await window.nengine.project.listContent(currentProject.root);
+  currentListing = listing;
+  for (const kind of listing.kinds) {
+    const li = document.createElement("li");
+    const name = document.createElement("span");
+    name.className = "kind-name";
+    name.textContent = kind.kind;
+    name.title = kind.files.map((file) => file.path).join("\n") || "no files";
+    const count = document.createElement("span");
+    count.className = "kind-count";
+    count.textContent = String(kind.count);
+    const create = document.createElement("button");
+    create.type = "button";
+    create.className = "kind-new";
+    create.textContent = "+";
+    create.title = `New ${kind.kind}`;
+    create.addEventListener("click", (event) => {
+      event.stopPropagation();
+      if (kind.kind === "scene") void openScene(null);
+      else openNewForm(kind.kind);
+    });
+    li.append(name, count, create);
+    const files = document.createElement("ul");
+    files.className = "kind-files";
+    for (const file of kind.files) {
+      const fileLi = document.createElement("li");
+      const fileButton = document.createElement("button");
+      fileButton.type = "button";
+      fileButton.className = "kind-file";
+      fileButton.textContent = file.id;
+      fileButton.title = file.path;
+      fileButton.addEventListener("click", () => {
+        if (kind.kind === "map") void openMap(file);
+        else if (kind.kind === "scene") void openScene(file);
+        else void openKindForm(kind.kind, file);
+      });
+      fileLi.appendChild(fileButton);
+      files.appendChild(fileLi);
+    }
+    li.appendChild(files);
+    contentList.appendChild(li);
+  }
+  const { stats } = await window.nengine.project.stats(currentProject.root);
+  setStatus(`${stats.total} content files · ${stats.mods} mods · ${stats.assets} assets`);
+}
+
+function disposeCurrentEditor(): void {
+  flushCollector = null;
+  if (disposeEditor !== null) {
+    disposeEditor();
+    disposeEditor = null;
+  }
+}
+
+function showPlaceholder(kind: string): void {
+  disposeCurrentEditor();
+  editorPanel.replaceChildren();
+  const heading = document.createElement("h3");
+  heading.textContent = "Editor";
+  const placeholder = document.createElement("p");
+  placeholder.className = "placeholder";
+  placeholder.textContent =
+    kind === "map" ? "No valid map to edit." : `Cannot open this ${kind} file.`;
+  editorPanel.append(heading, placeholder);
+}
+
+async function openKindForm(kind: ContentKind, file: EditorContentFile): Promise<void> {
+  if (currentProject === null || currentListing === null) return;
+  disposeCurrentEditor();
+  try {
+    const { content } = await window.nengine.project.readYaml(currentProject.root, file.path);
+    const parsed = parse(content);
+    disposeEditor = mountFormPanel(editorPanel, parsed, {
+      root: currentProject.root,
+      kind,
+      path: file.path,
+      editing: true,
+      listing: currentListing,
+      onStatus: setStatus,
+      onSaved: () => void renderContent(),
+      onFlush: registerFlush,
+    });
+    setStatus(`editing ${file.path}`);
+  } catch (err) {
+    showPlaceholder(kind);
+    setStatus(err instanceof Error ? err.message : String(err), true);
+  }
+}
+
+function openNewForm(kind: ContentKind): void {
+  if (currentProject === null || currentListing === null) return;
+  disposeCurrentEditor();
+  disposeEditor = mountFormPanel(editorPanel, null, {
+    root: currentProject.root,
+    kind,
+    path: null,
+    editing: false,
+    listing: currentListing,
+    onStatus: setStatus,
+    onSaved: () => void renderContent(),
+    onFlush: registerFlush,
+  });
+  setStatus(`creating a new ${kind}`);
+}
+
+async function openMap(file: EditorContentFile): Promise<void> {
+  if (currentProject === null) return;
+  disposeCurrentEditor();
+  try {
+    const { content } = await window.nengine.project.readYaml(currentProject.root, file.path);
+    const model = createMapModel(parse(content));
+    if (model === null) {
+      setStatus(`${file.path}: not a valid map`, true);
+      showPlaceholder("map");
+      return;
+    }
+    disposeEditor = mountMapEditor(editorPanel, model, {
+      root: currentProject.root,
+      path: file.path,
+      onStatus: setStatus,
+      onFlush: registerFlush,
+    });
+    setStatus(`editing ${file.path}`);
+  } catch (err) {
+    showPlaceholder("map");
+    setStatus(err instanceof Error ? err.message : String(err), true);
+  }
+}
+
+async function openScene(file: EditorContentFile | null): Promise<void> {
+  if (currentProject === null || currentListing === null) return;
+  disposeCurrentEditor();
+  try {
+    const mapFile = currentListing.kinds.find((kind) => kind.kind === "map")?.files[0];
+    if (mapFile === undefined) {
+      setStatus("no map file found — cannot open the scene editor", true);
+      showPlaceholder("scene");
+      return;
+    }
+    const { content: mapContent } = await window.nengine.project.readYaml(currentProject.root, mapFile.path);
+    const mapModel = createMapModel(parse(mapContent));
+    if (mapModel === null) {
+      setStatus(`${mapFile.path}: not a valid map — cannot open the scene editor`, true);
+      showPlaceholder("scene");
+      return;
+    }
+
+    const buildings = await loadBuildingPalette(currentProject.root);
+    const footprints: Record<string, SceneFootprint> = {};
+    for (const entry of buildings) footprints[entry.id] = entry.footprint;
+    const animals = currentListing.kinds.find((kind) => kind.kind === "animal")?.files.map((f) => f.id) ?? [];
+
+    let sceneInput: unknown = undefined;
+    let sceneId: string | null = null;
+    let scenePath = "";
+    if (file !== null) {
+      const { content } = await window.nengine.project.readYaml(currentProject.root, file.path);
+      sceneInput = parse(content);
+      scenePath = file.path;
+    } else {
+      sceneId = freeSceneId();
+      scenePath = `content/scenes/${sceneId}.yaml`;
+    }
+    const sceneModel = createSceneModel(sceneInput, {
+      mapWidth: mapModel.def.width,
+      mapHeight: mapModel.def.height,
+      footprints,
+      defaultId: sceneId ?? DEFAULT_SCENE_ID,
+    });
+    if (sceneModel === null) {
+      setStatus(`${scenePath}: not a valid scene`, true);
+      showPlaceholder("scene");
+      return;
+    }
+
+    disposeEditor = mountSceneEditor(editorPanel, {
+      root: currentProject.root,
+      scenePath,
+      mapPath: mapFile.path,
+      mapModel,
+      sceneModel,
+      buildings,
+      animals,
+      onStatus: setStatus,
+      onSaved: () => void renderContent(),
+      onFlush: registerFlush,
+    });
+    setStatus(`editing ${scenePath}`);
+  } catch (err) {
+    showPlaceholder("scene");
+    setStatus(err instanceof Error ? err.message : String(err), true);
+  }
+}
+
+async function loadBuildingPalette(root: string): Promise<SceneBuildingPaletteEntry[]> {
+  if (currentListing === null) return [];
+  const files = currentListing.kinds.find((kind) => kind.kind === "building")?.files ?? [];
+  const entries: SceneBuildingPaletteEntry[] = [];
+  for (const file of files) {
+    try {
+      const { content } = await window.nengine.project.readYaml(root, file.path);
+      const parsed = buildingSchema.safeParse(parse(content));
+      if (parsed.success) {
+        entries.push({
+          id: file.id,
+          footprint: parsed.data.footprint,
+          ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
+        });
+      }
+    } catch {
+      // skip unreadable building defs
+    }
+  }
+  return entries;
+}
+
+function freeSceneId(): string {
+  const existing = currentListing?.kinds.find((kind) => kind.kind === "scene")?.files.map((f) => f.id) ?? [];
+  if (!existing.includes(DEFAULT_SCENE_ID)) return DEFAULT_SCENE_ID;
+  let n = 2;
+  while (existing.includes(`scene-${n}`)) n++;
+  return `scene-${n}`;
+}
+
+async function playProject(): Promise<void> {
+  if (currentProject === null || playtestActive) return;
+  playtestActive = true;
+  showScreen("playtest");
+  const result = await startPlaytest({
+    root: currentProject.root,
+    collectFlushes: () => (flushCollector !== null ? flushCollector() : []),
+  });
+  if (!result.ok) {
+    setStatus(result.error ?? "playtest failed", true);
+    await exitPlaytest();
+  }
+}
+
+async function exitPlaytest(): Promise<void> {
+  if (!playtestActive) return;
+  playtestActive = false;
+  try {
+    await stopPlaytest();
+  } catch (err) {
+    setStatus(err instanceof Error ? err.message : String(err), true);
+  }
+  showScreen("editor");
+  void renderContent();
+}
+
+async function openPackageDialog(): Promise<void> {
+  if (currentProject === null) return;
+  let data = currentProject.projectYaml;
+  try {
+    const fresh = await window.nengine.project.readProjectYaml(currentProject.root);
+    data = fresh.data;
+  } catch (err) {
+    setStatus(err instanceof Error ? err.message : String(err), true);
+  }
+  openPackDialog({
+    root: currentProject.root,
+    projectName: currentProject.name,
+    projectYaml: data,
+    onStatus: setStatus,
+  });
+}
+
+element("btn-open").addEventListener("click", () => void openViaDialog());
+element("btn-new").addEventListener("click", () => void newViaDialog());
+element("btn-close").addEventListener("click", goHome);
+element("btn-play").addEventListener("click", () => void playProject());
+element("btn-package").addEventListener("click", () => void openPackageDialog());
+element("btn-playtest-exit").addEventListener("click", () => void exitPlaytest());
+element("btn-mcp-copy").addEventListener("click", () => void copyMcpUrl());
+mountPackDialog();
+
+showScreen("home");
+void renderRecent();

+ 308 - 0
apps/editor/src/renderer/map-editor.ts

@@ -0,0 +1,308 @@
+import { stringify } from "yaml";
+import {
+  TILE_SIZE,
+  addTileId,
+  erase,
+  isWalkableTile,
+  markSaved,
+  paint,
+  pointToTile,
+  serialize,
+  setSpawn,
+  tileColor,
+  toggleWalkable,
+} from "./map-model.js";
+import type { MapModel } from "./map-model.js";
+import type { PlaytestFlush } from "./playtest-session.js";
+
+export type MapTool = "paint" | "erase" | "spawn" | "walkable";
+
+export interface MapEditorOptions {
+  root: string;
+  path: string;
+  onStatus: (message: string, isError?: boolean) => void;
+  onFlush?: (collect: () => PlaytestFlush[]) => void;
+}
+
+const TOOL_BUTTONS: { id: MapTool; label: string; title: string }[] = [
+  { id: "paint", label: "Paint", title: "Left-click / drag paints the selected tile" },
+  { id: "erase", label: "Erase", title: "Left-drag erases; right-click erases anywhere" },
+  { id: "spawn", label: "Spawn", title: "Click moves the spawn point" },
+  { id: "walkable", label: "Walkable", title: "Shows blocked tiles and toggles a tile id's walkability on click" },
+];
+
+export function mountMapEditor(container: HTMLElement, model: MapModel, options: MapEditorOptions): () => void {
+  const root = document.createElement("div");
+  root.className = "map-editor";
+  container.replaceChildren(root);
+
+  let tool: MapTool = "paint";
+  let selectedId = model.palette[0] ?? "";
+  let painting = false;
+  let disposed = false;
+
+  const toolbar = document.createElement("div");
+  toolbar.className = "map-toolbar";
+  const title = document.createElement("h3");
+  title.textContent = `Map: ${model.def.id}`;
+  const path = document.createElement("span");
+  path.className = "map-path";
+  path.textContent = options.path;
+  path.title = options.path;
+  const tools = document.createElement("div");
+  tools.className = "map-tools";
+  const toolButtons = new Map<MapTool, HTMLButtonElement>();
+  for (const t of TOOL_BUTTONS) {
+    const button = document.createElement("button");
+    button.type = "button";
+    button.className = "map-tool-button";
+    button.textContent = t.label;
+    button.title = t.title;
+    button.addEventListener("click", () => {
+      setTool(t.id === tool && t.id === "walkable" ? "paint" : t.id);
+    });
+    toolButtons.set(t.id, button);
+    tools.appendChild(button);
+  }
+  const save = document.createElement("button");
+  save.type = "button";
+  save.className = "primary";
+  save.addEventListener("click", () => {
+    void saveMap();
+  });
+  toolbar.append(title, path, tools, save);
+  root.appendChild(toolbar);
+
+  const body = document.createElement("div");
+  body.className = "map-body";
+
+  const palette = document.createElement("div");
+  palette.className = "map-palette";
+  const paletteList = document.createElement("div");
+  paletteList.className = "palette-list";
+  const addForm = document.createElement("form");
+  addForm.className = "add-tile-form";
+  const addInput = document.createElement("input");
+  addInput.type = "text";
+  addInput.placeholder = "tile id";
+  const addButton = document.createElement("button");
+  addButton.type = "submit";
+  addButton.textContent = "Add";
+  addForm.append(addInput, addButton);
+  addForm.addEventListener("submit", (event) => {
+    event.preventDefault();
+    const value = addInput.value.trim();
+    addInput.value = "";
+    if (value !== "") {
+      addTileId(model, value);
+      renderPalette();
+    }
+  });
+  palette.append(paletteList, addForm);
+  body.appendChild(palette);
+
+  const wrap = document.createElement("div");
+  wrap.className = "map-canvas-wrap";
+  const canvas = document.createElement("canvas");
+  canvas.width = model.def.width * TILE_SIZE;
+  canvas.height = model.def.height * TILE_SIZE;
+  wrap.appendChild(canvas);
+  body.appendChild(wrap);
+  root.appendChild(body);
+
+  function renderPalette(): void {
+    paletteList.replaceChildren();
+    for (const id of model.palette) {
+      const swatch = document.createElement("button");
+      swatch.type = "button";
+      swatch.className = "palette-swatch";
+      if (id === selectedId && tool === "paint") swatch.classList.add("selected");
+      const chip = document.createElement("span");
+      chip.className = "swatch-chip";
+      chip.style.background = tileColor(id);
+      const label = document.createElement("span");
+      label.className = "swatch-label";
+      label.textContent = isWalkableTile(model, id) ? id : `${id} (blocked)`;
+      swatch.append(chip, label);
+      swatch.addEventListener("click", () => {
+        if (tool === "walkable") {
+          toggleWalkable(model, id);
+          renderPalette();
+          draw();
+          updateSave();
+        } else {
+          selectedId = id;
+          renderPalette();
+        }
+      });
+      paletteList.appendChild(swatch);
+    }
+  }
+
+  function setTool(next: MapTool): void {
+    tool = next;
+    for (const [id, button] of toolButtons) {
+      button.classList.toggle("selected", id === next);
+    }
+    renderPalette();
+    draw();
+  }
+
+  function draw(): void {
+    if (disposed) return;
+    const ctx = canvas.getContext("2d");
+    if (ctx === null) return;
+    const data = model.def.layers[model.groundIndex]?.data;
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    for (let y = 0; y < model.def.height; y++) {
+      for (let x = 0; x < model.def.width; x++) {
+        const tile = data?.[y]?.[x] ?? "";
+        ctx.fillStyle = tileColor(tile);
+        ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
+      }
+    }
+    drawGrid(ctx);
+    if (tool === "walkable") drawBlocked(ctx, data);
+    drawSpawn(ctx);
+  }
+
+  function drawGrid(ctx: CanvasRenderingContext2D): void {
+    ctx.strokeStyle = "rgba(10, 12, 16, 0.35)";
+    ctx.lineWidth = 1;
+    ctx.beginPath();
+    for (let x = 0; x <= model.def.width; x++) {
+      ctx.moveTo(x * TILE_SIZE + 0.5, 0);
+      ctx.lineTo(x * TILE_SIZE + 0.5, canvas.height);
+    }
+    for (let y = 0; y <= model.def.height; y++) {
+      ctx.moveTo(0, y * TILE_SIZE + 0.5);
+      ctx.lineTo(canvas.width, y * TILE_SIZE + 0.5);
+    }
+    ctx.stroke();
+  }
+
+  function drawBlocked(ctx: CanvasRenderingContext2D, data: string[][] | undefined): void {
+    for (let y = 0; y < model.def.height; y++) {
+      for (let x = 0; x < model.def.width; x++) {
+        const tile = data?.[y]?.[x] ?? "";
+        if (tile === "" || isWalkableTile(model, tile)) continue;
+        ctx.strokeStyle = "rgba(231, 76, 60, 0.9)";
+        ctx.lineWidth = 2;
+        const px = x * TILE_SIZE;
+        const py = y * TILE_SIZE;
+        ctx.beginPath();
+        ctx.moveTo(px, py);
+        ctx.lineTo(px + TILE_SIZE, py + TILE_SIZE);
+        ctx.moveTo(px + TILE_SIZE, py);
+        ctx.lineTo(px, py + TILE_SIZE);
+        ctx.stroke();
+      }
+    }
+  }
+
+  function drawSpawn(ctx: CanvasRenderingContext2D): void {
+    const sx = model.def.spawn.x * TILE_SIZE + TILE_SIZE / 2;
+    const sy = model.def.spawn.y * TILE_SIZE + TILE_SIZE / 2;
+    ctx.strokeStyle = "#ffd54f";
+    ctx.fillStyle = "#ffd54f";
+    ctx.lineWidth = 3;
+    ctx.beginPath();
+    ctx.arc(sx, sy, TILE_SIZE * 0.32, 0, Math.PI * 2);
+    ctx.stroke();
+    ctx.beginPath();
+    ctx.arc(sx, sy, TILE_SIZE * 0.14, 0, Math.PI * 2);
+    ctx.fill();
+  }
+
+  function cellAt(event: MouseEvent): { x: number; y: number } {
+    const rect = canvas.getBoundingClientRect();
+    return pointToTile(event.clientX - rect.left, event.clientY - rect.top);
+  }
+
+  function applyAction(x: number, y: number): void {
+    const paletteBefore = model.palette.join("\u0000");
+    switch (tool) {
+      case "paint":
+        paint(model, x, y, selectedId);
+        break;
+      case "erase":
+        erase(model, x, y);
+        break;
+      case "spawn":
+        setSpawn(model, x, y);
+        break;
+      case "walkable": {
+        const tile = model.def.layers[model.groundIndex]?.data[y]?.[x] ?? "";
+        if (tile !== "") toggleWalkable(model, tile);
+        break;
+      }
+    }
+    draw();
+    if (tool === "walkable" || model.palette.join("\u0000") !== paletteBefore) renderPalette();
+    updateSave();
+  }
+
+  function updateSave(): void {
+    save.textContent = model.dirty ? "Save *" : "Save";
+    save.disabled = !model.dirty;
+  }
+
+  async function saveMap(): Promise<void> {
+    const def = serialize(model);
+    if (def === null) {
+      options.onStatus(`${options.path}: map invalid — not saved`, true);
+      return;
+    }
+    try {
+      await window.nengine.project.writeYaml({ root: options.root, path: options.path, content: stringify(def) });
+      markSaved(model);
+      updateSave();
+      options.onStatus(`saved ${options.path}`);
+    } catch (err) {
+      options.onStatus(err instanceof Error ? err.message : String(err), true);
+    }
+  }
+
+  canvas.addEventListener("mousedown", (event) => {
+    if (event.button === 2) {
+      const { x, y } = cellAt(event);
+      erase(model, x, y);
+      draw();
+      updateSave();
+      event.preventDefault();
+      return;
+    }
+    if (event.button !== 0) return;
+    painting = true;
+    const { x, y } = cellAt(event);
+    applyAction(x, y);
+  });
+  canvas.addEventListener("mousemove", (event) => {
+    if (!painting) return;
+    const { x, y } = cellAt(event);
+    applyAction(x, y);
+  });
+  canvas.addEventListener("mouseup", () => {
+    painting = false;
+  });
+  canvas.addEventListener("mouseleave", () => {
+    painting = false;
+  });
+  canvas.addEventListener("contextmenu", (event) => event.preventDefault());
+
+  setTool("paint");
+  updateSave();
+
+  if (options.onFlush !== undefined) {
+    options.onFlush(() => {
+      if (!model.dirty) return [];
+      const def = serialize(model);
+      if (def === null) return [{ path: options.path, error: "map invalid — not saved" }];
+      return [{ path: options.path, content: stringify(def) }];
+    });
+  }
+
+  return () => {
+    disposed = true;
+  };
+}

+ 191 - 0
apps/editor/src/renderer/map-model.ts

@@ -0,0 +1,191 @@
+import { mapSchema } from "@nengine/core-schema";
+import { UNWALKABLE_TILES } from "@nengine/core-systems";
+import type { MapDef } from "@nengine/core";
+
+/** Editor canvas tile size in pixels (matches the game viewport's 32px tiles). */
+export const TILE_SIZE = 32;
+
+/** Tile id `erase` writes when the map uses it (the engine's neutral terrain). */
+export const DEFAULT_ERASE_TILE = "grass";
+
+/**
+ * Strict-typed editor model over a MapDef. The model owns a deep-cloned
+ * working copy of the def; grid operations never mutate the def passed to
+ * `createMapModel`. Pure logic — node-testable, no DOM.
+ */
+export interface MapModel {
+  /** Working copy of the map definition (paint/erase/spawn/walkable edits live here). */
+  def: MapDef;
+  /** Index of the terrain layer: the layer named "ground", else the first layer. */
+  groundIndex: number;
+  /** Palette tile ids: ids present on the map plus ids added via `addTileId`
+   * (never includes the empty string). */
+  palette: string[];
+  /** True when any serializable edit (paint/erase/spawn/walkable) is unsaved. */
+  dirty: boolean;
+}
+
+export function createMapModel(def: unknown): MapModel | null {
+  const result = mapSchema.safeParse(def);
+  if (!result.success) return null;
+  const model = cloneDef(result.data);
+  const groundIndex = Math.max(0, model.layers.findIndex((layer) => layer.name === "ground"));
+  return { def: model, groundIndex, palette: collectIds(model), dirty: false };
+}
+
+/** Unique tile ids currently on the map (all layers, row-major first-seen order). */
+export function collectTileIds(model: MapModel): string[] {
+  return collectIds(model.def);
+}
+
+/** Paint `tileId` onto the ground layer at (x, y). Out-of-bounds is a no-op. */
+export function paint(model: MapModel, x: number, y: number, tileId: string): void {
+  if (x < 0 || y < 0) return;
+  const row = model.def.layers[model.groundIndex]?.data[y];
+  if (row === undefined || x >= row.length) return;
+  if (row[x] === tileId) return;
+  row[x] = tileId;
+  model.dirty = true;
+  if (tileId !== "" && !model.palette.includes(tileId)) model.palette.push(tileId);
+}
+
+/** Erase the ground cell at (x, y): write `DEFAULT_ERASE_TILE` when the map
+ * uses it, else the first palette tile id, else "" (void). */
+export function erase(model: MapModel, x: number, y: number): void {
+  paint(model, x, y, eraseTileId(model));
+}
+
+/** Move the spawn point. Out-of-range coordinates are clamped into the map;
+ * non-integer coordinates are rejected (no-op). */
+export function setSpawn(model: MapModel, x: number, y: number): void {
+  if (!Number.isInteger(x) || !Number.isInteger(y)) return;
+  model.def.spawn.x = clampInt(x, 0, model.def.width - 1);
+  model.def.spawn.y = clampInt(y, 0, model.def.height - 1);
+  model.dirty = true;
+}
+
+/**
+ * Toggle a tile id's walkable record entry. When the id is currently `false`
+ * the key is deleted (restoring the engine's fallback semantics, D33); when it
+ * is `true` or absent it is set to `false`. The record only ever carries
+ * `false` entries — a fallback-blocked id (e.g. water) toggled off the record
+ * still stays non-walkable at runtime; making such an id walkable needs
+ * `walkable: { <id>: true }` in YAML (editor v1, documented).
+ */
+export function toggleWalkable(model: MapModel, tileId: string): void {
+  if (tileId === "") return;
+  if (model.def.walkable?.[tileId] === false) {
+    const walkable = { ...(model.def.walkable ?? {}) };
+    delete walkable[tileId];
+    if (Object.keys(walkable).length === 0) {
+      delete model.def.walkable;
+    } else {
+      model.def.walkable = walkable;
+    }
+  } else {
+    model.def.walkable = { ...(model.def.walkable ?? {}), [tileId]: false };
+  }
+  model.dirty = true;
+}
+
+/** Register a palette tile id that may not be on the map yet. No def change. */
+export function addTileId(model: MapModel, tileId: string): void {
+  if (tileId === "") return;
+  if (!model.palette.includes(tileId)) model.palette.push(tileId);
+}
+
+/** Walkability of a tile id, mirroring the engine's resolution (D33): the
+ * declared `walkable` value wins; undeclared ids fall back to the engine's
+ * UNWALKABLE_TILES constant (default `{"water"}`). */
+export function isWalkableTile(model: MapModel, tileId: string): boolean {
+  const declared = model.def.walkable?.[tileId];
+  if (typeof declared === "boolean") return declared;
+  return !UNWALKABLE_TILES.has(tileId);
+}
+
+/** Serialize the working copy as a fresh plain MapDef, validated by
+ * `mapSchema.safeParse`. Returns null when the working copy is invalid —
+ * invalid edits must not serialize. */
+export function serialize(model: MapModel): MapDef | null {
+  const result = mapSchema.safeParse(model.def);
+  if (!result.success) return null;
+  return cloneDef(result.data);
+}
+
+/** Clear the unsaved-changes flag (call after a successful save). */
+export function markSaved(model: MapModel): void {
+  model.dirty = false;
+}
+
+/**
+ * Editor canvas tile color. grass/tilled/water keep the render package's
+ * TILE_COLORS values for visual continuity; every other tile id gets a stable
+ * hash-derived hue (same id → same color). This is a palette approximation —
+ * asset-accurate rendering is playtest territory.
+ */
+const TILE_PALETTE: Record<string, string> = {
+  grass: "#7cb342",
+  tilled: "#8d6e63",
+  water: "#4fc3f7",
+};
+
+export const UNKNOWN_TILE_COLOR = "#5b6470";
+
+export function tileColor(tileId: string): string {
+  const known = TILE_PALETTE[tileId];
+  if (known !== undefined) return known;
+  if (tileId === "") return UNKNOWN_TILE_COLOR;
+  return `hsl(${stableHue(tileId)} 55% 55%)`;
+}
+
+/** Canvas pixel offset → grid tile (pure geometry; negative offsets give
+ * negative tiles, which edit ops reject/clamp). */
+export function pointToTile(offsetX: number, offsetY: number, tileSize: number = TILE_SIZE): { x: number; y: number } {
+  return { x: Math.floor(offsetX / tileSize), y: Math.floor(offsetY / tileSize) };
+}
+
+function eraseTileId(model: MapModel): string {
+  if (model.palette.includes(DEFAULT_ERASE_TILE)) return DEFAULT_ERASE_TILE;
+  return model.palette[0] ?? "";
+}
+
+function collectIds(def: MapDef): string[] {
+  const ids: string[] = [];
+  for (const layer of def.layers) {
+    for (const row of layer.data) {
+      for (const tile of row) {
+        if (tile !== "" && !ids.includes(tile)) ids.push(tile);
+      }
+    }
+  }
+  return ids;
+}
+
+function cloneDef(def: MapDef): MapDef {
+  const clone: MapDef = {
+    id: def.id,
+    width: def.width,
+    height: def.height,
+    tilesetRef: def.tilesetRef,
+    spawn: { x: def.spawn.x, y: def.spawn.y },
+    layers: def.layers.map((layer) => ({
+      name: layer.name,
+      data: layer.data.map((row) => [...row]),
+    })),
+  };
+  if (def.walkable !== undefined) clone.walkable = { ...def.walkable };
+  if (def.tiles !== undefined) clone.tiles = { ...def.tiles };
+  return clone;
+}
+
+function clampInt(value: number, min: number, max: number): number {
+  return Math.min(Math.max(value, min), max);
+}
+
+function stableHue(id: string): number {
+  let hash = 0;
+  for (let i = 0; i < id.length; i++) {
+    hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
+  }
+  return hash % 360;
+}

+ 9 - 0
apps/editor/src/renderer/nengine.d.ts

@@ -0,0 +1,9 @@
+import type { EditorMcpApi, EditorPackApi, EditorPlaytestApi, EditorProjectApi } from "../ipc.js";
+
+declare global {
+  interface Window {
+    nengine: { project: EditorProjectApi; playtest: EditorPlaytestApi; mcp: EditorMcpApi; pack: EditorPackApi };
+  }
+}
+
+export {};

+ 116 - 0
apps/editor/src/renderer/pack-dialog.ts

@@ -0,0 +1,116 @@
+import type { EditorPackOptions, EditorPackRunResult } from "../ipc.js";
+import type { EditorProjectYaml } from "../project-yaml.js";
+import { dialogDefaultsFromProject, packHints, projectYamlFromPack } from "../pack-options.js";
+
+export interface PackDialogDeps {
+  root: string;
+  projectName: string;
+  projectYaml: EditorProjectYaml | null;
+  onStatus: (message: string, isError?: boolean) => void;
+}
+
+function element(id: string): HTMLElement {
+  const el = document.getElementById(id);
+  if (el === null) throw new Error(`missing element #${id}`);
+  return el;
+}
+
+const dialog = element("pack-dialog");
+const nameInput = element("pack-name") as HTMLInputElement;
+const iconInput = element("pack-icon") as HTMLInputElement;
+const desktopCheck = element("pack-desktop") as HTMLInputElement;
+const installerCheck = element("pack-installer") as HTMLInputElement;
+const modsCheck = element("pack-mods") as HTMLInputElement;
+const splitCheck = element("pack-split") as HTMLInputElement;
+const outDirInput = element("pack-outdir") as HTMLInputElement;
+const resultEl = element("pack-result");
+const runButton = element("btn-pack-run") as HTMLButtonElement;
+
+let deps: PackDialogDeps | null = null;
+
+function readOptions(): EditorPackOptions {
+  return {
+    name: nameInput.value,
+    icon: iconInput.value,
+    desktop: desktopCheck.checked,
+    installer: installerCheck.checked,
+    mods: modsCheck.checked,
+    splitAssets: splitCheck.checked,
+    outDir: outDirInput.value,
+  };
+}
+
+function writeOptions(options: EditorPackOptions): void {
+  nameInput.value = options.name;
+  iconInput.value = options.icon;
+  desktopCheck.checked = options.desktop;
+  installerCheck.checked = options.installer;
+  modsCheck.checked = options.mods;
+  splitCheck.checked = options.splitAssets;
+  outDirInput.value = options.outDir;
+}
+
+function showResult(message: string, isError: boolean): void {
+  resultEl.textContent = message;
+  resultEl.classList.toggle("status-error", isError);
+  resultEl.classList.remove("hidden");
+}
+
+function busy(running: boolean): void {
+  runButton.disabled = running;
+  runButton.textContent = running ? "Packaging…" : "Run package";
+}
+
+async function run(depsToUse: PackDialogDeps, options: EditorPackOptions): Promise<void> {
+  busy(true);
+  resultEl.classList.add("hidden");
+  try {
+    await window.nengine.project.writeProjectYaml({
+      root: depsToUse.root,
+      data: projectYamlFromPack(options, depsToUse.projectYaml),
+    });
+  } catch (err) {
+    showResult(err instanceof Error ? err.message : String(err), true);
+    busy(false);
+    return;
+  }
+  depsToUse.onStatus("packaging…");
+  let result: EditorPackRunResult;
+  try {
+    result = await window.nengine.pack.run({ root: depsToUse.root, options });
+  } catch (err) {
+    showResult(`Packaging failed:\n${err instanceof Error ? err.message : String(err)}`, true);
+    depsToUse.onStatus("");
+    busy(false);
+    return;
+  }
+  depsToUse.onStatus("");
+  if (result.ok) {
+    showResult(`Package complete → ${result.outDir}\n${packHints(result.stats).join("\n")}`, false);
+  } else {
+    showResult(`Packaging failed:\n${result.error}`, true);
+  }
+  busy(false);
+}
+
+export function openPackDialog(next: PackDialogDeps): void {
+  deps = next;
+  writeOptions(dialogDefaultsFromProject(deps.projectYaml, deps.root, deps.projectName));
+  resultEl.textContent = "";
+  resultEl.classList.add("hidden");
+  dialog.classList.remove("hidden");
+}
+
+export function closePackDialog(): void {
+  dialog.classList.add("hidden");
+}
+
+export function mountPackDialog(): void {
+  element("btn-pack-run").addEventListener("click", () => {
+    if (deps !== null) void run(deps, readOptions());
+  });
+  element("btn-pack-close").addEventListener("click", closePackDialog);
+  installerCheck.addEventListener("change", () => {
+    if (installerCheck.checked) desktopCheck.checked = true;
+  });
+}

+ 47 - 0
apps/editor/src/renderer/playtest-hud.ts

@@ -0,0 +1,47 @@
+export interface PlayerStats {
+  inventory: Record<string, number>;
+  gold: number;
+  stamina: number;
+  selectedSeed: string | null;
+}
+
+/** Player HUD fields from a snapshot world (dev-app ui.ts parity, D35). */
+export function playerStatsFromSnapshot(world: unknown[]): PlayerStats {
+  const player = world.find(
+    (entity): entity is Record<string, unknown> => isRecord(entity) && entity["id"] === "player",
+  );
+  const inventory: Record<string, number> = {};
+  const rawInventory = player?.["inventory"];
+  if (isRecord(rawInventory)) {
+    for (const [id, qty] of Object.entries(rawInventory)) {
+      if (typeof qty === "number" && qty > 0) inventory[id] = qty;
+    }
+  }
+  return {
+    inventory,
+    gold: numberField(player, "gold"),
+    stamina: numberField(player, "stamina"),
+    selectedSeed: stringField(player, "selectedSeed"),
+  };
+}
+
+export function inventorySummary(stats: PlayerStats): string {
+  const items = Object.entries(stats.inventory)
+    .map(([id, qty]) => `${id} ×${qty}`)
+    .join(" · ");
+  return items.length > 0 ? items : "items: —";
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function numberField(value: Record<string, unknown> | undefined, key: string): number {
+  const field = value?.[key];
+  return typeof field === "number" ? field : 0;
+}
+
+function stringField(value: Record<string, unknown> | undefined, key: string): string | null {
+  const field = value?.[key];
+  return typeof field === "string" && field.length > 0 ? field : null;
+}

+ 124 - 0
apps/editor/src/renderer/playtest-session.ts

@@ -0,0 +1,124 @@
+import type { ContentBundle } from "@nengine/core";
+import { arrowDirection } from "@nengine/render";
+
+/**
+ * A pending save produced by an open editor model. `content` is the
+ * stringified YAML to persist; `error` models a model that cannot serialize
+ * (invalid edits never write — the playtest must fail loudly before boot).
+ */
+export type PlaytestFlush = { path: string; content: string } | { path: string; error: string };
+
+/** Collects the dirty models currently open in the editor (empty = nothing to save). */
+export type PlaytestFlushCollector = () => PlaytestFlush[];
+
+export interface SaveAllResult {
+  flushed: string[];
+  failures: { path: string; error: string }[];
+}
+
+/** Persist every dirty model; a rejecting write or an un-serializable model is
+ * recorded as a failure (never thrown), so a single bad file cannot half-save. */
+export async function saveAllFlushes(
+  flushes: PlaytestFlush[],
+  write: (path: string, content: string) => Promise<void>,
+): Promise<SaveAllResult> {
+  const flushed: string[] = [];
+  const failures: { path: string; error: string }[] = [];
+  for (const flush of flushes) {
+    if ("error" in flush) {
+      failures.push({ path: flush.path, error: flush.error });
+      continue;
+    }
+    try {
+      await write(flush.path, flush.content);
+      flushed.push(flush.path);
+    } catch (err) {
+      failures.push({ path: flush.path, error: err instanceof Error ? err.message : String(err) });
+    }
+  }
+  return { flushed, failures };
+}
+
+export type PlaytestPhase = "idle" | "saving" | "compiling" | "booting" | "playing" | "stopping";
+
+export interface PlaytestSessionDeps {
+  collectFlushes: PlaytestFlushCollector;
+  write: (path: string, content: string) => Promise<void>;
+  compile: () => Promise<ContentBundle>;
+  boot: (bundle: ContentBundle) => Promise<void>;
+  teardown: () => Promise<void> | void;
+  onPhase?: (phase: PlaytestPhase) => void;
+}
+
+export interface PlaytestStartResult {
+  ok: boolean;
+  saved: string[];
+  error?: string;
+}
+
+export interface PlaytestSession {
+  readonly phase: PlaytestPhase;
+  start(): Promise<PlaytestStartResult>;
+  stop(): Promise<void>;
+}
+
+/** The playtest lifecycle (E5): save-all → compile → boot → playing, with
+ * stop() destroying the booted instance. Pure orchestration — every I/O side
+ * is an injected dep, so the whole sequence is node-testable. */
+export function createPlaytestSession(deps: PlaytestSessionDeps): PlaytestSession {
+  let phase: PlaytestPhase = "idle";
+
+  function setPhase(next: PlaytestPhase): void {
+    phase = next;
+    deps.onPhase?.(next);
+  }
+
+  async function start(): Promise<PlaytestStartResult> {
+    if (phase !== "idle") {
+      return { ok: false, saved: [], error: "playtest already running" };
+    }
+    setPhase("saving");
+    const result = await saveAllFlushes(deps.collectFlushes(), deps.write);
+    if (result.failures.length > 0) {
+      setPhase("idle");
+      const lines = result.failures.map((f) => `- ${f.path}: ${f.error}`).join("\n");
+      return { ok: false, saved: result.flushed, error: `save-all failed:\n${lines}` };
+    }
+    setPhase("compiling");
+    let bundle: ContentBundle;
+    try {
+      bundle = await deps.compile();
+    } catch (err) {
+      setPhase("idle");
+      return { ok: false, saved: result.flushed, error: err instanceof Error ? err.message : String(err) };
+    }
+    setPhase("booting");
+    try {
+      await deps.boot(bundle);
+    } catch (err) {
+      setPhase("idle");
+      return { ok: false, saved: result.flushed, error: err instanceof Error ? err.message : String(err) };
+    }
+    setPhase("playing");
+    return { ok: true, saved: result.flushed };
+  }
+
+  async function stop(): Promise<void> {
+    if (phase === "idle") return;
+    setPhase("stopping");
+    try {
+      await deps.teardown();
+    } finally {
+      setPhase("idle");
+    }
+  }
+
+  return { get phase() { return phase; }, start, stop };
+}
+
+/** Map a keydown key to a raw `"key:Arrow*"` input string, or undefined for
+ * non-arrow keys (the local-sync input mapping, D25). */
+export function keydownInput(key: string): string | undefined {
+  const input = `key:${key}`;
+  return arrowDirection(input) ? input : undefined;
+}

+ 159 - 0
apps/editor/src/renderer/playtest.ts

@@ -0,0 +1,159 @@
+import { createEngine } from "@nengine/core";
+import type { ContentBundle, Engine, MapDef } from "@nengine/core";
+import { connectLocal, createSoundPlayer, createViewport } from "@nengine/render";
+import type { LocalSyncHandle, SoundPlayer, ViewportHandle } from "@nengine/render";
+import { createPlaytestSession, keydownInput } from "./playtest-session.js";
+import type { PlaytestFlush, PlaytestSession, PlaytestStartResult } from "./playtest-session.js";
+import { inventorySummary, playerStatsFromSnapshot } from "./playtest-hud.js";
+
+export interface PlaytestStartOptions {
+  root: string;
+  collectFlushes: () => PlaytestFlush[];
+}
+
+export interface PlaytestState {
+  engine: Engine;
+  viewport: ViewportHandle;
+  localSync: LocalSyncHandle;
+  sound: SoundPlayer;
+  removeListeners: (() => void)[];
+}
+
+const PHASE_LABELS: Partial<Record<string, string>> = {
+  saving: "Saving edits…",
+  compiling: "Compiling content…",
+  booting: "Booting engine…",
+  stopping: "Stopping…",
+};
+
+function element(id: string): HTMLElement {
+  const el = document.getElementById(id);
+  if (el === null) throw new Error(`missing element #${id}`);
+  return el;
+}
+
+const titleEl = element("playtest-title");
+const statusEl = element("playtest-status");
+const timeEl = element("playtest-time");
+const statsEl = element("playtest-stats");
+const feedbackEl = element("playtest-feedback");
+const gameEl = element("playtest-game");
+const exitButton = element("btn-playtest-exit") as HTMLButtonElement;
+
+let session: PlaytestSession | null = null;
+let state: PlaytestState | null = null;
+
+export async function startPlaytest(opts: PlaytestStartOptions): Promise<PlaytestStartResult> {
+  if (session !== null) {
+    return { ok: false, saved: [], error: "playtest already running" };
+  }
+  titleEl.textContent = `Playtest — ${opts.root}`;
+  statusEl.textContent = "";
+  feedbackEl.textContent = "";
+  exitButton.disabled = true;
+  session = createPlaytestSession({
+    collectFlushes: opts.collectFlushes,
+    write: (path, content) => window.nengine.project.writeYaml({ root: opts.root, path, content }),
+    compile: async () => {
+      const result = await window.nengine.playtest.start(opts.root);
+      if ("error" in result) throw new Error(result.error);
+      return result.bundle;
+    },
+    boot: async (bundle) => {
+      state = await bootEngine(bundle);
+    },
+    teardown: async () => {
+      await teardownEngine();
+    },
+    onPhase: (phase) => {
+      statusEl.textContent = PHASE_LABELS[phase] ?? "";
+      exitButton.disabled = phase !== "playing";
+      feedbackEl.textContent = phase === "playing" ? "playtest running — arrow keys to move, click to interact" : "";
+    },
+  });
+  return await session.start();
+}
+
+export async function stopPlaytest(): Promise<void> {
+  if (session === null) return;
+  const current = session;
+  session = null;
+  await current.stop();
+}
+
+async function bootEngine(bundle: ContentBundle): Promise<PlaytestState> {
+  const engine = createEngine({ bundle });
+  let viewport: ViewportHandle | null = null;
+  try {
+    await engine.start();
+    const map = engine.registry.get<MapDef>("map", "farm") ?? engine.registry.list<MapDef>("map")[0];
+    if (!map) throw new Error("no map content loaded");
+    viewport = await createViewport(gameEl, { map });
+    const sound = createSoundPlayer();
+    const localSync = connectLocal(engine, viewport, { sound });
+    const removeListeners = bindInput(localSync, viewport, sound, engine);
+    localSync.setTimeLabel((label) => {
+      timeEl.textContent = label;
+    });
+    removeListeners.push(engine.events.on("player-intent", () => refreshHud(engine)));
+    refreshHud(engine);
+    return { engine, viewport, localSync, sound, removeListeners };
+  } catch (err) {
+    viewport?.destroy();
+    await engine.stop();
+    throw err;
+  }
+}
+
+function bindInput(
+  localSync: LocalSyncHandle,
+  viewport: ViewportHandle,
+  sound: SoundPlayer,
+  engine: Engine,
+): (() => void)[] {
+  const removers: (() => void)[] = [];
+  const onPointer = (): void => {
+    sound.prime();
+  };
+  window.addEventListener("pointerdown", onPointer);
+  removers.push(() => window.removeEventListener("pointerdown", onPointer));
+  const onClick = (event: MouseEvent): void => {
+    const tile = viewport.tileAt(event.clientX, event.clientY);
+    if (tile) {
+      localSync.intent("interact", { tile });
+      refreshHud(engine);
+    }
+  };
+  gameEl.addEventListener("click", onClick);
+  removers.push(() => gameEl.removeEventListener("click", onClick));
+  const onKey = (event: KeyboardEvent): void => {
+    sound.prime();
+    const input = keydownInput(event.key);
+    if (input) {
+      localSync.input(input);
+      refreshHud(engine);
+    }
+  };
+  window.addEventListener("keydown", onKey);
+  removers.push(() => window.removeEventListener("keydown", onKey));
+  return removers;
+}
+
+function refreshHud(engine: Engine): void {
+  const stats = playerStatsFromSnapshot(engine.world.snapshot());
+  statsEl.textContent = `gold ${stats.gold} · stamina ${stats.stamina} · ${inventorySummary(stats)}`;
+}
+
+async function teardownEngine(): Promise<void> {
+  if (state === null) return;
+  const current = state;
+  state = null;
+  current.localSync.destroy();
+  for (const remove of current.removeListeners) remove();
+  current.viewport.destroy();
+  await current.engine.stop();
+  gameEl.replaceChildren();
+  timeEl.textContent = "";
+  statsEl.textContent = "";
+  feedbackEl.textContent = "";
+}

+ 664 - 0
apps/editor/src/renderer/scene-editor.ts

@@ -0,0 +1,664 @@
+import { stringify } from "yaml";
+import type { SceneEntity } from "@nengine/core-schema";
+import {
+  TILE_SIZE,
+  addTileId,
+  erase,
+  isWalkableTile,
+  markSaved as markMapSaved,
+  paint,
+  pointToTile,
+  serialize as serializeMap,
+  setSpawn,
+  tileColor,
+  toggleWalkable,
+} from "./map-model.js";
+import type { MapModel } from "./map-model.js";
+import {
+  SELECT_COLOR,
+  addAnimal,
+  addBuilding,
+  addNpc,
+  describeEntity,
+  entityColor,
+  entityFootprint,
+  entityIndexAt,
+  markSaved as markSceneSaved,
+  moveEntity,
+  removeEntity,
+  serialize as serializeScene,
+} from "./scene-model.js";
+import type { SceneOpResult, SceneFootprint, SceneModel } from "./scene-model.js";
+import type { PlaytestFlush } from "./playtest-session.js";
+
+export interface SceneBuildingPaletteEntry {
+  id: string;
+  name?: string;
+  footprint: SceneFootprint;
+}
+
+export interface SceneEditorOptions {
+  root: string;
+  scenePath: string;
+  mapPath: string;
+  mapModel: MapModel;
+  sceneModel: SceneModel;
+  /** Building palette entries with footprint data from the project's defs. */
+  buildings: SceneBuildingPaletteEntry[];
+  /** Animal ids from the project's animal defs. */
+  animals: string[];
+  onStatus: (message: string, isError?: boolean) => void;
+  onSaved?: () => void;
+  onFlush?: (collect: () => PlaytestFlush[]) => void;
+}
+
+type SceneEditorMode = "scene" | "map";
+type MapTool = "paint" | "erase" | "spawn" | "walkable";
+type ActivePlacement = { type: "building"; id: string } | { type: "animal"; id: string } | { type: "npc"; id: string } | null;
+
+const MAP_TOOLS: { id: MapTool; label: string; title: string }[] = [
+  { id: "paint", label: "Paint", title: "Left-click / drag paints the selected tile" },
+  { id: "erase", label: "Erase", title: "Left-drag erases; right-click erases anywhere" },
+  { id: "spawn", label: "Spawn", title: "Click moves the spawn point" },
+  { id: "walkable", label: "Walkable", title: "Shows blocked tiles and toggles a tile id's walkability on click" },
+];
+
+export function mountSceneEditor(container: HTMLElement, options: SceneEditorOptions): () => void {
+  const root = document.createElement("div");
+  root.className = "scene-editor";
+  container.replaceChildren(root);
+
+  let mode: SceneEditorMode = "scene";
+  let activePlacement: ActivePlacement = null;
+  let selectedIndex: number | null = null;
+  let mapTool: MapTool = "paint";
+  let mapSelectedId = options.mapModel.palette[0] ?? "";
+  let painting = false;
+  let disposed = false;
+
+  const toolbar = document.createElement("div");
+  toolbar.className = "scene-toolbar";
+  const title = document.createElement("h3");
+  title.textContent = `Scene: ${options.sceneModel.def.id}`;
+  const path = document.createElement("span");
+  path.className = "scene-path";
+  path.textContent = options.scenePath;
+  path.title = options.scenePath;
+  const modeToggle = document.createElement("div");
+  modeToggle.className = "scene-mode-toggle";
+  const modeButtons = new Map<SceneEditorMode, HTMLButtonElement>();
+  for (const [value, label] of [["scene", "Edit Scene"], ["map", "Edit Map"]] as const) {
+    const button = document.createElement("button");
+    button.type = "button";
+    button.className = "scene-mode-button";
+    button.textContent = label;
+    button.addEventListener("click", () => setMode(value));
+    modeButtons.set(value, button);
+    modeToggle.appendChild(button);
+  }
+  const save = document.createElement("button");
+  save.type = "button";
+  save.className = "primary";
+  save.addEventListener("click", () => void saveAll());
+  toolbar.append(title, path, modeToggle, save);
+  root.appendChild(toolbar);
+
+  const body = document.createElement("div");
+  body.className = "scene-body";
+
+  const palette = document.createElement("div");
+  palette.className = "scene-palette";
+  body.appendChild(palette);
+
+  const main = document.createElement("div");
+  main.className = "scene-main";
+
+  const wrap = document.createElement("div");
+  wrap.className = "map-canvas-wrap";
+  const canvas = document.createElement("canvas");
+  canvas.width = options.mapModel.def.width * TILE_SIZE;
+  canvas.height = options.mapModel.def.height * TILE_SIZE;
+  wrap.appendChild(canvas);
+  main.appendChild(wrap);
+
+  const entityList = document.createElement("div");
+  entityList.className = "scene-entity-list";
+  const listTitle = document.createElement("h4");
+  listTitle.textContent = "Entities";
+  const listBody = document.createElement("div");
+  listBody.className = "scene-entity-rows";
+  entityList.append(listTitle, listBody);
+  main.appendChild(entityList);
+
+  body.appendChild(main);
+  root.appendChild(body);
+
+  function setMode(next: SceneEditorMode): void {
+    mode = next;
+    activePlacement = null;
+    selectedIndex = null;
+    for (const [value, button] of modeButtons) button.classList.toggle("selected", value === next);
+    renderPalette();
+    draw();
+  }
+
+  function setArm(placement: NonNullable<ActivePlacement>): void {
+    activePlacement = placement;
+    selectedIndex = null;
+    renderPalette();
+    renderEntityList();
+    draw();
+  }
+
+  function setSelected(index: number | null): void {
+    activePlacement = null;
+    selectedIndex = index;
+    renderPalette();
+    renderEntityList();
+    draw();
+  }
+
+  function renderPalette(): void {
+    palette.replaceChildren();
+    if (mode === "scene") renderScenePalette();
+    else renderMapPalette();
+  }
+
+  function renderScenePalette(): void {
+    const buildings = sectionHeader("Buildings");
+    const buildingList = document.createElement("div");
+    buildingList.className = "scene-palette-list";
+    if (options.buildings.length === 0) {
+      const none = document.createElement("span");
+      none.className = "muted";
+      none.textContent = "no building defs in the project";
+      buildingList.appendChild(none);
+    }
+    for (const entry of options.buildings) {
+      const button = document.createElement("button");
+      button.type = "button";
+      button.className = "scene-palette-item";
+      const active = activePlacement !== null && activePlacement.type === "building" && activePlacement.id === entry.id;
+      if (active) button.classList.add("selected");
+      const badge = document.createElement("span");
+      badge.className = "scene-palette-badge";
+      badge.textContent = `${entry.footprint.w}×${entry.footprint.h}`;
+      const label = document.createElement("span");
+      label.textContent = entry.name ?? entry.id;
+      button.title = `${entry.id} — ${entry.footprint.w}×${entry.footprint.h} footprint`;
+      button.append(label, badge);
+      button.addEventListener("click", () => setArm({ type: "building", id: entry.id }));
+      buildingList.appendChild(button);
+    }
+    palette.append(buildings, buildingList);
+
+    const animals = sectionHeader("Animals");
+    const animalList = document.createElement("div");
+    animalList.className = "scene-palette-list";
+    if (options.animals.length === 0) {
+      const none = document.createElement("span");
+      none.className = "muted";
+      none.textContent = "no animal defs in the project";
+      animalList.appendChild(none);
+    }
+    for (const id of options.animals) {
+      const button = document.createElement("button");
+      button.type = "button";
+      button.className = "scene-palette-item";
+      if (activePlacement !== null && activePlacement.type === "animal" && activePlacement.id === id) button.classList.add("selected");
+      const label = document.createElement("span");
+      label.textContent = id;
+      button.append(label);
+      button.addEventListener("click", () => setArm({ type: "animal", id }));
+      animalList.appendChild(button);
+    }
+    palette.append(animals, animalList);
+
+    const npcSection = sectionHeader("NPC");
+    const npcForm = document.createElement("div");
+    npcForm.className = "scene-npc-form";
+    const npcIdInput = document.createElement("input");
+    npcIdInput.type = "text";
+    npcIdInput.placeholder = "npc id";
+    npcIdInput.spellcheck = false;
+    const dialogueInput = document.createElement("input");
+    dialogueInput.type = "text";
+    dialogueInput.placeholder = "dialogue id (optional)";
+    dialogueInput.spellcheck = false;
+    const labelInput = document.createElement("input");
+    labelInput.type = "text";
+    labelInput.placeholder = "label (optional)";
+    labelInput.spellcheck = false;
+    const npcButton = document.createElement("button");
+    npcButton.type = "button";
+    npcButton.textContent = "Place NPC";
+    npcButton.addEventListener("click", () => {
+      const id = npcIdInput.value.trim();
+      if (id === "") {
+        options.onStatus("enter an npc id first", true);
+        return;
+      }
+      setArm({ type: "npc", id });
+      options.onStatus(`armed: place npc "${id}" on the map`);
+    });
+    npcForm.append(npcIdInput, dialogueInput, labelInput, npcButton);
+    palette.append(npcSection, npcForm);
+  }
+
+  function renderMapPalette(): void {
+    const tools = document.createElement("div");
+    tools.className = "map-tools";
+    const toolButtons = new Map<MapTool, HTMLButtonElement>();
+    for (const tool of MAP_TOOLS) {
+      const button = document.createElement("button");
+      button.type = "button";
+      button.className = "map-tool-button";
+      button.textContent = tool.label;
+      button.title = tool.title;
+      button.addEventListener("click", () => {
+        mapTool = tool.id === mapTool && tool.id === "walkable" ? "paint" : tool.id;
+        renderPalette();
+        draw();
+      });
+      toolButtons.set(tool.id, button);
+      tools.appendChild(button);
+    }
+    for (const [id, button] of toolButtons) button.classList.toggle("selected", id === mapTool);
+    palette.append(tools);
+
+    const list = document.createElement("div");
+    list.className = "palette-list";
+    for (const id of options.mapModel.palette) {
+      const swatch = document.createElement("button");
+      swatch.type = "button";
+      swatch.className = "palette-swatch";
+      if (id === mapSelectedId && mapTool === "paint") swatch.classList.add("selected");
+      const chip = document.createElement("span");
+      chip.className = "swatch-chip";
+      chip.style.background = tileColor(id);
+      const label = document.createElement("span");
+      label.className = "swatch-label";
+      label.textContent = isWalkableTile(options.mapModel, id) ? id : `${id} (blocked)`;
+      swatch.append(chip, label);
+      swatch.addEventListener("click", () => {
+        if (mapTool === "walkable") {
+          toggleWalkable(options.mapModel, id);
+          renderPalette();
+          draw();
+          updateSave();
+        } else {
+          mapSelectedId = id;
+          renderPalette();
+        }
+      });
+      list.appendChild(swatch);
+    }
+    palette.append(list);
+
+    const addForm = document.createElement("form");
+    addForm.className = "add-tile-form";
+    const addInput = document.createElement("input");
+    addInput.type = "text";
+    addInput.placeholder = "tile id";
+    const addButton = document.createElement("button");
+    addButton.type = "submit";
+    addButton.textContent = "Add";
+    addForm.append(addInput, addButton);
+    addForm.addEventListener("submit", (event) => {
+      event.preventDefault();
+      const value = addInput.value.trim();
+      addInput.value = "";
+      if (value !== "") {
+        addTileId(options.mapModel, value);
+        renderPalette();
+      }
+    });
+    palette.append(addForm);
+  }
+
+  function renderEntityList(): void {
+    listBody.replaceChildren();
+    if (options.sceneModel.def.entities.length === 0) {
+      const none = document.createElement("span");
+      none.className = "muted";
+      none.textContent = "no entities yet — pick from the palette and click the map";
+      listBody.appendChild(none);
+      return;
+    }
+    options.sceneModel.def.entities.forEach((entity, index) => {
+      const row = document.createElement("div");
+      row.className = "scene-entity-row";
+      if (index === selectedIndex) row.classList.add("selected");
+      const dot = document.createElement("span");
+      dot.className = "scene-entity-dot";
+      dot.style.background = entityColor(entity);
+      const label = document.createElement("span");
+      label.textContent = entityLabel(entity);
+      label.title = describeEntity(entity);
+      const coords = document.createElement("span");
+      coords.className = "scene-entity-coords";
+      coords.textContent = `(${entity.x},${entity.y})`;
+      const removeButton = document.createElement("button");
+      removeButton.type = "button";
+      removeButton.className = "scene-entity-delete";
+      removeButton.textContent = "×";
+      removeButton.title = "Remove entity";
+      removeButton.addEventListener("click", (event) => {
+        event.stopPropagation();
+        if (window.confirm(`Remove ${entityLabel(entity)}?`)) {
+          const result = removeEntity(options.sceneModel, index);
+          if (result.ok) {
+            selectedIndex = null;
+            options.onStatus(`removed ${entityLabel(entity)}`);
+          } else {
+            options.onStatus(result.reason, true);
+          }
+          renderEntityList();
+          draw();
+          updateSave();
+        }
+      });
+      row.append(dot, label, coords, removeButton);
+      row.addEventListener("click", () => setSelected(selectedIndex === index ? null : index));
+      listBody.appendChild(row);
+    });
+  }
+
+  function draw(): void {
+    if (disposed) return;
+    const ctx = canvas.getContext("2d");
+    if (ctx === null) return;
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+    drawTiles(ctx);
+    drawGrid(ctx);
+    if (mode === "map") {
+      if (mapTool === "walkable") drawBlocked(ctx);
+      drawSpawn(ctx);
+    }
+    drawEntities(ctx);
+  }
+
+  function drawTiles(ctx: CanvasRenderingContext2D): void {
+    const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data;
+    for (let y = 0; y < options.mapModel.def.height; y++) {
+      for (let x = 0; x < options.mapModel.def.width; x++) {
+        const tile = data?.[y]?.[x] ?? "";
+        ctx.fillStyle = tileColor(tile);
+        ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
+      }
+    }
+  }
+
+  function drawGrid(ctx: CanvasRenderingContext2D): void {
+    ctx.strokeStyle = "rgba(10, 12, 16, 0.35)";
+    ctx.lineWidth = 1;
+    ctx.beginPath();
+    for (let x = 0; x <= options.mapModel.def.width; x++) {
+      ctx.moveTo(x * TILE_SIZE + 0.5, 0);
+      ctx.lineTo(x * TILE_SIZE + 0.5, canvas.height);
+    }
+    for (let y = 0; y <= options.mapModel.def.height; y++) {
+      ctx.moveTo(0, y * TILE_SIZE + 0.5);
+      ctx.lineTo(canvas.width, y * TILE_SIZE + 0.5);
+    }
+    ctx.stroke();
+  }
+
+  function drawBlocked(ctx: CanvasRenderingContext2D): void {
+    const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data;
+    for (let y = 0; y < options.mapModel.def.height; y++) {
+      for (let x = 0; x < options.mapModel.def.width; x++) {
+        const tile = data?.[y]?.[x] ?? "";
+        if (tile === "" || isWalkableTile(options.mapModel, tile)) continue;
+        ctx.strokeStyle = "rgba(231, 76, 60, 0.9)";
+        ctx.lineWidth = 2;
+        const px = x * TILE_SIZE;
+        const py = y * TILE_SIZE;
+        ctx.beginPath();
+        ctx.moveTo(px, py);
+        ctx.lineTo(px + TILE_SIZE, py + TILE_SIZE);
+        ctx.moveTo(px + TILE_SIZE, py);
+        ctx.lineTo(px, py + TILE_SIZE);
+        ctx.stroke();
+      }
+    }
+  }
+
+  function drawSpawn(ctx: CanvasRenderingContext2D): void {
+    const sx = options.mapModel.def.spawn.x * TILE_SIZE + TILE_SIZE / 2;
+    const sy = options.mapModel.def.spawn.y * TILE_SIZE + TILE_SIZE / 2;
+    ctx.strokeStyle = "#ffd54f";
+    ctx.fillStyle = "#ffd54f";
+    ctx.lineWidth = 3;
+    ctx.beginPath();
+    ctx.arc(sx, sy, TILE_SIZE * 0.32, 0, Math.PI * 2);
+    ctx.stroke();
+    ctx.beginPath();
+    ctx.arc(sx, sy, TILE_SIZE * 0.14, 0, Math.PI * 2);
+    ctx.fill();
+  }
+
+  function drawEntities(ctx: CanvasRenderingContext2D): void {
+    options.sceneModel.def.entities.forEach((entity, index) => {
+      const footprint = entityFootprint(options.sceneModel, entity);
+      const px = entity.x * TILE_SIZE;
+      const py = entity.y * TILE_SIZE;
+      if (entity.type === "building") {
+        ctx.fillStyle = entityColor(entity);
+        ctx.globalAlpha = 0.85;
+        ctx.fillRect(px, py, footprint.w * TILE_SIZE, footprint.h * TILE_SIZE);
+        ctx.globalAlpha = 1;
+        ctx.strokeStyle = "rgba(10, 12, 16, 0.6)";
+        ctx.lineWidth = 1;
+        ctx.strokeRect(px + 0.5, py + 0.5, footprint.w * TILE_SIZE - 1, footprint.h * TILE_SIZE - 1);
+        ctx.fillStyle = "#e6e9ec";
+        ctx.font = "10px system-ui, sans-serif";
+        ctx.fillText(entity.buildingId, px + 3, py + 12);
+      } else {
+        const cx = px + TILE_SIZE / 2;
+        const cy = py + TILE_SIZE / 2;
+        const radius = entity.type === "npc" ? TILE_SIZE * 0.3 : TILE_SIZE * 0.25;
+        ctx.fillStyle = entityColor(entity);
+        ctx.beginPath();
+        ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+        ctx.fill();
+        ctx.strokeStyle = "rgba(10, 12, 16, 0.6)";
+        ctx.lineWidth = 1;
+        ctx.stroke();
+        ctx.fillStyle = "#e6e9ec";
+        ctx.font = "10px system-ui, sans-serif";
+        ctx.fillText(entity.type === "npc" ? entity.npcId : entity.animalId, px + 2, py + 24);
+      }
+      if (index === selectedIndex) {
+        ctx.strokeStyle = SELECT_COLOR;
+        ctx.lineWidth = 2;
+        ctx.strokeRect(px - 1.5, py - 1.5, footprint.w * TILE_SIZE + 3, footprint.h * TILE_SIZE + 3);
+      }
+    });
+  }
+
+  function cellAt(event: MouseEvent): { x: number; y: number } {
+    const rect = canvas.getBoundingClientRect();
+    return pointToTile(event.clientX - rect.left, event.clientY - rect.top);
+  }
+
+  function applyPlacement(result: SceneOpResult, placed: string): void {
+    if (result.ok) {
+      const last = options.sceneModel.def.entities[options.sceneModel.def.entities.length - 1];
+      const at = last !== undefined ? ` at (${last.x},${last.y})` : "";
+      options.onStatus(`placed ${placed}${at}`);
+    } else {
+      options.onStatus(result.reason, true);
+    }
+    renderEntityList();
+    draw();
+    updateSave();
+  }
+
+  function applyMapAction(x: number, y: number): void {
+    const model = options.mapModel;
+    const paletteBefore = model.palette.join("\u0000");
+    switch (mapTool) {
+      case "paint":
+        paint(model, x, y, mapSelectedId);
+        break;
+      case "erase":
+        erase(model, x, y);
+        break;
+      case "spawn":
+        setSpawn(model, x, y);
+        break;
+      case "walkable": {
+        const tile = model.def.layers[model.groundIndex]?.data[y]?.[x] ?? "";
+        if (tile !== "") toggleWalkable(model, tile);
+        break;
+      }
+    }
+    draw();
+    if (mapTool === "walkable" || model.palette.join("\u0000") !== paletteBefore) renderPalette();
+    updateSave();
+  }
+
+  function updateSave(): void {
+    const dirty = options.sceneModel.dirty || options.mapModel.dirty;
+    save.textContent = dirty ? "Save *" : "Save";
+    save.disabled = !dirty;
+  }
+
+  async function saveAll(): Promise<void> {
+    const saved: string[] = [];
+    if (options.sceneModel.dirty) {
+      const def = serializeScene(options.sceneModel);
+      if (def === null) {
+        options.onStatus(`${options.scenePath}: scene invalid — not saved`, true);
+        return;
+      }
+      try {
+        await window.nengine.project.writeYaml({ root: options.root, path: options.scenePath, content: stringify(def) });
+        markSceneSaved(options.sceneModel);
+        saved.push(options.scenePath);
+      } catch (err) {
+        options.onStatus(err instanceof Error ? err.message : String(err), true);
+        return;
+      }
+    }
+    if (options.mapModel.dirty) {
+      const def = serializeMap(options.mapModel);
+      if (def === null) {
+        options.onStatus(`${options.mapPath}: map invalid — not saved`, true);
+        return;
+      }
+      try {
+        await window.nengine.project.writeYaml({ root: options.root, path: options.mapPath, content: stringify(def) });
+        markMapSaved(options.mapModel);
+        saved.push(options.mapPath);
+      } catch (err) {
+        options.onStatus(err instanceof Error ? err.message : String(err), true);
+        return;
+      }
+    }
+    if (saved.length > 0) {
+      updateSave();
+      options.onStatus(`saved ${saved.join(" · ")}`);
+      options.onSaved?.();
+    }
+  }
+
+  canvas.addEventListener("mousedown", (event) => {
+    if (event.button === 2) {
+      if (mode === "map") {
+        const { x, y } = cellAt(event);
+        erase(options.mapModel, x, y);
+        draw();
+        updateSave();
+      }
+      event.preventDefault();
+      return;
+    }
+    if (event.button !== 0) return;
+    const { x, y } = cellAt(event);
+    if (mode === "map") {
+      painting = true;
+      applyMapAction(x, y);
+      return;
+    }
+    if (activePlacement !== null) {
+      if (activePlacement.type === "npc") {
+        applyPlacement(addNpc(options.sceneModel, activePlacement.id, x, y), `npc "${activePlacement.id}"`);
+      } else if (activePlacement.type === "building") {
+        applyPlacement(addBuilding(options.sceneModel, activePlacement.id, x, y), `building "${activePlacement.id}"`);
+      } else {
+        applyPlacement(addAnimal(options.sceneModel, activePlacement.id, x, y), `animal "${activePlacement.id}"`);
+      }
+      return;
+    }
+    const index = entityIndexAt(options.sceneModel, x, y);
+    if (selectedIndex !== null) {
+      if (index === -1) {
+        const entity = options.sceneModel.def.entities[selectedIndex];
+        if (entity === undefined) return;
+        const result = moveEntity(options.sceneModel, selectedIndex, x, y);
+        if (result.ok) options.onStatus(`moved ${describeEntity(entity)} to (${x},${y})`);
+        else options.onStatus(result.reason, true);
+        renderEntityList();
+        draw();
+        updateSave();
+        return;
+      }
+      if (index === selectedIndex) setSelected(null);
+      else setSelected(index);
+      return;
+    }
+    setSelected(index === -1 ? null : index);
+  });
+  canvas.addEventListener("mousemove", (event) => {
+    if (mode !== "map" || !painting) return;
+    const { x, y } = cellAt(event);
+    applyMapAction(x, y);
+  });
+  canvas.addEventListener("mouseup", () => {
+    painting = false;
+  });
+  canvas.addEventListener("mouseleave", () => {
+    painting = false;
+  });
+  canvas.addEventListener("contextmenu", (event) => event.preventDefault());
+
+  setMode("scene");
+  renderEntityList();
+  updateSave();
+
+  if (options.onFlush !== undefined) {
+    options.onFlush(() => {
+      const flushes: PlaytestFlush[] = [];
+      if (options.sceneModel.dirty) {
+        const def = serializeScene(options.sceneModel);
+        if (def === null) flushes.push({ path: options.scenePath, error: "scene invalid — not saved" });
+        else flushes.push({ path: options.scenePath, content: stringify(def) });
+      }
+      if (options.mapModel.dirty) {
+        const def = serializeMap(options.mapModel);
+        if (def === null) flushes.push({ path: options.mapPath, error: "map invalid — not saved" });
+        else flushes.push({ path: options.mapPath, content: stringify(def) });
+      }
+      return flushes;
+    });
+  }
+
+  return () => {
+    disposed = true;
+  };
+}
+
+function sectionHeader(text: string): HTMLHeadingElement {
+  const header = document.createElement("h4");
+  header.className = "scene-palette-section";
+  header.textContent = text;
+  return header;
+}
+
+function entityLabel(entity: SceneEntity): string {
+  if (entity.type === "building") return `building ${entity.buildingId}`;
+  if (entity.type === "npc") return `npc ${entity.npcId}`;
+  return `animal ${entity.animalId}`;
+}

+ 281 - 0
apps/editor/src/renderer/scene-model.ts

@@ -0,0 +1,281 @@
+import { sceneSchema } from "@nengine/core-schema";
+import type { SceneDef, SceneEntity } from "@nengine/core-schema";
+
+/**
+ * Strict-typed editor model over a SceneDef (D40.9 / E4). The model owns a
+ * deep-cloned working copy of the def; entity operations never mutate the def
+ * passed to `createSceneModel`. The model ALSO owns the map dimensions and the
+ * building footprint table so every placement decision is footprint-accurate:
+ * the engine's `seedScenes` step fails boot loudly on a placement conflict, so
+ * the editor must refuse what the engine rejects (overlap, unknown building
+ * refs). Pure logic — node-testable, no DOM.
+ */
+
+export interface SceneFootprint {
+  w: number;
+  h: number;
+}
+
+export interface SceneModelOptions {
+  mapWidth: number;
+  mapHeight: number;
+  /** buildingId → footprint, read from the project's building defs. A building
+   * whose id is missing here cannot be placed (unknown reference — the engine
+   * fails boot on it). */
+  footprints?: Record<string, SceneFootprint>;
+  /** Id for a brand-new (empty) scene created from `undefined` input. */
+  defaultId?: string;
+}
+
+export interface SceneModel {
+  /** Working copy of the scene definition (entity CRUD edits live here). */
+  def: SceneDef;
+  /** True when any entity edit is unsaved. */
+  dirty: boolean;
+  /** Map bounds placement is clamped into (the scene's map tile grid). */
+  mapWidth: number;
+  mapHeight: number;
+  /** buildingId → footprint for overlap checks; npc/animal are 1×1. */
+  footprints: Record<string, SceneFootprint>;
+}
+
+/** Result of a placement/edit operation; rejections carry a status-bar reason. */
+export type SceneOpResult = { ok: true } | { ok: false; reason: string };
+
+export const DEFAULT_SCENE_ID = "main";
+
+/** NPC marker color on the editor canvas (amber). */
+export const NPC_COLOR = "#ffb300";
+/** Animal marker color on the editor canvas (red). */
+export const ANIMAL_COLOR = "#e53935";
+/** Selected-entity outline color. */
+export const SELECT_COLOR = "#ffd54f";
+
+export function createSceneModel(input: unknown, options: SceneModelOptions): SceneModel | null {
+  const base = { mapWidth: options.mapWidth, mapHeight: options.mapHeight };
+  if (input === undefined) {
+    return { def: { id: options.defaultId ?? DEFAULT_SCENE_ID, entities: [] }, dirty: false, ...base, footprints: cloneFootprints(options.footprints) };
+  }
+  const result = sceneSchema.safeParse(input);
+  if (!result.success) return null;
+  return { def: cloneDef(result.data), dirty: false, ...base, footprints: cloneFootprints(options.footprints) };
+}
+
+/** Place a building. Coords are clamped into `[0, map - footprint]`; a
+ * footprint larger than the map, an unknown building id, a duplicate
+ * building-at-coords, or any overlap with another entity's footprint is
+ * rejected (the engine's `seedScenes` fails boot on placement conflicts). */
+export function addBuilding(model: SceneModel, buildingId: string, x: number, y: number): SceneOpResult {
+  if (buildingId === "") return fail("building id must not be empty");
+  if (!Number.isInteger(x) || !Number.isInteger(y)) return fail("coordinates must be integers");
+  const footprint = model.footprints[buildingId];
+  if (footprint === undefined) return fail(`unknown building "${buildingId}" — add a building def first`);
+  const at = clampPlacement(model, footprint.w, footprint.h, x, y);
+  if (at === null) {
+    return fail(`building "${buildingId}" (${footprint.w}×${footprint.h}) does not fit within ${model.mapWidth}×${model.mapHeight}`);
+  }
+  const duplicate = model.def.entities.some(
+    (entity) => entity.type === "building" && entity.buildingId === buildingId && entity.x === at.x && entity.y === at.y,
+  );
+  if (duplicate) return fail(`duplicate building "${buildingId}" at (${at.x},${at.y})`);
+  const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h);
+  if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
+  model.def.entities.push({ type: "building", buildingId, x: at.x, y: at.y });
+  model.dirty = true;
+  return { ok: true };
+}
+
+/** Place an NPC. `npcId` must be unique within the scene (the engine's
+ * seed-time duplicate check fails boot on a repeat); empty `dialogue`/`label`
+ * options are omitted (the schema requires min(1) strings). */
+export function addNpc(
+  model: SceneModel,
+  npcId: string,
+  x: number,
+  y: number,
+  opts?: { dialogue?: string; label?: string },
+): SceneOpResult {
+  if (npcId === "") return fail("npc id must not be empty");
+  if (!Number.isInteger(x) || !Number.isInteger(y)) return fail("coordinates must be integers");
+  const duplicate = model.def.entities.some((entity) => entity.type === "npc" && entity.npcId === npcId);
+  if (duplicate) return fail(`duplicate npc id "${npcId}"`);
+  const at = clampPlacement(model, 1, 1, x, y);
+  if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
+  const hit = overlappingEntity(model, at.x, at.y, 1, 1);
+  if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
+  const npc: SceneEntity = {
+    type: "npc",
+    npcId,
+    x: at.x,
+    y: at.y,
+    ...(opts?.dialogue !== undefined && opts.dialogue.length > 0 ? { dialogue: opts.dialogue } : {}),
+    ...(opts?.label !== undefined && opts.label.length > 0 ? { label: opts.label } : {}),
+  };
+  model.def.entities.push(npc);
+  model.dirty = true;
+  return { ok: true };
+}
+
+/** Place an animal (1×1). Runtime animal ids are auto-numbered
+ * (`animal:<id>:<n>`), so no id uniqueness applies — only clamping + overlap. */
+export function addAnimal(model: SceneModel, animalId: string, x: number, y: number): SceneOpResult {
+  if (animalId === "") return fail("animal id must not be empty");
+  if (!Number.isInteger(x) || !Number.isInteger(y)) return fail("coordinates must be integers");
+  const at = clampPlacement(model, 1, 1, x, y);
+  if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
+  const hit = overlappingEntity(model, at.x, at.y, 1, 1);
+  if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
+  model.def.entities.push({ type: "animal", animalId, x: at.x, y: at.y });
+  model.dirty = true;
+  return { ok: true };
+}
+
+/** Remove the entity at `index`. Out-of-range indices are rejected. */
+export function removeEntity(model: SceneModel, index: number): SceneOpResult {
+  if (model.def.entities[index] === undefined) return fail(`no entity at index ${index}`);
+  model.def.entities.splice(index, 1);
+  model.dirty = true;
+  return { ok: true };
+}
+
+/** Move the entity at `index` to (x, y): re-clamped into the map and
+ * re-validated against every OTHER entity's footprint. A rejected move leaves
+ * the working copy untouched. */
+export function moveEntity(model: SceneModel, index: number, x: number, y: number): SceneOpResult {
+  const entity = model.def.entities[index];
+  if (entity === undefined) return fail(`no entity at index ${index}`);
+  if (!Number.isInteger(x) || !Number.isInteger(y)) return fail("coordinates must be integers");
+  const footprint = entityFootprint(model, entity);
+  const at = clampPlacement(model, footprint.w, footprint.h, x, y);
+  if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
+  const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h, index);
+  if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
+  entity.x = at.x;
+  entity.y = at.y;
+  model.dirty = true;
+  return { ok: true };
+}
+
+/** Serialize the working copy as a fresh plain SceneDef, validated by
+ * `sceneSchema.safeParse`. Returns null when the working copy is invalid —
+ * invalid scenes must not write. */
+export function serialize(model: SceneModel): SceneDef | null {
+  const result = sceneSchema.safeParse(model.def);
+  if (!result.success) return null;
+  return cloneDef(result.data);
+}
+
+/** Clear the unsaved-changes flag (call after a successful save). */
+export function markSaved(model: SceneModel): void {
+  model.dirty = false;
+}
+
+/** Index of the topmost entity whose footprint contains (x, y), or -1. */
+export function entityIndexAt(model: SceneModel, x: number, y: number): number {
+  let found = -1;
+  for (let i = 0; i < model.def.entities.length; i++) {
+    const entity = model.def.entities[i];
+    if (entity === undefined) continue;
+    const footprint = entityFootprint(model, entity);
+    if (x >= entity.x && x < entity.x + footprint.w && y >= entity.y && y < entity.y + footprint.h) found = i;
+  }
+  return found;
+}
+
+/** The entity whose footprint contains (x, y), or undefined. */
+export function entityAt(model: SceneModel, x: number, y: number): SceneEntity | undefined {
+  const index = entityIndexAt(model, x, y);
+  return index === -1 ? undefined : model.def.entities[index];
+}
+
+/** Placement footprint of an entity: buildings are footprint-aware, npc/animal
+ * are 1×1. A building already in the scene whose id has no footprint entry
+ * (content added outside the editor) falls back to 1×1. */
+export function entityFootprint(model: SceneModel, entity: SceneEntity): SceneFootprint {
+  if (entity.type === "building") return model.footprints[entity.buildingId] ?? { w: 1, h: 1 };
+  return { w: 1, h: 1 };
+}
+
+/** Editor canvas color: buildings get a stable hash-derived hue (same id →
+ * same color), npc = amber, animal = red. Plain shapes — no asset rendering
+ * in v1 (asset-accurate rendering is playtest territory, E5). */
+export function entityColor(entity: SceneEntity): string {
+  if (entity.type === "building") return `hsl(${stableHue(entity.buildingId)} 45% 45%)`;
+  if (entity.type === "npc") return NPC_COLOR;
+  return ANIMAL_COLOR;
+}
+
+/** Human-readable entity reference for rejection reasons ("building \"shed\""). */
+export function describeEntity(entity: SceneEntity): string {
+  if (entity.type === "building") return `building "${entity.buildingId}"`;
+  if (entity.type === "npc") return `npc "${entity.npcId}"`;
+  return `animal "${entity.animalId}"`;
+}
+
+function fail(reason: string): SceneOpResult {
+  return { ok: false, reason };
+}
+
+/** Clamp (x, y) into the map so a w×h footprint fits; null when the footprint
+ * is larger than the map or the coordinates are not integers. */
+function clampPlacement(model: SceneModel, w: number, h: number, x: number, y: number): { x: number; y: number } | null {
+  if (!Number.isInteger(x) || !Number.isInteger(y)) return null;
+  const maxX = model.mapWidth - w;
+  const maxY = model.mapHeight - h;
+  if (maxX < 0 || maxY < 0) return null;
+  return { x: clampInt(x, 0, maxX), y: clampInt(y, 0, maxY) };
+}
+
+/** First entity (excluding `excludeIndex`) whose footprint intersects the
+ * rectangle at (x, y) sized w×h. */
+function overlappingEntity(
+  model: SceneModel,
+  x: number,
+  y: number,
+  w: number,
+  h: number,
+  excludeIndex?: number,
+): SceneEntity | undefined {
+  return model.def.entities.find((entity, index) => {
+    if (index === excludeIndex) return false;
+    const footprint = entityFootprint(model, entity);
+    return rectsIntersect(x, y, w, h, entity.x, entity.y, footprint.w, footprint.h);
+  });
+}
+
+function rectsIntersect(
+  ax: number,
+  ay: number,
+  aw: number,
+  ah: number,
+  bx: number,
+  by: number,
+  bw: number,
+  bh: number,
+): boolean {
+  return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
+}
+
+function cloneDef(def: SceneDef): SceneDef {
+  return { id: def.id, entities: def.entities.map((entity) => ({ ...entity })) };
+}
+
+function cloneFootprints(footprints: Record<string, SceneFootprint> | undefined): Record<string, SceneFootprint> {
+  const out: Record<string, SceneFootprint> = {};
+  if (footprints !== undefined) {
+    for (const [id, footprint] of Object.entries(footprints)) out[id] = { w: footprint.w, h: footprint.h };
+  }
+  return out;
+}
+
+function clampInt(value: number, min: number, max: number): number {
+  return Math.min(Math.max(value, min), max);
+}
+
+function stableHue(id: string): number {
+  let hash = 0;
+  for (let i = 0; i < id.length; i++) {
+    hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
+  }
+  return hash % 360;
+}

+ 20 - 0
apps/editor/src/shim/fs-promises.ts

@@ -0,0 +1,20 @@
+export interface Dirent {
+  name: string;
+  isDirectory(): boolean;
+}
+
+// Renderer shim for `node:fs/promises` (dev-app-proven): bundle mode does ZERO
+// fs calls at runtime, so every read/listing rejects like a missing file.
+export async function readFile(_path: string, _encoding?: string): Promise<string> {
+  throw new Error(`ENOENT: ${_path} (playtest is bundle-mode — no fs in the renderer)`);
+}
+
+export async function readdir(_path: string): Promise<string[]>;
+export async function readdir(_path: string, opts: { withFileTypes: true }): Promise<Dirent[]>;
+export async function readdir(_path: string, _opts?: { withFileTypes?: boolean }): Promise<string[] | Dirent[]> {
+  throw new Error(`ENOENT: ${_path} (playtest is bundle-mode — no fs in the renderer)`);
+}
+
+export async function stat(_path: string): Promise<{ isDirectory(): boolean }> {
+  throw new Error(`ENOENT: ${_path} (playtest is bundle-mode — no fs in the renderer)`);
+}

+ 13 - 0
apps/editor/src/shim/fs.ts

@@ -0,0 +1,13 @@
+export interface FSWatcher {
+  close(): void;
+}
+
+// Renderer shim for `node:fs` (dev-app-proven): bundle mode does ZERO fs calls
+// at runtime, so existsSync reports missing and watch no-ops.
+export function existsSync(_path: string): boolean {
+  return false;
+}
+
+export function watch(): FSWatcher {
+  return { close() {} };
+}

+ 49 - 0
apps/editor/src/shim/path.ts

@@ -0,0 +1,49 @@
+// Renderer shim for `node:path` (dev-app-proven): pure string path math so the
+// `@nengine/core` barrel resolves in the renderer build.
+export function isAbsolute(p: string): boolean {
+  return p.startsWith("/");
+}
+
+export function resolve(...segments: string[]): string {
+  const parts: string[] = [];
+  for (const segment of segments) {
+    if (segment.length === 0) continue;
+    if (segment.startsWith("/")) {
+      parts.length = 0;
+    }
+    parts.push(...segment.split("/"));
+  }
+  const out: string[] = [];
+  for (const part of parts) {
+    if (part === "" || part === ".") continue;
+    if (part === "..") {
+      out.pop();
+    } else {
+      out.push(part);
+    }
+  }
+  return "/" + out.join("/");
+}
+
+export function join(...segments: string[]): string {
+  return resolve(...segments);
+}
+
+export function basename(p: string, suffix?: string): string {
+  const name = p.split("/").filter((s) => s.length > 0).pop() ?? "";
+  if (suffix && name.endsWith(suffix)) {
+    return name.slice(0, -suffix.length);
+  }
+  return name;
+}
+
+export function extname(p: string): string {
+  const name = basename(p);
+  const idx = name.lastIndexOf(".");
+  return idx > 0 ? name.slice(idx) : "";
+}
+
+export function dirname(p: string): string {
+  const idx = p.lastIndexOf("/");
+  return idx > 0 ? p.slice(0, idx) : "/";
+}

+ 5 - 0
apps/editor/src/shim/url.ts

@@ -0,0 +1,5 @@
+// Renderer shim for `node:url` (dev-app-proven): bundle-mode Mods load via
+// base64 data: URLs, so pathToFileURL is only a structural stub.
+export function pathToFileURL(path: string): { href: string } {
+  return { href: `file://${path}` };
+}

+ 159 - 0
apps/editor/src/template.ts

@@ -0,0 +1,159 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { createEngine } from "@nengine/core";
+import type { EditorProjectInfo } from "./ipc.js";
+import { inspectProject } from "./project.js";
+
+const MAP_SIZE = 12;
+
+export function sanitizeId(name: string): string {
+  const slug = name
+    .trim()
+    .toLowerCase()
+    .replace(/\s+/g, "-")
+    .replace(/[^a-z0-9-]/g, "")
+    .replace(/-+/g, "-")
+    .replace(/^-|-$/g, "");
+  return slug === "" ? "farm" : slug;
+}
+
+export async function newProject(name: string, dir: string): Promise<EditorProjectInfo> {
+  const root = resolve(dir);
+  const id = sanitizeId(name);
+  const title = name.trim() === "" ? "My Farm" : name.trim();
+  for (const [rel, content] of Object.entries(templateFiles(id, title))) {
+    const target = join(root, rel);
+    await mkdir(dirname(target), { recursive: true });
+    await writeFile(target, content, "utf8");
+  }
+  await mkdir(join(root, "mods"), { recursive: true });
+  const engine = createEngine({ contentDir: join(root, "content") });
+  try {
+    await engine.start();
+  } finally {
+    await engine.stop();
+  }
+  return inspectProject(root);
+}
+
+function templateFiles(id: string, title: string): Record<string, string> {
+  return {
+    "content/time.yaml": [
+      "# Time configuration: one season, short days.",
+      "id: default",
+      "dayLengthTicks: 240",
+      "seasons:",
+      "  - { name: spring, dayCount: 14 }",
+      "start: { day: 1, season: spring }",
+      "",
+    ].join("\n"),
+    "content/weather.yaml": [
+      "# Weather probability table: spring, mostly sunny.",
+      "id: default",
+      "defaults:",
+      "  sunny: { name: Sunny }",
+      "  rain: { name: Rain }",
+      "seasons:",
+      "  - season: spring",
+      "    weathers:",
+      "      - { id: sunny, weight: 70 }",
+      "      - { id: rain, weight: 30 }",
+      "",
+    ].join("\n"),
+    "content/interact.yaml": [
+      "# Interact priority order: harvest → water → sow → till.",
+      "id: default",
+      "order:",
+      "  - harvest",
+      "  - water",
+      "  - sow",
+      "  - till",
+      "",
+    ].join("\n"),
+    [`content/maps/${id}.yaml`]: mapYaml(id, title),
+    "content/items/hoe.yaml": [
+      "# Tool: tills a soil tile so a seed can be sown.",
+      "id: hoe",
+      "name: Hoe",
+      "kind: tool",
+      "stackable: false",
+      "price: 100",
+      "use: tills a soil tile for planting",
+      "",
+    ].join("\n"),
+    [`content/items/${id}-seed.yaml`]: [
+      `# Seed: planted in tilled soil to grow a ${id}.`,
+      `id: ${id}-seed`,
+      `name: ${title} Seed`,
+      "kind: seed",
+      "stackable: true",
+      "price: 20",
+      `use: plant in tilled soil to grow ${id}`,
+      "",
+    ].join("\n"),
+    [`content/items/${id}.yaml`]: [
+      `# The harvested ${id} crop.`,
+      `id: ${id}`,
+      `name: ${title}`,
+      "kind: material",
+      "stackable: true",
+      "price: 35",
+      `use: harvested from a grown ${id} plant`,
+      "",
+    ].join("\n"),
+    [`content/crops/${id}.yaml`]: [
+      `# ${title}: 3 stages, water required through the grown stage.`,
+      `id: ${id}`,
+      `seedItem: ${id}-seed`,
+      "stages:",
+      "  - { name: sprout, days: 2, waterRequired: true }",
+      "  - { name: grown, days: 2, waterRequired: true }",
+      "  - { name: mature, days: 1, waterRequired: false }",
+      "seasons:",
+      "  - spring",
+      "drops:",
+      `  - { itemId: ${id}, qty: 1 }`,
+      "",
+    ].join("\n"),
+    [`content/scenes/${id}.yaml`]: [
+      `# Initial entity list for the ${title} project (empty to start).`,
+      `id: ${id}`,
+      "entities: []",
+      "",
+    ].join("\n"),
+  };
+}
+
+function tileAt(x: number, y: number): string {
+  if (x < 3 && y < 3) return "water";
+  if (x >= 4 && x < 8 && y >= 4 && y < 6) return "tilled";
+  return "grass";
+}
+
+function mapYaml(id: string, title: string): string {
+  const rows: string[][] = [];
+  for (let y = 0; y < MAP_SIZE; y++) {
+    const row: string[] = [];
+    for (let x = 0; x < MAP_SIZE; x++) {
+      row.push(tileAt(x, y));
+    }
+    rows.push(row);
+  }
+  const grid = rows.map((row) => `      - [${row.join(", ")}]`).join("\n");
+  return [
+    `# Minimal playable map for ${title}: grass with a pond (top-left) and a`,
+    "# pre-tilled bed ready for your first seeds.",
+    `id: ${id}`,
+    `width: ${MAP_SIZE}`,
+    `height: ${MAP_SIZE}`,
+    `tilesetRef: "${id}-tiles"`,
+    "spawn: { x: 8, y: 8 }",
+    "walkable:",
+    "  water: false",
+    "layers:",
+    "  - name: ground",
+    "    data:",
+    grid,
+    "",
+  ].join("\n");
+}

+ 7 - 0
apps/editor/test/fixtures/buildings/shed.yaml

@@ -0,0 +1,7 @@
+# Fixture building def: a 2x2 shed. Footprints drive the scene editor's
+# overlap checks (E4) exactly as PlacementSystem.place validates at seed time.
+id: shed
+name: Shed
+footprint: { w: 2, h: 2 }
+buildable: any
+blocksMovement: true

+ 6 - 0
apps/editor/test/fixtures/buildings/well.yaml

@@ -0,0 +1,6 @@
+# Fixture building def: a 1x1 well.
+id: well
+name: Well
+footprint: { w: 1, h: 1 }
+buildable: any
+blocksMovement: true

+ 36 - 0
apps/editor/test/fixtures/farm.yaml

@@ -0,0 +1,36 @@
+# Sample-game map: Pinefield Valley. 16x16 with a pond (water, top-left),
+# a pre-tilled bed (visual variety; runtime plots are world entities created
+# by till), and open grass for buildings. Tile ids use the render palette
+# (grass/tilled/water); unknown ids fall back to a neutral color. The `tiles`
+# mapping (D31) binds each tile id to an image asset (content/assets/) the
+# viewport draws when available — missing ids fall back to tileColor().
+id: farm
+width: 16
+height: 16
+tilesetRef: "farm-tiles"
+spawn: { x: 2, y: 4 }
+walkable:
+  water: false
+tiles:
+  grass: grass-1
+  water: water-1
+  tilled: tilled-1
+layers:
+  - name: ground
+    data:
+      - [water, water, water, water, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [water, water, water, water, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [water, water, water, water, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]

+ 9 - 0
apps/editor/test/fixtures/scene-farm.yaml

@@ -0,0 +1,9 @@
+# Fixture scene: a small farm yard with a shed, a dialogue-carrying mayor NPC
+# and one chicken. Loaded by scene-model.test.ts to prove the real end-to-end
+# path: file YAML -> parse -> model -> place edits -> serialize -> stringify
+# -> parse -> schema-valid.
+id: farm-yard
+entities:
+  - { type: building, buildingId: shed, x: 0, y: 0 }
+  - { type: npc, npcId: mayor, x: 4, y: 4, dialogue: intro, label: Mayor }
+  - { type: animal, animalId: chicken, x: 6, y: 6 }

+ 289 - 0
apps/editor/test/form-schema.test.ts

@@ -0,0 +1,289 @@
+import { z } from "zod";
+import {
+  animalSchema,
+  buildingSchema,
+  cropSchema,
+  dialogueSchema,
+  interactSchema,
+  itemSchema,
+  sceneSchema,
+  shopSchema,
+  soundSchema,
+  spriteSchema,
+  stateMachineSchema,
+  triggerSchema,
+  weatherSchema,
+} from "@nengine/core-schema";
+import { describe, expect, it } from "vitest";
+import { FormSchemaError, deriveFormSchema, humanizeId, refKindForField } from "../src/renderer/form-schema.js";
+import type { FormFieldSchema } from "../src/renderer/form-schema.js";
+
+function field(schema: z.ZodTypeAny, name: string): FormFieldSchema {
+  const found = deriveFormSchema(schema).fields.find((f) => f.name === name);
+  if (found === undefined) throw new Error(`no field "${name}"`);
+  return found;
+}
+
+function fieldIn(fields: FormFieldSchema[], name: string): FormFieldSchema {
+  const found = fields.find((f) => f.name === name);
+  if (found === undefined) throw new Error(`no field "${name}"`);
+  return found;
+}
+
+describe("humanizeId", () => {
+  it("splits camelCase and snake into capitalized words", () => {
+    expect(humanizeId("spriteRef")).toBe("Sprite ref");
+    expect(humanizeId("dayLengthTicks")).toBe("Day length ticks");
+    expect(humanizeId("emitRate")).toBe("Emit rate");
+    expect(humanizeId("waterRequired")).toBe("Water required");
+    expect(humanizeId("id")).toBe("Id");
+    expect(humanizeId("tick_in_day")).toBe("Tick in day");
+  });
+});
+
+describe("refKindForField", () => {
+  it("maps Id/Ref/Src suffixes to content kinds", () => {
+    expect(refKindForField("itemId")).toBe("item");
+    expect(refKindForField("spriteRef")).toBe("sprite");
+    expect(refKindForField("buildingId")).toBe("building");
+    expect(refKindForField("animalId")).toBe("animal");
+    expect(refKindForField("dialogueId")).toBe("dialogue");
+    expect(refKindForField("soundId")).toBe("sound");
+    expect(refKindForField("particleId")).toBe("particle");
+  });
+
+  it("maps exact-name table entries", () => {
+    expect(refKindForField("seedItem")).toBe("item");
+    expect(refKindForField("sound")).toBe("sound");
+    expect(refKindForField("shop")).toBe("shop");
+    expect(refKindForField("dialogue")).toBe("dialogue");
+    expect(refKindForField("src")).toBe("asset");
+    expect(refKindForField("filters")).toBe("shader");
+  });
+
+  it("leaves names without a matching kind as plain strings", () => {
+    expect(refKindForField("tilesetRef")).toBeNull();
+    expect(refKindForField("npcId")).toBeNull();
+    expect(refKindForField("id")).toBeNull();
+    expect(refKindForField("name")).toBeNull();
+    expect(refKindForField("to")).toBeNull();
+    expect(refKindForField("from")).toBeNull();
+  });
+});
+
+describe("deriveFormSchema primitives", () => {
+  it("derives an item schema", () => {
+    const form = deriveFormSchema(itemSchema);
+    const names = form.fields.map((f) => f.name);
+    expect(names).toEqual(["id", "name", "kind", "stackable", "price", "use"]);
+    expect(field(itemSchema, "id").type).toBe("string");
+    expect(field(itemSchema, "name").type).toBe("string");
+    expect(field(itemSchema, "kind").type).toBe("enum");
+    expect(field(itemSchema, "kind").enumValues).toEqual(["tool", "seed", "material", "consumable", "decoration"]);
+    expect(field(itemSchema, "stackable").type).toBe("boolean");
+    expect(field(itemSchema, "price").type).toBe("number");
+    expect(field(itemSchema, "price").optional).toBe(true);
+    expect(field(itemSchema, "price").integer).toBe(true);
+    expect(field(itemSchema, "price").numberMin).toBe(0);
+    expect(field(itemSchema, "use").optional).toBe(true);
+    expect(field(itemSchema, "use").type).toBe("string");
+  });
+
+  it("derives number min/max hints", () => {
+    expect(field(cropSchema, "id").type).toBe("string");
+    const days = fieldIn(field(cropSchema, "stages").element?.fields ?? [], "days");
+    expect(days.type).toBe("number");
+    expect(days.integer).toBe(true);
+    expect(days.numberMin).toBe(1);
+  });
+});
+
+describe("deriveFormSchema arrays and refs", () => {
+  it("derives crop stages with spriteRef and drops with itemId", () => {
+    const stages = field(cropSchema, "stages");
+    expect(stages.type).toBe("array");
+    expect(stages.element?.type).toBe("object");
+    const spriteRef = fieldIn(stages.element?.fields ?? [], "spriteRef");
+    expect(spriteRef.type).toBe("ref");
+    expect(spriteRef.refKind).toBe("sprite");
+    expect(spriteRef.optional).toBe(true);
+    expect(field(cropSchema, "seedItem").type).toBe("ref");
+    expect(field(cropSchema, "seedItem").refKind).toBe("item");
+    const drops = field(cropSchema, "drops");
+    expect(drops.type).toBe("array");
+    const itemId = fieldIn(drops.element?.fields ?? [], "itemId");
+    expect(itemId.type).toBe("ref");
+    expect(itemId.refKind).toBe("item");
+    expect(field(cropSchema, "seasons").element?.type).toBe("string");
+  });
+
+  it("derives animal stages and produces", () => {
+    const stages = field(animalSchema, "stages");
+    expect(fieldIn(stages.element?.fields ?? [], "spriteRef").refKind).toBe("sprite");
+    const produces = field(animalSchema, "produces");
+    expect(fieldIn(produces.element?.fields ?? [], "itemId").refKind).toBe("item");
+    expect(fieldIn(produces.element?.fields ?? [], "chance").optional).toBe(true);
+  });
+
+  it("derives shop lines", () => {
+    const buys = field(shopSchema, "buys");
+    expect(buys.type).toBe("array");
+    expect(fieldIn(buys.element?.fields ?? [], "itemId").type).toBe("ref");
+    expect(fieldIn(buys.element?.fields ?? [], "price").integer).toBe(true);
+    const sells = field(shopSchema, "sells");
+    expect(fieldIn(sells.element?.fields ?? [], "stock").optional).toBe(true);
+  });
+
+  it("derives sprite frames and the layer discriminated union", () => {
+    const frames = field(spriteSchema, "frames");
+    expect(frames.type).toBe("array");
+    const layers = fieldIn(frames.element?.fields ?? [], "layers");
+    expect(layers.type).toBe("array");
+    const layer = layers.element;
+    expect(layer?.type).toBe("union");
+    expect(layer?.discriminator).toBe("shape");
+    const branches = layer?.branches ?? [];
+    expect(branches.map((b) => b.id)).toEqual(["rect", "circle", "image"]);
+    const rect = branches[0];
+    expect(rect?.label).toBe("Rect");
+    const rectFields = rect?.fields ?? [];
+    expect(rectFields.map((f) => f.name)).toEqual(["x", "y", "w", "h", "r", "fill"]);
+    expect(fieldIn(rectFields, "w").optional).toBe(true);
+    const fill = fieldIn(rectFields, "fill");
+    expect(fill.pattern).toBe("^#[0-9a-fA-F]{6}$");
+    const image = branches[2];
+    const src = fieldIn(image?.fields ?? [], "src");
+    expect(src.type).toBe("ref");
+    expect(src.refKind).toBe("asset");
+    expect(field(spriteSchema, "filters").type).toBe("array");
+    expect(field(spriteSchema, "filters").element?.type).toBe("ref");
+    expect(field(spriteSchema, "filters").element?.refKind).toBe("shader");
+  });
+});
+
+describe("deriveFormSchema unions", () => {
+  it("derives statemachine transitions with a ConditionExpr union", () => {
+    const transitions = field(stateMachineSchema, "transitions");
+    expect(transitions.type).toBe("array");
+    const when = fieldIn(transitions.element?.fields ?? [], "when");
+    expect(when.type).toBe("union");
+    expect(when.optional).toBe(true);
+    const branches = when.branches ?? [];
+    expect(branches.length).toBe(5);
+    expect(branches[0]?.label).toBe("eq / gte / lte");
+    expect(branches[1]?.label).toBe("exists");
+    expect(branches.map((b) => b.label)).toContain("And");
+    expect(branches.map((b) => b.label)).toContain("Or");
+    expect(branches.map((b) => b.label)).toContain("Not");
+    const andBranch = branches.find((b) => b.label === "And");
+    expect(andBranch?.fields?.[0]?.type).toBe("array");
+  });
+
+  it("derives the schedule at union (enum + object)", () => {
+    const onSchedule = field(stateMachineSchema, "onSchedule");
+    const at = fieldIn(onSchedule.element?.fields ?? [], "at");
+    expect(at.type).toBe("union");
+    const branches = at.branches ?? [];
+    expect(branches[0]?.primitive?.type).toBe("enum");
+    expect(branches[0]?.primitive?.enumValues).toEqual(["day-start", "season-change"]);
+    expect(branches[1]?.fields?.map((f) => f.name)).toEqual(["day", "season", "tickInDay"]);
+  });
+
+  it("derives the behavior action union with implicit action discriminator", () => {
+    const fire = field(triggerSchema, "fire");
+    expect(fire.type).toBe("array");
+    const action = fire.element;
+    expect(action?.type).toBe("union");
+    expect(action?.discriminator).toBe("action");
+    const branches = action?.branches ?? [];
+    expect(branches.map((b) => b.id)).toEqual(["set-state", "add", "emit-event", "give-item", "play-sound", "dialogue"]);
+    const giveItem = branches.find((b) => b.id === "give-item");
+    expect(fieldIn(giveItem?.fields ?? [], "itemId").refKind).toBe("item");
+    const playSound = branches.find((b) => b.id === "play-sound");
+    expect(fieldIn(playSound?.fields ?? [], "sound").refKind).toBe("sound");
+  });
+
+  it("derives building buildable as a union of literals and object", () => {
+    const buildable = field(buildingSchema, "buildable");
+    expect(buildable.type).toBe("union");
+    const branches = buildable.branches ?? [];
+    expect(branches.map((b) => b.id)).toEqual(["map", "any", "branch-2"]);
+    expect(branches[0]?.primitive?.type).toBe("enum");
+    expect(branches[0]?.primitive?.enumValues).toEqual(["map"]);
+    expect(branches[1]?.primitive?.enumValues).toEqual(["any"]);
+    expect(branches[2]?.fields?.map((f) => f.name)).toEqual(["tiles"]);
+  });
+
+  it("derives dialogue nodes with choices", () => {
+    const nodes = field(dialogueSchema, "nodes");
+    expect(nodes.type).toBe("array");
+    const choices = fieldIn(nodes.element?.fields ?? [], "choices");
+    expect(choices.type).toBe("array");
+    expect(choices.optional).toBe(true);
+    expect(fieldIn(choices.element?.fields ?? [], "text").type).toBe("string");
+    expect(fieldIn(choices.element?.fields ?? [], "to").type).toBe("string");
+    expect(fieldIn(choices.element?.fields ?? [], "when").type).toBe("union");
+    expect(field(dialogueSchema, "startNode").type).toBe("string");
+  });
+});
+
+describe("deriveFormSchema scene union", () => {
+  it("derives scene entities as a discriminated union over type", () => {
+    const entities = field(sceneSchema, "entities");
+    expect(entities.type).toBe("array");
+    const entity = entities.element;
+    expect(entity?.type).toBe("union");
+    expect(entity?.discriminator).toBe("type");
+    const branches = entity?.branches ?? [];
+    expect(branches.map((b) => b.id)).toEqual(["building", "npc", "animal"]);
+    const building = branches[0];
+    expect(building?.label).toBe("Building");
+    const buildingFields = building?.fields ?? [];
+    expect(buildingFields.map((f) => f.name)).toEqual(["buildingId", "x", "y"]);
+    expect(fieldIn(buildingFields, "buildingId").type).toBe("ref");
+    expect(fieldIn(buildingFields, "buildingId").refKind).toBe("building");
+    const npc = branches[1];
+    const npcFields = npc?.fields ?? [];
+    expect(fieldIn(npcFields, "npcId").type).toBe("string");
+    expect(fieldIn(npcFields, "dialogue").type).toBe("ref");
+    expect(fieldIn(npcFields, "dialogue").refKind).toBe("dialogue");
+    const animal = branches[2];
+    expect(fieldIn(animal?.fields ?? [], "animalId").refKind).toBe("animal");
+  });
+});
+
+describe("deriveFormSchema records and single-file kinds", () => {
+  it("derives weather defaults as a record of objects", () => {
+    const defaults = field(weatherSchema, "defaults");
+    expect(defaults.type).toBe("record");
+    expect(defaults.optional).toBe(true);
+    expect(defaults.recordValue?.type).toBe("object");
+    expect(field(weatherSchema, "seasons").type).toBe("array");
+  });
+
+  it("derives interact order as an enum array", () => {
+    const order = field(interactSchema, "order");
+    expect(order.type).toBe("array");
+    expect(order.element?.type).toBe("enum");
+    expect(order.element?.enumValues).toEqual(["harvest", "water", "sow", "till"]);
+  });
+
+  it("derives sound synth and src", () => {
+    const synth = field(soundSchema, "synth");
+    expect(synth.type).toBe("object");
+    expect(synth.optional).toBe(true);
+    expect(fieldIn(synth.fields ?? [], "wave").enumValues).toEqual(["sine", "square", "triangle", "sawtooth"]);
+    const src = field(soundSchema, "src");
+    expect(src.type).toBe("ref");
+    expect(src.refKind).toBe("asset");
+  });
+});
+
+describe("deriveFormSchema error reporting", () => {
+  it("throws on unsupported zod constructs instead of mis-rendering", () => {
+    expect(() => deriveFormSchema(z.tuple([z.string(), z.number()]))).toThrow(FormSchemaError);
+    expect(() => deriveFormSchema(z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() })))).toThrow(
+      FormSchemaError,
+    );
+  });
+});

+ 224 - 0
apps/editor/test/form-value.test.ts

@@ -0,0 +1,224 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { contentSchemas } from "@nengine/core-schema";
+import { parse } from "yaml";
+import { describe, expect, it } from "vitest";
+import { deriveFormSchema } from "../src/renderer/form-schema.js";
+import type { FormFieldSchema, FormSchema } from "../src/renderer/form-schema.js";
+import {
+  applyValue,
+  coerceForValidate,
+  detectUnionBranch,
+  getPath,
+  initialItemValue,
+  initialValue,
+  setPath,
+  unionBranchValue,
+} from "../src/renderer/form-value.js";
+
+const sampleDir = fileURLToPath(new URL("../../../examples/sample-game/content", import.meta.url));
+const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
+
+async function loadYaml(rel: string): Promise<unknown> {
+  return parse(await readFile(join(sampleDir, rel), "utf8"));
+}
+
+function derive(kind: keyof typeof contentSchemas) {
+  return deriveFormSchema(contentSchemas[kind]);
+}
+
+function fieldOf(schema: FormSchema, name: string): FormFieldSchema {
+  const found = schema.fields.find((f) => f.name === name);
+  if (found === undefined) throw new Error(`no field "${name}"`);
+  return found;
+}
+
+/** derive → applyValue → coerceForValidate must be valid and JSON-identical
+ * to the original def when nothing was edited. */
+async function expectRoundtrip(kind: keyof typeof contentSchemas, rel: string): Promise<void> {
+  const def = await loadYaml(rel);
+  const schema = contentSchemas[kind];
+  const parsed = schema.safeParse(def);
+  expect(parsed.success, `${kind} fixture must validate`).toBe(true);
+  const formSchema = derive(kind);
+  const working = applyValue(formSchema, def);
+  const out = coerceForValidate(formSchema, working);
+  const validated = schema.safeParse(out);
+  expect(validated.success, `${kind} roundtrip must validate`).toBe(true);
+  expect(JSON.stringify(out)).toBe(JSON.stringify(def));
+}
+
+describe("initialValue", () => {
+  it("builds a blank item def", () => {
+    const value = initialValue(derive("item"));
+    expect(value).toEqual({ id: "", name: "", kind: "tool", stackable: false });
+  });
+
+  it("leaves optional fields absent and arrays empty", () => {
+    const value = initialValue(derive("crop"));
+    expect(value).toEqual({
+      id: "",
+      seedItem: "",
+      stages: [],
+      seasons: [],
+      drops: [],
+    });
+  });
+
+  it("picks the first enum value and the first union branch", () => {
+    expect(initialValue(derive("interact"))).toEqual({ id: "", order: [] });
+    expect(initialValue(derive("sprite"))).toEqual({ id: "", frames: [] });
+  });
+
+  it("starts a union at the first branch with the discriminator set", () => {
+    const frames = fieldOf(derive("sprite"), "frames");
+    const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers");
+    const layer = layers.element as FormFieldSchema;
+    expect(initialItemValue(layer)).toEqual({ shape: "rect", x: 0, y: 0, fill: "" });
+  });
+});
+
+describe("applyValue", () => {
+  it("preserves unknown fields losslessly", async () => {
+    const def = (await loadYaml("crops/carrot.yaml")) as Record<string, unknown>;
+    def.customField = { nested: [1, 2, 3] };
+    const working = applyValue(derive("crop"), def);
+    expect(working.customField).toEqual({ nested: [1, 2, 3] });
+    expect(working.seedItem).toBe("carrot-seed");
+    const out = coerceForValidate(derive("crop"), working);
+    expect(out.customField).toEqual({ nested: [1, 2, 3] });
+  });
+
+  it("coerces wrong-typed primitives to the schema shape", () => {
+    const working = applyValue(derive("item"), { id: 5, name: true, kind: 3, stackable: "true", price: "10" });
+    expect(working.id).toBe("5");
+    expect(working.name).toBe("true");
+    expect(working.kind).toBe(3);
+    expect(working.stackable).toBe(true);
+    expect(working.price).toBe(10);
+  });
+
+  it("keeps any-typed values as-is and fills required unions", () => {
+    const working = applyValue(derive("behavior"), {});
+    expect(working.when).toEqual({ op: "eq", path: "", value: "" });
+  });
+});
+
+describe("coerceForValidate", () => {
+  it("drops emptied optional fields but keeps required empties for validation", () => {
+    const formSchema = derive("item");
+    const value = {
+      id: "",
+      name: "hoe",
+      kind: "tool",
+      stackable: false,
+      price: "",
+      use: "",
+    };
+    const out = coerceForValidate(formSchema, value);
+    expect(out).toEqual({ id: "", name: "hoe", kind: "tool", stackable: false });
+  });
+
+  it("parses any-typed strings back to numbers and booleans", () => {
+    const fire = fieldOf(derive("behavior"), "fire");
+    const action = fire.element as FormFieldSchema;
+    const value = { action: "emit-event", event: "particle", payload: { count: "42", flag: "true", id: "sparkle" } };
+    const out = coerceForValidate({ fields: [action] }, { fire: value });
+    const payload = out.fire as { payload?: unknown } | undefined;
+    expect(payload?.payload).toEqual({ count: 42, flag: true, id: "sparkle" });
+  });
+});
+
+describe("union value model", () => {
+  it("detects ConditionExpr branches by required fields and enum values", () => {
+    const transitions = fieldOf(derive("statemachine"), "transitions");
+    const when = fieldOf({ fields: transitions.element?.fields ?? [] }, "when");
+    expect(detectUnionBranch(when, { op: "eq", path: "x", value: "y" })?.label).toBe("eq / gte / lte");
+    expect(detectUnionBranch(when, { op: "exists", path: "x" })?.label).toBe("exists");
+    expect(detectUnionBranch(when, { and: [{ op: "exists", path: "x" }] })?.label).toBe("And");
+    expect(detectUnionBranch(when, { or: [] })?.label).toBe("Or");
+    expect(detectUnionBranch(when, { not: { op: "exists", path: "x" } })?.label).toBe("Not");
+  });
+
+  it("detects discriminated union branches by the discriminator", () => {
+    const frames = fieldOf(derive("sprite"), "frames");
+    const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers");
+    const layer = layers.element as FormFieldSchema;
+    expect(detectUnionBranch(layer, { shape: "rect", x: 1, y: 2, fill: "#fff" })?.id).toBe("rect");
+    expect(detectUnionBranch(layer, { shape: "image", src: "a" })?.id).toBe("image");
+  });
+
+  it("builds a fresh branch value on switch", () => {
+    const frames = fieldOf(derive("sprite"), "frames");
+    const layers = fieldOf({ fields: frames.element?.fields ?? [] }, "layers");
+    const layer = layers.element as FormFieldSchema;
+    const image = (layer.branches ?? []).find((b) => b.id === "image");
+    if (image === undefined) throw new Error("no image branch");
+    expect(unionBranchValue(layer, image)).toEqual({ shape: "image", src: "" });
+  });
+
+  it("resolves building buildable literal branches", () => {
+    const buildable = fieldOf(derive("building"), "buildable");
+    expect(detectUnionBranch(buildable, "map")?.id).toBe("map");
+    expect(detectUnionBranch(buildable, { tiles: ["grass"] })?.id).toBe("branch-2");
+  });
+});
+
+describe("getPath/setPath", () => {
+  it("reads and writes deep paths through arrays and objects", () => {
+    const value: Record<string, unknown> = {};
+    setPath(value, ["stages", 1, "spriteRef"], "grown");
+    expect(getPath(value, ["stages", 1, "spriteRef"])).toBe("grown");
+    expect(Array.isArray(getPath(value, ["stages"]))).toBe(true);
+    setPath(value, ["stages", 0, "name"], "sprout");
+    expect(getPath(value, ["stages"])).toEqual([{ name: "sprout" }, { spriteRef: "grown" }]);
+  });
+});
+
+describe("roundtrip real content", () => {
+  it("farm map", async () => {
+    const def = parse(await readFile(join(fixturesDir, "farm.yaml"), "utf8"));
+    const schema = contentSchemas.map;
+    const formSchema = derive("map");
+    const working = applyValue(formSchema, def);
+    const out = coerceForValidate(formSchema, working);
+    expect(schema.safeParse(out).success).toBe(true);
+    expect(JSON.stringify(out)).toBe(JSON.stringify(def));
+  });
+
+  it("time", () => expectRoundtrip("time", "time.yaml"));
+  it("weather", () => expectRoundtrip("weather", "weather.yaml"));
+  it("interact", () => expectRoundtrip("interact", "interact.yaml"));
+  it("item", () => expectRoundtrip("item", "items/hoe.yaml"));
+  it("crop", () => expectRoundtrip("crop", "crops/carrot.yaml"));
+  it("animal", () => expectRoundtrip("animal", "animals/chicken.yaml"));
+  it("shop", () => expectRoundtrip("shop", "shops/general-store.yaml"));
+  it("behavior", () => expectRoundtrip("behavior", "behaviors/starter-gift.yaml"));
+  it("statemachine", () => expectRoundtrip("statemachine", "statemachines/carrot-quest.yaml"));
+  it("dialogue", () => expectRoundtrip("dialogue", "dialogues/mayor.yaml"));
+  it("building", () => expectRoundtrip("building", "buildings/shop.yaml"));
+  it("sprite with rect/circle layers", () => expectRoundtrip("sprite", "sprites/player.yaml"));
+  it("sprite with image layers", () => expectRoundtrip("sprite", "sprites/shop.yaml"));
+  it("sound", () => expectRoundtrip("sound", "sounds/coin.yaml"));
+  it("particle", () => expectRoundtrip("particle", "particles/dust.yaml"));
+  it("shader", () => expectRoundtrip("shader", "shaders/rain-tint.yaml"));
+  it("animation", () => expectRoundtrip("animation", "animations/player.yaml"));
+
+  it("scene (inline def)", () => {
+    const def = {
+      id: "main",
+      entities: [
+        { type: "building", buildingId: "shop", x: 1, y: 2 },
+        { type: "npc", npcId: "mayor", x: 3, y: 4, dialogue: "mayor", label: "Mayor" },
+        { type: "animal", animalId: "chicken", x: 5, y: 6 },
+      ],
+    };
+    const schema = contentSchemas.scene;
+    const formSchema = derive("scene");
+    const working = applyValue(formSchema, def);
+    const out = coerceForValidate(formSchema, working);
+    expect(schema.safeParse(out).success).toBe(true);
+    expect(JSON.stringify(out)).toBe(JSON.stringify(def));
+  });
+});

+ 370 - 0
apps/editor/test/map-model.test.ts

@@ -0,0 +1,370 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { mapSchema } from "@nengine/core";
+import type { MapDef } from "@nengine/core";
+import { parse, stringify } from "yaml";
+import { describe, expect, it } from "vitest";
+import {
+  DEFAULT_ERASE_TILE,
+  TILE_SIZE,
+  UNKNOWN_TILE_COLOR,
+  addTileId,
+  collectTileIds,
+  createMapModel,
+  erase,
+  isWalkableTile,
+  markSaved,
+  paint,
+  pointToTile,
+  serialize,
+  setSpawn,
+  tileColor,
+  toggleWalkable,
+} from "../src/renderer/map-model.js";
+import type { MapModel } from "../src/renderer/map-model.js";
+
+const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
+
+function grid(width: number, height: number, fill = "grass"): string[][] {
+  return Array.from({ length: height }, () => Array.from({ length: width }, () => fill));
+}
+
+function makeDef(overrides: Partial<MapDef> = {}): MapDef {
+  const def: MapDef = {
+    id: "t",
+    width: 4,
+    height: 4,
+    tilesetRef: "ts",
+    spawn: { x: 1, y: 1 },
+    layers: [{ name: "ground", data: grid(4, 4) }],
+    ...overrides,
+  };
+  return def;
+}
+
+function model(def: MapDef): MapModel {
+  const m = createMapModel(def);
+  if (m === null) throw new Error("expected a valid model");
+  return m;
+}
+
+describe("createMapModel", () => {
+  it("builds a model from a valid def without mutating the input", () => {
+    const def = makeDef();
+    const snapshot = JSON.stringify(def);
+    const m = model(def);
+    paint(m, 0, 0, "water");
+    setSpawn(m, 2, 2);
+    toggleWalkable(m, "grass");
+    erase(m, 1, 1);
+    expect(JSON.stringify(def)).toBe(snapshot);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("rejects corrupt defs", () => {
+    expect(createMapModel({ id: 42 })).toBeNull();
+    const badRows = makeDef({ layers: [{ name: "ground", data: grid(4, 2) }] });
+    expect(createMapModel(badRows)).toBeNull();
+  });
+
+  it("seeds the palette from ids on the map", () => {
+    const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "tilled") }] });
+    def.layers[0].data[0] = ["water", "grass", "tilled", "grass"];
+    const m = model(def);
+    expect(m.palette).toEqual(["water", "grass", "tilled"]);
+  });
+});
+
+describe("paint", () => {
+  it("writes ground-layer data and marks dirty", () => {
+    const m = model(makeDef());
+    expect(m.dirty).toBe(false);
+    paint(m, 2, 3, "tilled");
+    expect(m.def.layers[0].data[3][2]).toBe("tilled");
+    expect(m.dirty).toBe(true);
+  });
+
+  it("uses the layer named ground", () => {
+    const def = makeDef();
+    def.layers = [
+      { name: "base", data: grid(4, 4, "rock") },
+      { name: "ground", data: grid(4, 4) },
+    ];
+    const m = model(def);
+    paint(m, 1, 1, "water");
+    expect(m.def.layers[1].data[1][1]).toBe("water");
+    expect(m.def.layers[0].data[1][1]).toBe("rock");
+  });
+
+  it("falls back to the first layer when ground is absent", () => {
+    const def = makeDef({ layers: [{ name: "base", data: grid(4, 4) }] });
+    const m = model(def);
+    paint(m, 2, 2, "water");
+    expect(m.def.layers[0].data[2][2]).toBe("water");
+  });
+
+  it("ignores out-of-bounds cells", () => {
+    const m = model(makeDef());
+    paint(m, -1, 0, "water");
+    paint(m, 0, -1, "water");
+    paint(m, 99, 0, "water");
+    paint(m, 0, 99, "water");
+    expect(m.def.layers[0].data[0][0]).toBe("grass");
+    expect(m.dirty).toBe(false);
+  });
+
+  it("adds a new tile id to the palette", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "rock");
+    expect(m.palette).toContain("rock");
+    expect(collectTileIds(m)).toContain("rock");
+  });
+});
+
+describe("erase", () => {
+  it("restores the default grass tile when the map uses it", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "water");
+    erase(m, 0, 0);
+    expect(m.def.layers[0].data[0][0]).toBe(DEFAULT_ERASE_TILE);
+  });
+
+  it("falls back to the first palette id when grass is absent", () => {
+    const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "water") }] });
+    const m = model(def);
+    paint(m, 1, 1, "tilled");
+    erase(m, 1, 1);
+    expect(m.def.layers[0].data[1][1]).toBe("water");
+  });
+
+  it("writes an empty string when the palette is empty", () => {
+    const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "") }] });
+    const m = model(def);
+    erase(m, 2, 2);
+    expect(m.def.layers[0].data[2][2]).toBe("");
+  });
+});
+
+describe("setSpawn", () => {
+  it("moves the spawn point", () => {
+    const m = model(makeDef());
+    setSpawn(m, 3, 2);
+    expect(m.def.spawn).toEqual({ x: 3, y: 2 });
+    expect(m.dirty).toBe(true);
+  });
+
+  it("clamps out-of-bounds coordinates", () => {
+    const m = model(makeDef());
+    setSpawn(m, -5, 2);
+    expect(m.def.spawn.x).toBe(0);
+    setSpawn(m, 100, 100);
+    expect(m.def.spawn).toEqual({ x: 3, y: 3 });
+  });
+
+  it("rejects non-integer coordinates", () => {
+    const m = model(makeDef());
+    setSpawn(m, Number.NaN, 1);
+    setSpawn(m, 1.5, 1);
+    expect(m.def.spawn).toEqual({ x: 1, y: 1 });
+    expect(m.dirty).toBe(false);
+  });
+});
+
+describe("toggleWalkable", () => {
+  it("sets absent ids to false", () => {
+    const m = model(makeDef());
+    expect(m.def.walkable).toBeUndefined();
+    toggleWalkable(m, "grass");
+    expect(m.def.walkable?.grass).toBe(false);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("deletes the key when already false (restores fallback)", () => {
+    const m = model(makeDef());
+    toggleWalkable(m, "grass");
+    toggleWalkable(m, "grass");
+    expect(m.def.walkable).toBeUndefined();
+  });
+
+  it("sets true entries to false", () => {
+    const def = makeDef();
+    def.walkable = { grass: true };
+    const m = model(def);
+    toggleWalkable(m, "grass");
+    expect(m.def.walkable?.grass).toBe(false);
+  });
+
+  it("keeps unrelated entries when deleting one", () => {
+    const def = makeDef();
+    def.walkable = { water: false, tilled: false };
+    const m = model(def);
+    toggleWalkable(m, "water");
+    expect(m.def.walkable).toEqual({ tilled: false });
+  });
+
+  it("ignores the empty tile id", () => {
+    const m = model(makeDef());
+    toggleWalkable(m, "");
+    expect(m.def.walkable).toBeUndefined();
+    expect(m.dirty).toBe(false);
+  });
+});
+
+describe("addTileId", () => {
+  it("registers a palette id without touching the def", () => {
+    const m = model(makeDef());
+    const defBefore = JSON.stringify(m.def);
+    addTileId(m, "sand");
+    expect(m.palette).toContain("sand");
+    expect(JSON.stringify(m.def)).toBe(defBefore);
+    expect(m.dirty).toBe(false);
+    addTileId(m, "sand");
+    expect(m.palette.filter((id) => id === "sand")).toHaveLength(1);
+    addTileId(m, "");
+    expect(m.palette).not.toContain("");
+  });
+});
+
+describe("serialize", () => {
+  it("roundtrips model → def → model identity for the same def", () => {
+    const def = makeDef({ walkable: { water: false }, tiles: { grass: "grass-1" } });
+    const m = model(def);
+    const out = serialize(m);
+    expect(out).not.toBeNull();
+    expect(out).toEqual(def);
+    expect(out).not.toBe(m.def);
+    expect(mapSchema.safeParse(out as MapDef).success).toBe(true);
+  });
+
+  it("returns a fresh object reflecting edits", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "tilled");
+    setSpawn(m, 0, 0);
+    const out = serialize(m);
+    expect(out?.layers[0].data[0][0]).toBe("tilled");
+    expect(out?.spawn).toEqual({ x: 0, y: 0 });
+  });
+
+  it("rejects an invalid working copy", () => {
+    const m = model(makeDef());
+    m.def.layers[0].data[0].length = 99;
+    expect(serialize(m)).toBeNull();
+  });
+});
+
+describe("collectTileIds", () => {
+  it("collects unique ids from all layers in stable order", () => {
+    const def: MapDef = {
+      id: "t",
+      width: 2,
+      height: 2,
+      tilesetRef: "ts",
+      spawn: { x: 0, y: 0 },
+      layers: [
+        { name: "ground", data: [["water", "grass"], ["grass", "tilled"]] },
+        { name: "deco", data: [["rock", "grass"], ["", "rock"]] },
+      ],
+    };
+    const m = model(def);
+    expect(collectTileIds(m)).toEqual(["water", "grass", "tilled", "rock"]);
+  });
+});
+
+describe("tileColor", () => {
+  it("keeps known tiles stable and special-cased", () => {
+    expect(tileColor("grass")).toBe("#7cb342");
+    expect(tileColor("water")).toBe("#4fc3f7");
+    expect(tileColor("tilled")).toBe("#8d6e63");
+    expect(tileColor("")).toBe(UNKNOWN_TILE_COLOR);
+  });
+
+  it("is stable for unknown ids and format is a hue", () => {
+    expect(tileColor("rock")).toBe(tileColor("rock"));
+    expect(tileColor("rock")).toMatch(/^hsl\(\d+ 55% 55%\)$/);
+    expect(tileColor("rock")).not.toBe(tileColor("sand"));
+  });
+});
+
+describe("isWalkableTile", () => {
+  it("honours declared walkable entries", () => {
+    const def = makeDef();
+    def.walkable = { water: false, tilled: true };
+    const m = model(def);
+    expect(isWalkableTile(m, "water")).toBe(false);
+    expect(isWalkableTile(m, "tilled")).toBe(true);
+  });
+
+  it("falls back to the engine's UNWALKABLE_TILES", () => {
+    const m = model(makeDef());
+    expect(isWalkableTile(m, "water")).toBe(false);
+    expect(isWalkableTile(m, "grass")).toBe(true);
+    expect(isWalkableTile(m, "rock")).toBe(true);
+  });
+});
+
+describe("pointToTile", () => {
+  it("maps pixel offsets to grid tiles", () => {
+    expect(pointToTile(0, 0)).toEqual({ x: 0, y: 0 });
+    expect(pointToTile(31, 31)).toEqual({ x: 0, y: 0 });
+    expect(pointToTile(32, 64)).toEqual({ x: 1, y: 2 });
+    expect(pointToTile(-5, 3)).toEqual({ x: -1, y: 0 });
+    expect(TILE_SIZE).toBe(32);
+  });
+});
+
+describe("dirty lifecycle", () => {
+  it("tracks edits and clears on markSaved", () => {
+    const m = model(makeDef());
+    expect(m.dirty).toBe(false);
+    paint(m, 0, 0, "water");
+    expect(m.dirty).toBe(true);
+    markSaved(m);
+    expect(m.dirty).toBe(false);
+    erase(m, 0, 0);
+    expect(m.dirty).toBe(true);
+    markSaved(m);
+    setSpawn(m, 2, 2);
+    expect(m.dirty).toBe(true);
+    markSaved(m);
+    toggleWalkable(m, "grass");
+    expect(m.dirty).toBe(true);
+    markSaved(m);
+    addTileId(m, "sand");
+    expect(m.dirty).toBe(false);
+  });
+});
+
+describe("real map fixture end-to-end", () => {
+  it("edits a real farm.yaml through parse → model → serialize → stringify → parse", async () => {
+    const text = await readFile(join(fixturesDir, "farm.yaml"), "utf8");
+    const m = createMapModel(parse(text));
+    expect(m).not.toBeNull();
+    if (m === null) throw new Error("fixture must be a valid map");
+    expect(mapSchema.safeParse(m.def).success).toBe(true);
+    expect(m.def.width).toBe(16);
+    expect(m.def.spawn).toEqual({ x: 2, y: 4 });
+
+    paint(m, 0, 0, "grass");
+    setSpawn(m, 4, 4);
+    toggleWalkable(m, "tilled");
+
+    const out = serialize(m);
+    expect(out).not.toBeNull();
+    if (out === null) throw new Error("edited fixture must serialize");
+
+    const yaml = stringify(out);
+    const reparsed = parse(yaml);
+    expect(reparsed).toEqual(out);
+    expect(mapSchema.safeParse(reparsed).success).toBe(true);
+
+    const m2 = createMapModel(reparsed);
+    expect(m2).not.toBeNull();
+    expect(m2?.def.layers[0].data[0][0]).toBe("grass");
+    expect(m2?.def.spawn).toEqual({ x: 4, y: 4 });
+    expect(m2?.def.walkable).toEqual({ water: false, tilled: false });
+    expect(m2?.def.tiles?.water).toBe("water-1");
+
+    expect(await readFile(join(fixturesDir, "farm.yaml"), "utf8")).toBe(text);
+  });
+});

+ 272 - 0
apps/editor/test/mcp-server.test.ts

@@ -0,0 +1,272 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine } from "@nengine/core";
+import type { Engine, TimeSystem } from "@nengine/core";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+import { syncContentWrite } from "../src/edit-engine.js";
+import { createEditorMcpHttpServer, resolveEditorMcpDirs } from "../src/mcp-server.js";
+import type { EditorMcpHttpHandle } from "../src/mcp-server.js";
+
+const TIME_YAML = [
+  "id: default",
+  "dayLengthTicks: 480",
+  "seasons:",
+  "  - { name: spring, dayCount: 28 }",
+  "start: { day: 1, season: spring }",
+  "",
+].join("\n");
+
+const FARM_YAML = [
+  "id: farm",
+  "width: 2",
+  "height: 2",
+  'tilesetRef: "tiles"',
+  "spawn: { x: 0, y: 0 }",
+  "layers:",
+  "  - name: ground",
+  "    data:",
+  "      - [a, b]",
+  "      - [b, a]",
+  "",
+].join("\n");
+
+const HOE_DEF = { id: "hoe", name: "Hoe", kind: "tool", stackable: false };
+const CARROT_YAML = ["id: carrot", "name: Carrot", "kind: material", "stackable: true", ""].join("\n");
+
+interface Harness {
+  tmp: string;
+  engine: Engine;
+  http: EditorMcpHttpHandle;
+  client: Client;
+  close: () => Promise<void>;
+}
+
+async function setup(): Promise<Harness> {
+  const tmp = await mkdtemp(join(tmpdir(), "editor-mcp-"));
+  const content = join(tmp, "content");
+  await mkdir(join(content, "maps"), { recursive: true });
+  await writeFile(join(content, "time.yaml"), TIME_YAML);
+  await writeFile(join(content, "maps", "farm.yaml"), FARM_YAML);
+  const http = await createEditorMcpHttpServer();
+  const engine = createEngine({ contentDir: content });
+  await engine.start();
+  await http.attach(engine);
+  const client = new Client({ name: "editor-mcp-http-test", version: "0.0.1" });
+  await client.connect(new StreamableHTTPClientTransport(new URL(http.url)));
+  return {
+    tmp,
+    engine,
+    http,
+    client,
+    close: async () => {
+      await client.close();
+      await http.close();
+      await engine.stop();
+      await rm(tmp, { recursive: true, force: true });
+    },
+  };
+}
+
+function textOf(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.find((block) => block.type === "text")?.text ?? "";
+}
+
+function jsonOf(text: string): Record<string, unknown> {
+  return JSON.parse(text) as Record<string, unknown>;
+}
+
+describe("editor embedded MCP server (streamable HTTP over the editing engine)", () => {
+  it("starts with a /mcp URL and reports running=false before any engine is attached", async () => {
+    const http = await createEditorMcpHttpServer();
+    try {
+      expect(http.running).toBe(false);
+      expect(http.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
+      const res = await fetch(http.url);
+      expect(res.status).toBe(503);
+    } finally {
+      await http.close();
+    }
+  });
+
+  it("attach makes running true and serves the handshake with the editor server identity", async () => {
+    const { http, client, close } = await setup();
+    try {
+      expect(http.running).toBe(true);
+      expect(client.getServerVersion()).toMatchObject({ name: "nengine-editor-mcp", version: "0.1.0" });
+    } finally {
+      await close();
+    }
+  });
+
+  it("listTools over real HTTP returns the full 124-tool surface", async () => {
+    const { client, close } = await setup();
+    try {
+      const tools = await client.listTools();
+      expect(tools.tools).toHaveLength(124);
+      for (const tool of tools.tools) {
+        expect(tool.inputSchema).toMatchObject({ type: "object" });
+      }
+      const names = tools.tools.map((tool) => tool.name);
+      for (const expected of ["item.create", "map.get", "time.advance", "server.info", "scene.validate", "npc.path"]) {
+        expect(names).toContain(expected);
+      }
+    } finally {
+      await close();
+    }
+  });
+
+  it("listResources/listPrompts over HTTP expose the 36 resources and 17 prompts", async () => {
+    const { client, close } = await setup();
+    try {
+      const resources = await client.listResources();
+      expect(resources.resources).toHaveLength(36);
+      const prompts = await client.listPrompts();
+      expect(prompts.prompts).toHaveLength(17);
+    } finally {
+      await close();
+    }
+  });
+
+  it("item.create over HTTP writes content/items/<id>.yaml AND registers live in the shared registry", async () => {
+    const { engine, tmp, client, close } = await setup();
+    try {
+      const result = await client.callTool({ name: "item.create", arguments: { def: HOE_DEF } });
+      expect(result.isError).not.toBe(true);
+      expect(jsonOf(textOf(result))).toMatchObject(HOE_DEF);
+
+      const file = await readFile(join(tmp, "content/items/hoe.yaml"), "utf8");
+      expect(file).toContain("id: hoe");
+      expect(engine.registry.get("item", "hoe")).toMatchObject(HOE_DEF);
+
+      const listed = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} })));
+      expect((listed.items as { id: string }[]).map((item) => item.id)).toContain("hoe");
+    } finally {
+      await close();
+    }
+  });
+
+  it("time.advance over HTTP mutates the instance's own world", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const advanced = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } })));
+      expect(advanced).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+      const time = engine.systems.get<TimeSystem>("time");
+      expect(time?.get()).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+      const viaTool = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} })));
+      expect(viaTool).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("dual-channel GUI→MCP: a file the GUI writes is visible to MCP after syncContentWrite", async () => {
+    const { engine, tmp, client, close } = await setup();
+    try {
+      await mkdir(join(tmp, "content/items"), { recursive: true });
+      await writeFile(join(tmp, "content/items/carrot.yaml"), CARROT_YAML);
+      await syncContentWrite(engine, tmp, "content/items/carrot.yaml");
+
+      const listed = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} })));
+      const ids = (listed.items as { id: string }[]).map((item) => item.id);
+      expect(ids).toContain("carrot");
+      expect(engine.registry.get("item", "carrot")).toMatchObject({ id: "carrot", kind: "material" });
+    } finally {
+      await close();
+    }
+  });
+
+  it("dual-channel: an invalid GUI-written file leaves the registry untouched", async () => {
+    const { engine, tmp, close } = await setup();
+    try {
+      await mkdir(join(tmp, "content/items"), { recursive: true });
+      await writeFile(join(tmp, "content/items/broken.yaml"), "id: broken\nname: [unclosed\n", "utf8");
+      await syncContentWrite(engine, tmp, "content/items/broken.yaml");
+      expect(engine.registry.get("item", "broken")).toBeUndefined();
+    } finally {
+      await close();
+    }
+  });
+
+  it("project switch: detach/attach keeps the same URL and serves the new engine's content", async () => {
+    const { engine, http, client, close } = await setup();
+    const tmp2 = await mkdtemp(join(tmpdir(), "editor-mcp2-"));
+    try {
+      const content2 = join(tmp2, "content");
+      await mkdir(join(content2, "maps"), { recursive: true });
+      await writeFile(join(content2, "time.yaml"), TIME_YAML);
+      await writeFile(join(content2, "maps", "farm.yaml"), FARM_YAML);
+      const engine2 = createEngine({ contentDir: content2 });
+      await engine2.start();
+
+      const urlBefore = http.url;
+      await http.detach();
+      expect(http.running).toBe(false);
+      await http.attach(engine2);
+      expect(http.running).toBe(true);
+      expect(http.url).toBe(urlBefore);
+
+      const client2 = new Client({ name: "editor-mcp-http-test-2", version: "0.0.1" });
+      await client2.connect(new StreamableHTTPClientTransport(new URL(http.url)));
+      try {
+        const created = await client2.callTool({ name: "item.create", arguments: { def: HOE_DEF } });
+        expect(created.isError).not.toBe(true);
+        expect(engine2.registry.get("item", "hoe")).toMatchObject(HOE_DEF);
+        expect(engine.registry.get("item", "hoe")).toBeUndefined();
+      } finally {
+        await client2.close();
+        await engine2.stop();
+      }
+    } finally {
+      await close();
+      await rm(tmp2, { recursive: true, force: true });
+    }
+  });
+
+  it("error paths over HTTP: unknown tool and missing required args", async () => {
+    const { client, close } = await setup();
+    try {
+      const unknown = await client.callTool({ name: "nope.nothing", arguments: {} });
+      expect(unknown.isError).toBe(true);
+      expect(textOf(unknown)).toMatch(/tool not found: nope.nothing/);
+
+      const missing = await client.callTool({ name: "time.advance", arguments: {} });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/missing required arguments: ticks/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("non-/mcp paths 404 and the OPTIONS preflight is answered", async () => {
+    const http = await createEditorMcpHttpServer();
+    try {
+      const { origin } = new URL(http.url);
+      const res = await fetch(`${origin}/other`);
+      expect(res.status).toBe(404);
+
+      const preflight = await fetch(http.url, {
+        method: "OPTIONS",
+        headers: { Origin: "http://localhost", "Access-Control-Request-Method": "POST" },
+      });
+      expect(preflight.status).toBe(204);
+      expect(preflight.headers.get("access-control-allow-origin")).toBe("*");
+    } finally {
+      await http.close();
+    }
+  });
+
+  it("resolveEditorMcpDirs: --dir maps content/<dir>/content and mods/<dir>/mods; no --dir errors", () => {
+    const viaDir = resolveEditorMcpDirs({ argv: ["--dir", "/proj"], cwd: "/x" });
+    expect(viaDir.contentDir).toBe("/proj/content");
+    expect(viaDir.modsDir).toBeUndefined();
+
+    const viaContent = resolveEditorMcpDirs({ argv: ["--content-dir", "content", "--mods-dir", "mods"], cwd: "/proj" });
+    expect(viaContent.contentDir).toBe("/proj/content");
+    expect(viaContent.modsDir).toBe("/proj/mods");
+
+    expect(() => resolveEditorMcpDirs({ argv: [], cwd: "/x" })).toThrow(/--mcp-stdio requires --dir/);
+  });
+});

+ 68 - 0
apps/editor/test/mcp-stdio.test.ts

@@ -0,0 +1,68 @@
+import { existsSync } from "node:fs";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+import { MCP_SERVER_NAME } from "../src/mcp-server.js";
+import { newProject } from "../src/template.js";
+
+const entry = resolve(process.cwd(), "dist/main/mcp-stdio.js");
+
+function textOf(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.find((block) => block.type === "text")?.text ?? "";
+}
+
+describe("editor --mcp-stdio headless mode (spawned entry over stdio)", () => {
+  it("spawns a drop-in stdio MCP server for --dir: handshake + full surface + a tool call", async () => {
+    expect(existsSync(entry)).toBe(true);
+    const dir = await mkdtemp(join(tmpdir(), "editor-stdio-"));
+    try {
+      const project = await newProject("Stdio Farm", dir);
+      const client = new Client({ name: "editor-stdio-test", version: "0.0.1" });
+      const transport = new StdioClientTransport({ command: "node", args: [entry, "--dir", project.root] });
+      await client.connect(transport);
+      try {
+        expect(client.getServerVersion()).toMatchObject({ name: MCP_SERVER_NAME, version: "0.1.0" });
+        const tools = await client.listTools();
+        expect(tools.tools).toHaveLength(124);
+        const info = JSON.parse(textOf(await client.callTool({ name: "server.info", arguments: {} }))) as {
+          engine: string;
+          content: { map: string[]; time: string[]; item: string[] };
+        };
+        expect(info.engine).toBe("nengine");
+        expect(info.content.map).toEqual(["stdio-farm"]);
+        expect(info.content.item.sort()).toEqual(["hoe", "stdio-farm", "stdio-farm-seed"]);
+      } finally {
+        await client.close();
+      }
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("item.create over stdio writes content into the project's content dir", async () => {
+    expect(existsSync(entry)).toBe(true);
+    const dir = await mkdtemp(join(tmpdir(), "editor-stdio-"));
+    try {
+      const project = await newProject("Stdio Farm", dir);
+      const client = new Client({ name: "editor-stdio-test", version: "0.0.1" });
+      const transport = new StdioClientTransport({ command: "node", args: [entry, "--dir", project.root] });
+      await client.connect(transport);
+      try {
+        const created = await client.callTool({
+          name: "item.create",
+          arguments: { def: { id: "sickle", name: "Sickle", kind: "tool", stackable: false } },
+        });
+        expect(created.isError).not.toBe(true);
+        const file = resolve(project.root, "content/items/sickle.yaml");
+        expect(existsSync(file)).toBe(true);
+      } finally {
+        await client.close();
+      }
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});

+ 130 - 0
apps/editor/test/pack-options.test.ts

@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import {
+  DEFAULT_PACK_SUBDIR,
+  dialogDefaultsFromProject,
+  packHints,
+  projectYamlFromPack,
+  resolvePackRequest,
+} from "../src/pack-options.js";
+
+describe("dialogDefaultsFromProject", () => {
+  it("pre-fills the dialog from project.yaml pack fields", () => {
+    const options = dialogDefaultsFromProject(
+      { name: "My Farm", icon: "icon.png", pack: { name: "Pinefield Valley", installer: true, mods: true, splitAssets: true } },
+      "/proj",
+      "fallback",
+    );
+    expect(options.name).toBe("Pinefield Valley");
+    expect(options.icon).toBe("icon.png");
+    expect(options.desktop).toBe(true);
+    expect(options.installer).toBe(true);
+    expect(options.mods).toBe(true);
+    expect(options.splitAssets).toBe(true);
+    expect(options.outDir).toBe(`/proj/${DEFAULT_PACK_SUBDIR}`);
+  });
+
+  it("falls back to the top-level name then the project display name with defaults", () => {
+    expect(dialogDefaultsFromProject({ name: "My Farm" }, "/proj", "fallback").name).toBe("My Farm");
+    const none = dialogDefaultsFromProject(null, "/proj", "fallback");
+    expect(none.name).toBe("fallback");
+    expect(none.icon).toBe("");
+    expect(none.desktop).toBe(false);
+    expect(none.installer).toBe(false);
+    expect(none.mods).toBe(false);
+    expect(none.splitAssets).toBe(false);
+  });
+
+  it("defaults desktop from pack.installer (installer implies desktop; desktop is not persisted)", () => {
+    const options = dialogDefaultsFromProject({ pack: { installer: true } }, "/proj", "x");
+    expect(options.desktop).toBe(true);
+    expect(options.installer).toBe(true);
+  });
+});
+
+describe("projectYamlFromPack", () => {
+  it("writes pack fields + icon and preserves the prev display name", () => {
+    const data = projectYamlFromPack(
+      { name: "Pinefield Valley", icon: "icon.png", desktop: true, installer: true, mods: false, splitAssets: true, outDir: "/proj/dist-pack" },
+      { name: "My Farm" },
+    );
+    expect(data).toEqual({
+      name: "My Farm",
+      icon: "icon.png",
+      pack: { name: "Pinefield Valley", installer: true, mods: false, splitAssets: true },
+    });
+  });
+
+  it("removes empty name/icon and keeps desktop out of project.yaml", () => {
+    const data = projectYamlFromPack(
+      { name: "", icon: "", desktop: true, installer: false, mods: false, splitAssets: false, outDir: "" },
+      { name: "My Farm", pack: { name: "Old", mods: true } },
+    );
+    expect(data.pack?.name).toBeUndefined();
+    expect(data.pack?.installer).toBe(false);
+    expect(data.pack?.mods).toBe(false);
+    expect(data.pack?.splitAssets).toBe(false);
+    expect(data.icon).toBeUndefined();
+    expect(data.name).toBe("My Farm");
+  });
+});
+
+describe("resolvePackRequest", () => {
+  it("maps dialog options to a pack request (content/outDir, installer implies desktop)", () => {
+    const request = resolvePackRequest(
+      "/proj",
+      { name: "Pinefield Valley", icon: "", desktop: false, installer: true, mods: true, splitAssets: false, outDir: "" },
+      true,
+    );
+    expect(request).toEqual({
+      contentDir: "/proj/content",
+      outDir: `/proj/${DEFAULT_PACK_SUBDIR}`,
+      desktop: true,
+      installer: true,
+      splitAssets: false,
+      modsDir: "/proj/mods",
+      name: "Pinefield Valley",
+    });
+  });
+
+  it("honors a custom outDir, plain desktop, and omits mods when off or the dir is absent", () => {
+    const custom = resolvePackRequest(
+      "/proj",
+      { name: "", icon: "", desktop: true, installer: false, mods: false, splitAssets: true, outDir: "/tmp/out" },
+      true,
+    );
+    expect(custom.outDir).toBe("/tmp/out");
+    expect(custom.splitAssets).toBe(true);
+    expect(custom.desktop).toBe(true);
+    expect(custom.name).toBeUndefined();
+    expect(custom.modsDir).toBeUndefined();
+
+    const noModsDir = resolvePackRequest(
+      "/proj",
+      { name: "", icon: "", desktop: false, installer: false, mods: true, splitAssets: false, outDir: "" },
+      false,
+    );
+    expect(noModsDir.modsDir).toBeUndefined();
+  });
+});
+
+describe("packHints", () => {
+  it("includes desktop and installer commands when those modes are on", () => {
+    const hints = packHints({
+      defCount: 1,
+      kinds: 1,
+      assetCount: 0,
+      modCount: 0,
+      desktop: true,
+      installer: true,
+    });
+    const text = hints.join("\n");
+    expect(text).toContain("npm install && npm start");
+    expect(text).toContain("npm install && npm run dist");
+  });
+
+  it("is site-only without desktop/installer", () => {
+    const hints = packHints({ defCount: 1, kinds: 1, assetCount: 0, modCount: 0, desktop: false, installer: false });
+    expect(hints).toHaveLength(1);
+    expect(hints[0]).toContain("serve the output folder");
+  });
+});

+ 146 - 0
apps/editor/test/pack.test.ts

@@ -0,0 +1,146 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { runEditorPack } from "../src/pack.js";
+
+const FIXTURE_FILES: Record<string, string> = {
+  "content/time.yaml": [
+    "id: default",
+    "dayLengthTicks: 240",
+    "seasons:",
+    "  - { name: spring, dayCount: 14 }",
+    "start: { day: 1, season: spring }",
+    "",
+  ].join("\n"),
+  "content/maps/farm.yaml": [
+    "id: farm",
+    "width: 12",
+    "height: 12",
+    "tilesetRef: farm-tiles",
+    "spawn: { x: 2, y: 2 }",
+    "walkable:",
+    "  water: false",
+    "layers:",
+    "  - name: ground",
+    "    data:",
+    ...mapRows(),
+    "",
+  ].join("\n"),
+  "content/items/hoe.yaml": [
+    "id: hoe",
+    "name: Hoe",
+    "kind: tool",
+    "stackable: false",
+    "price: 100",
+    "use: tills a soil tile",
+    "",
+  ].join("\n"),
+};
+
+function mapRows(): string[] {
+  const rows: string[] = [];
+  for (let y = 0; y < 12; y++) {
+    const row: string[] = [];
+    for (let x = 0; x < 12; x++) {
+      row.push(x < 2 && y < 2 ? "water" : "grass");
+    }
+    rows.push(`      - [${row.join(", ")}]`);
+  }
+  return rows;
+}
+
+async function fixtureDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), "editor-pack-"));
+  for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
+    const target = join(dir, rel);
+    await mkdir(dirname(target), { recursive: true });
+    await writeFile(target, content, "utf8");
+  }
+  return dir;
+}
+
+async function rmRecursive(dir: string): Promise<void> {
+  await rm(dir, { recursive: true, force: true });
+}
+
+function options(overrides: Partial<{ desktop: boolean; installer: boolean; mods: boolean; splitAssets: boolean }> = {}): Parameters<typeof runEditorPack>[1] {
+  return {
+    name: "Pinefield Valley",
+    icon: "",
+    desktop: overrides.desktop ?? false,
+    installer: overrides.installer ?? false,
+    mods: overrides.mods ?? false,
+    splitAssets: overrides.splitAssets ?? false,
+    outDir: "",
+  };
+}
+
+describe("runEditorPack (E7 package dialog pipeline)", () => {
+  it("packs a minimal fixture project into a static site + bundle.json", async () => {
+    const dir = await fixtureDir();
+    try {
+      const result = await runEditorPack(dir, options(), existsSync(join(dir, "mods")));
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+      expect(result.outDir).toBe(resolve(dir, "dist-pack"));
+      expect(existsSync(join(result.outDir, "bundle.json"))).toBe(true);
+      expect(existsSync(join(result.outDir, "index.html"))).toBe(true);
+      expect(existsSync(join(result.outDir, "assets"))).toBe(true);
+      expect(result.stats.kinds).toBeGreaterThan(0);
+      expect(result.stats.defCount).toBeGreaterThan(0);
+      expect(result.stats.desktop).toBe(false);
+      expect(result.stats.installer).toBe(false);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("desktop mode writes the Electron shell (main.cjs + package.json)", async () => {
+    const dir = await fixtureDir();
+    try {
+      const result = await runEditorPack(dir, options({ desktop: true }), existsSync(join(dir, "mods")));
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+      expect(result.stats.desktop).toBe(true);
+      expect(existsSync(join(result.outDir, "desktop", "main.cjs"))).toBe(true);
+      expect(existsSync(join(result.outDir, "desktop", "package.json"))).toBe(true);
+      expect(existsSync(join(result.outDir, "desktop", "README-desktop.md"))).toBe(true);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("installer mode writes electron-builder.yml and copies the site into desktop/", async () => {
+    const dir = await fixtureDir();
+    try {
+      const result = await runEditorPack(dir, options({ installer: true }), existsSync(join(dir, "mods")));
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+      expect(result.stats.installer).toBe(true);
+      expect(result.stats.desktop).toBe(true);
+      expect(existsSync(join(result.outDir, "desktop", "electron-builder.yml"))).toBe(true);
+      expect(existsSync(join(result.outDir, "desktop", "index.html"))).toBe(true);
+      expect(existsSync(join(result.outDir, "desktop", "bundle.json"))).toBe(true);
+      expect(existsSync(join(result.outDir, "desktop", "assets"))).toBe(true);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("surfaces a content-compile failure as {ok: false, error} with the per-def message", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "editor-pack-bad-"));
+    try {
+      await mkdir(join(dir, "content", "maps"), { recursive: true });
+      await writeFile(join(dir, "content", "maps", "bad.yaml"), ["id: bad", "width: nope", "height: 2", ""].join("\n"));
+      const result = await runEditorPack(dir, options(), false);
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+      expect(result.error).toContain("validation failed");
+      expect(result.error).toContain("map/bad");
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+});

+ 291 - 0
apps/editor/test/playtest-boot.test.ts

@@ -0,0 +1,291 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine } from "@nengine/core";
+import type { ContentBundle } from "@nengine/core";
+import { compileBundle } from "@nengine/pack";
+import { createPlaytestSession, saveAllFlushes } from "../src/renderer/playtest-session.js";
+
+const MOD_ID = "playtest-mod";
+const MOD_CODE = [
+  "export default {",
+  "  manifest: { id: \"playtest-mod\", version: \"0.1.0\" },",
+  "  hooks: {",
+  "    onDayStart(ctx) {",
+  "      const { world } = ctx;",
+  "      let stats = world.get(\"mod:playtest-stats\");",
+  "      if (!stats) {",
+  "        stats = world.create({ id: \"mod:playtest-stats\", type: \"mod-state\", days: 0 });",
+  "      }",
+  "      stats.days += 1;",
+  "    },",
+  "  },",
+  "};",
+  "",
+].join("\n");
+
+const FIXTURE_FILES: Record<string, string> = {
+  "content/time.yaml": [
+    "id: default",
+    "dayLengthTicks: 480",
+    "seasons:",
+    "  - { name: spring, dayCount: 14 }",
+    "start: { day: 1, season: spring }",
+    "",
+  ].join("\n"),
+  "content/weather.yaml": [
+    "id: default",
+    "defaults:",
+    "  sunny: { name: Sunny }",
+    "seasons:",
+    "  - season: spring",
+    "    weathers:",
+    "      - { id: sunny, weight: 100 }",
+    "",
+  ].join("\n"),
+  "content/interact.yaml": [
+    "id: default",
+    "order:",
+    "  - harvest",
+    "  - water",
+    "  - sow",
+    "  - till",
+    "",
+  ].join("\n"),
+  "content/maps/farm.yaml": [
+    "# Playtest fixture map: grass with a water pond; spawn at (2,2).",
+    "id: farm",
+    "width: 12",
+    "height: 12",
+    "tilesetRef: farm-tiles",
+    "spawn: { x: 2, y: 2 }",
+    "walkable:",
+    "  water: false",
+    "layers:",
+    "  - name: ground",
+    "    data:",
+    mapRows(),
+    "",
+  ].join("\n"),
+  "content/items/hoe.yaml": [
+    "id: hoe",
+    "name: Hoe",
+    "kind: tool",
+    "stackable: false",
+    "price: 100",
+    "use: tills a soil tile",
+    "",
+  ].join("\n"),
+  "content/items/carrot-seed.yaml": [
+    "id: carrot-seed",
+    "name: Carrot Seed",
+    "kind: seed",
+    "stackable: true",
+    "price: 20",
+    "use: plant in tilled soil",
+    "",
+  ].join("\n"),
+  "content/items/carrot.yaml": [
+    "id: carrot",
+    "name: Carrot",
+    "kind: material",
+    "stackable: true",
+    "price: 35",
+    "use: harvested from a carrot plant",
+    "",
+  ].join("\n"),
+  "content/items/egg.yaml": [
+    "id: egg",
+    "name: Egg",
+    "kind: material",
+    "stackable: true",
+    "price: 25",
+    "use: laid by a chicken",
+    "",
+  ].join("\n"),
+  "content/crops/carrot.yaml": [
+    "id: carrot",
+    "seedItem: carrot-seed",
+    "stages:",
+    "  - { name: sprout, days: 1, waterRequired: true }",
+    "  - { name: mature, days: 1, waterRequired: false }",
+    "seasons:",
+    "  - spring",
+    "drops:",
+    "  - { itemId: carrot, qty: 1 }",
+    "",
+  ].join("\n"),
+  "content/buildings/shed.yaml": [
+    "id: shed",
+    "name: Shed",
+    "footprint: { w: 2, h: 2 }",
+    "buildable: any",
+    "blocksMovement: true",
+    "",
+  ].join("\n"),
+  "content/animals/chicken.yaml": [
+    "id: chicken",
+    "stages:",
+    "  - { name: chick, days: 1, feedRequired: true }",
+    "produces:",
+    "  - { itemId: egg, qty: 1 }",
+    "",
+  ].join("\n"),
+  "content/scenes/farm.yaml": [
+    "id: farm",
+    "entities:",
+    "  - { type: building, buildingId: shed, x: 4, y: 4 }",
+    "  - { type: npc, npcId: mayor, x: 6, y: 2, label: Mayor }",
+    "  - { type: animal, animalId: chicken, x: 8, y: 8 }",
+    "",
+  ].join("\n"),
+  "mods/playtest-mod/index.mjs": MOD_CODE,
+};
+
+/** A 12×12 map: a 2×2 water pond at the top-left, grass elsewhere. */
+function mapRows(): string {
+  const rows: string[] = [];
+  for (let y = 0; y < 12; y++) {
+    const row: string[] = [];
+    for (let x = 0; x < 12; x++) {
+      row.push(x < 2 && y < 2 ? "water" : "grass");
+    }
+    rows.push(`      - [${row.join(", ")}]`);
+  }
+  return rows.join("\n");
+}
+
+async function fixtureDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), "editor-playtest-"));
+  for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
+    const target = join(dir, rel);
+    await mkdir(dirname(target), { recursive: true });
+    await writeFile(target, content, "utf8");
+  }
+  return dir;
+}
+
+async function rmRecursive(dir: string): Promise<void> {
+  await rm(dir, { recursive: true, force: true });
+}
+
+describe("playtest bundle boot (E5 fixture)", () => {
+  it("compiles a project with a scene + mod and boots in bundle mode with the scene seeded", async () => {
+    const dir = await fixtureDir();
+    try {
+      const bundle = await compileBundle(join(dir, "content"), join(dir, "mods"));
+      expect(bundle.mods?.[MOD_ID]).toBeDefined();
+      expect(typeof bundle.mods?.[MOD_ID]?.code).toBe("string");
+      expect(bundle.content["building"]).toBeDefined();
+      expect(bundle.content["animal"]).toBeDefined();
+      expect(bundle.content["scene"]).toBeDefined();
+
+      const engine = createEngine({ bundle });
+      await engine.start();
+      try {
+        const mapEntity = engine.world.query({ type: "map" })[0];
+        expect(mapEntity?.id).toBe("farm");
+        expect(engine.world.get("player")).toMatchObject({ id: "player", x: 2, y: 2 });
+
+        const shed = engine.world.query({ type: "building" })[0];
+        expect(shed?.id).toBe("building:shed:4:4");
+        expect(shed?.x).toBe(4);
+        expect(shed?.y).toBe(4);
+        expect(shed?.w).toBe(2);
+        expect(shed?.h).toBe(2);
+
+        const mayor = engine.world.get("mayor");
+        expect(mayor?.type).toBe("npc");
+        expect(mayor?.label).toBe("Mayor");
+
+        const chicken = engine.world.query({ type: "animal" })[0];
+        expect(chicken?.id).toBe("animal:chicken:1");
+        expect(chicken?.x).toBe(8);
+        expect(chicken?.y).toBe(8);
+
+        expect(engine.mods).toHaveLength(1);
+        expect(engine.mods[0]?.id).toBe(MOD_ID);
+        expect(engine.mods[0]?.loaded).toBe(true);
+        expect(engine.world.get("mod:playtest-stats")).toBeUndefined();
+
+        engine.tick(480);
+        expect(engine.world.get("mod:playtest-stats")?.days).toBe(1);
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("save-all flush content lands in the compiled bundle (see what ships)", async () => {
+    const dir = await fixtureDir();
+    try {
+      const modified = FIXTURE_FILES["content/maps/farm.yaml"] as string;
+      const result = await saveAllFlushes(
+        [{ path: "content/maps/farm.yaml", content: modified.replace("spawn: { x: 2, y: 2 }", "spawn: { x: 5, y: 5 }") }],
+        (path, content) => writeFile(join(dir, path), content, "utf8"),
+      );
+      expect(result.failures).toEqual([]);
+      expect(result.flushed).toEqual(["content/maps/farm.yaml"]);
+
+      const bundle = await compileBundle(join(dir, "content"));
+      const engine = createEngine({ bundle });
+      await engine.start();
+      try {
+        expect(engine.world.get("player")?.x).toBe(5);
+        expect(engine.world.get("player")?.y).toBe(5);
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("leaves bundle.mods undefined when the project has no mods dir (main handler parity)", async () => {
+    const dir = await fixtureDir();
+    try {
+      await rm(join(dir, "mods"), { recursive: true, force: true });
+      const modsDir = join(dir, "mods");
+      const bundle = await compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined);
+      expect(bundle.mods).toBeUndefined();
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("the full playtest session orchestrates save-all → compile → boot and tears down", async () => {
+    const dir = await fixtureDir();
+    try {
+      let bootedBundle: ContentBundle | undefined;
+      let tornDown = false;
+      const session = createPlaytestSession({
+        collectFlushes: () => [],
+        write: async (path, content) => writeFile(join(dir, path), content, "utf8"),
+        compile: async () => {
+          const modsDir = join(dir, "mods");
+          return compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined);
+        },
+        boot: async (bundle) => {
+          bootedBundle = bundle;
+        },
+        teardown: async () => {
+          tornDown = true;
+        },
+      });
+      const result = await session.start();
+      expect(result.ok).toBe(true);
+      expect(bootedBundle?.content["scene"]).toBeDefined();
+      expect(bootedBundle?.mods?.[MOD_ID]).toBeDefined();
+
+      await session.stop();
+      expect(tornDown).toBe(true);
+      expect(session.phase).toBe("idle");
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+});

+ 42 - 0
apps/editor/test/playtest-hud.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { inventorySummary, playerStatsFromSnapshot } from "../src/renderer/playtest-hud.js";
+
+describe("playerStatsFromSnapshot", () => {
+  it("reads gold, stamina, seed and inventory from the player entity", () => {
+    const stats = playerStatsFromSnapshot([
+      { id: "plot:0:0", type: "plot" },
+      { id: "player", type: "player", gold: 42, stamina: 30, selectedSeed: "carrot-seed", inventory: { carrot: 3, "carrot-seed": 1 } },
+    ]);
+    expect(stats.gold).toBe(42);
+    expect(stats.stamina).toBe(30);
+    expect(stats.selectedSeed).toBe("carrot-seed");
+    expect(stats.inventory).toEqual({ carrot: 3, "carrot-seed": 1 });
+  });
+
+  it("defaults when the player or fields are missing", () => {
+    const stats = playerStatsFromSnapshot([]);
+    expect(stats.gold).toBe(0);
+    expect(stats.stamina).toBe(0);
+    expect(stats.selectedSeed).toBeNull();
+    expect(stats.inventory).toEqual({});
+  });
+
+  it("drops zero/negative inventory quantities", () => {
+    const stats = playerStatsFromSnapshot([
+      { id: "player", type: "player", inventory: { carrot: 2, hoe: 0, weed: -1 } },
+    ]);
+    expect(stats.inventory).toEqual({ carrot: 2 });
+  });
+});
+
+describe("inventorySummary", () => {
+  it("joins inventory items", () => {
+    expect(inventorySummary({ inventory: { carrot: 3, hoe: 1 }, gold: 0, stamina: 0, selectedSeed: null })).toBe(
+      "carrot ×3 · hoe ×1",
+    );
+  });
+
+  it("shows a placeholder for an empty inventory", () => {
+    expect(inventorySummary({ inventory: {}, gold: 0, stamina: 0, selectedSeed: null })).toBe("items: —");
+  });
+});

+ 206 - 0
apps/editor/test/playtest-session.test.ts

@@ -0,0 +1,206 @@
+import { describe, expect, it } from "vitest";
+import { createPlaytestSession, keydownInput, saveAllFlushes } from "../src/renderer/playtest-session.js";
+
+describe("saveAllFlushes", () => {
+  it("writes clean flushes and reports them as flushed", async () => {
+    const written: string[] = [];
+    const result = await saveAllFlushes(
+      [
+        { path: "a.yaml", content: "x" },
+        { path: "b.yaml", content: "y" },
+      ],
+      async (path, content) => {
+        written.push(`${path}:${content}`);
+      },
+    );
+    expect(result.flushed).toEqual(["a.yaml", "b.yaml"]);
+    expect(result.failures).toEqual([]);
+    expect(written).toEqual(["a.yaml:x", "b.yaml:y"]);
+  });
+
+  it("records error flushes and rejecting writes as failures without throwing", async () => {
+    const result = await saveAllFlushes(
+      [
+        { path: "bad.yaml", error: "map invalid" },
+        { path: "throws.yaml", content: "z" },
+        { path: "ok.yaml", content: "ok" },
+      ],
+      async (path) => {
+        if (path === "throws.yaml") throw new Error("disk full");
+      },
+    );
+    expect(result.flushed).toEqual(["ok.yaml"]);
+    expect(result.failures).toEqual([
+      { path: "bad.yaml", error: "map invalid" },
+      { path: "throws.yaml", error: "disk full" },
+    ]);
+  });
+
+  it("writes nothing when there are no flushes", async () => {
+    const result = await saveAllFlushes([], async () => {
+      throw new Error("unreachable");
+    });
+    expect(result.flushed).toEqual([]);
+    expect(result.failures).toEqual([]);
+  });
+});
+
+describe("keydownInput", () => {
+  it("maps arrow keys to raw inputs", () => {
+    expect(keydownInput("ArrowUp")).toBe("key:ArrowUp");
+    expect(keydownInput("ArrowDown")).toBe("key:ArrowDown");
+    expect(keydownInput("ArrowLeft")).toBe("key:ArrowLeft");
+    expect(keydownInput("ArrowRight")).toBe("key:ArrowRight");
+  });
+
+  it("returns undefined for non-arrow keys", () => {
+    expect(keydownInput("a")).toBeUndefined();
+    expect(keydownInput("Enter")).toBeUndefined();
+    expect(keydownInput(" ")).toBeUndefined();
+  });
+});
+
+describe("createPlaytestSession", () => {
+  it("runs save-all → compile → boot → playing, then stop tears down", async () => {
+    const phases: string[] = [];
+    let booted: unknown = null;
+    let tornDown = false;
+    const session = createPlaytestSession({
+      collectFlushes: () => [{ path: "m.yaml", content: "m" }],
+      write: async () => {},
+      compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
+      boot: async (bundle) => {
+        booted = bundle;
+      },
+      teardown: async () => {
+        tornDown = true;
+      },
+      onPhase: (phase) => phases.push(phase),
+    });
+    const result = await session.start();
+    expect(result.ok).toBe(true);
+    expect(result.saved).toEqual(["m.yaml"]);
+    expect(booted).toEqual({ format: "nengine-bundle", version: 1, content: {} });
+    expect(session.phase).toBe("playing");
+    expect(phases).toEqual(["saving", "compiling", "booting", "playing"]);
+
+    await session.stop();
+    expect(tornDown).toBe(true);
+    expect(session.phase).toBe("idle");
+    expect(phases[phases.length - 1]).toBe("idle");
+  });
+
+  it("fails on an un-serializable model and never compiles", async () => {
+    let compiled = false;
+    const session = createPlaytestSession({
+      collectFlushes: () => [{ path: "bad.yaml", error: "map invalid" }],
+      write: async () => {},
+      compile: async () => {
+        compiled = true;
+        throw new Error("nope");
+      },
+      boot: async () => {},
+      teardown: async () => {},
+    });
+    const result = await session.start();
+    expect(result.ok).toBe(false);
+    expect(result.saved).toEqual([]);
+    expect(result.error).toContain("bad.yaml");
+    expect(result.error).toContain("map invalid");
+    expect(compiled).toBe(false);
+    expect(session.phase).toBe("idle");
+  });
+
+  it("fails on a write rejection", async () => {
+    const session = createPlaytestSession({
+      collectFlushes: () => [{ path: "a.yaml", content: "a" }],
+      write: async () => {
+        throw new Error("disk full");
+      },
+      compile: async () => {
+        throw new Error("unreachable");
+      },
+      boot: async () => {},
+      teardown: async () => {},
+    });
+    const result = await session.start();
+    expect(result.ok).toBe(false);
+    expect(result.saved).toEqual([]);
+    expect(result.error).toContain("disk full");
+  });
+
+  it("fails on a compile error with the compile message", async () => {
+    let booted = false;
+    const session = createPlaytestSession({
+      collectFlushes: () => [],
+      write: async () => {},
+      compile: async () => {
+        throw new Error("content validation failed");
+      },
+      boot: async () => {
+        booted = true;
+      },
+      teardown: async () => {},
+    });
+    const result = await session.start();
+    expect(result.ok).toBe(false);
+    expect(result.error).toContain("content validation failed");
+    expect(booted).toBe(false);
+    expect(session.phase).toBe("idle");
+  });
+
+  it("fails on a boot error with the boot message", async () => {
+    const session = createPlaytestSession({
+      collectFlushes: () => [],
+      write: async () => {},
+      compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
+      boot: async () => {
+        throw new Error("no map content loaded");
+      },
+      teardown: async () => {},
+    });
+    const result = await session.start();
+    expect(result.ok).toBe(false);
+    expect(result.error).toContain("no map content loaded");
+    expect(session.phase).toBe("idle");
+  });
+
+  it("rejects start while already running", async () => {
+    let resolveBoot: (() => void) | undefined;
+    const session = createPlaytestSession({
+      collectFlushes: () => [],
+      write: async () => {},
+      compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
+      boot: async () => {
+        await new Promise<void>((resolve) => {
+          resolveBoot = resolve;
+        });
+      },
+      teardown: async () => {},
+    });
+    const first = session.start();
+    await Promise.resolve();
+    const second = await session.start();
+    expect(second.ok).toBe(false);
+    expect(second.error).toBe("playtest already running");
+    resolveBoot?.();
+    await first;
+    expect(session.phase).toBe("playing");
+  });
+
+  it("stop is a no-op when idle", async () => {
+    let tornDown = false;
+    const session = createPlaytestSession({
+      collectFlushes: () => [],
+      write: async () => {},
+      compile: async () => ({ format: "nengine-bundle", version: 1, content: {} }),
+      boot: async () => {},
+      teardown: async () => {
+        tornDown = true;
+      },
+    });
+    await session.stop();
+    expect(tornDown).toBe(false);
+    expect(session.phase).toBe("idle");
+  });
+});

+ 67 - 0
apps/editor/test/project-yaml.test.ts

@@ -0,0 +1,67 @@
+import { mkdir, mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { PROJECT_YAML_NAME, projectYamlSchema, readProjectYaml, writeProjectYaml } from "../src/project-yaml.js";
+
+async function tmpDir(): Promise<string> {
+  return mkdtemp(join(tmpdir(), "editor-yaml-"));
+}
+
+describe("projectYamlSchema", () => {
+  it("accepts empty and partial metadata", () => {
+    expect(projectYamlSchema.safeParse({}).success).toBe(true);
+    expect(projectYamlSchema.safeParse({ name: "Game" }).success).toBe(true);
+    expect(
+      projectYamlSchema.safeParse({ name: "Game", icon: "icon.png", pack: { installer: true, splitAssets: true } })
+        .success,
+    ).toBe(true);
+  });
+
+  it("rejects unknown keys and wrong types", () => {
+    expect(projectYamlSchema.safeParse({ nmae: "Game" }).success).toBe(false);
+    expect(projectYamlSchema.safeParse({ name: 42 }).success).toBe(false);
+    expect(projectYamlSchema.safeParse({ pack: { installer: "yes" } }).success).toBe(false);
+    expect(projectYamlSchema.safeParse({ pack: { unknown: true } }).success).toBe(false);
+    expect(projectYamlSchema.safeParse({ pack: "installed" }).success).toBe(false);
+  });
+});
+
+describe("readProjectYaml / writeProjectYaml", () => {
+  it("returns null when project.yaml is absent", async () => {
+    const dir = await tmpDir();
+    try {
+      expect(await readProjectYaml(dir)).toBeNull();
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("roundtrips a written project.yaml", async () => {
+    const dir = await tmpDir();
+    try {
+      const data = { name: "Pinefield", icon: "icon.png", pack: { installer: true, mods: true, splitAssets: true } };
+      await writeProjectYaml(dir, data);
+      expect(await readProjectYaml(dir)).toEqual(data);
+      const { readFile } = await import("node:fs/promises");
+      const raw = await readFile(join(dir, PROJECT_YAML_NAME), "utf8");
+      expect(raw).toContain("name: Pinefield");
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("throws on malformed yaml and invalid data", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(dir, { recursive: true });
+      const { writeFile } = await import("node:fs/promises");
+      await writeFile(join(dir, PROJECT_YAML_NAME), "name: [unclosed\n", "utf8");
+      await expect(readProjectYaml(dir)).rejects.toThrow(/invalid project.yaml/);
+
+      await expect(writeProjectYaml(dir, { name: 42 } as never)).rejects.toThrow(/invalid project.yaml/);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});

+ 175 - 0
apps/editor/test/project.test.ts

@@ -0,0 +1,175 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { inspectProject, listContent, openProject, readYaml, resolveWithin, stats, writeYaml } from "../src/project.js";
+import { newProject } from "../src/template.js";
+
+async function tmpDir(): Promise<string> {
+  return mkdtemp(join(tmpdir(), "editor-project-"));
+}
+
+describe("openProject", () => {
+  it("accepts a folder with content/ and returns project info", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content"));
+      const project = await openProject(dir);
+      expect(project.root).toBe(dir);
+      expect(project.name).toBe(dir.split("/").pop());
+      expect(project.hasContent).toBe(true);
+      expect(project.hasMods).toBe(false);
+      expect(project.projectYaml).toBeNull();
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("rejects a folder without content/", async () => {
+    const dir = await tmpDir();
+    try {
+      await expect(openProject(dir)).rejects.toThrow(/missing content/);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("uses project.yaml name when present", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content"));
+      await writeFile(join(dir, "project.yaml"), "name: My Game\n", "utf8");
+      const project = await openProject(dir);
+      expect(project.name).toBe("My Game");
+      expect(project.projectYaml?.name).toBe("My Game");
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("tolerates a malformed project.yaml", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content"));
+      await writeFile(join(dir, "project.yaml"), "name: [broken\n", "utf8");
+      const project = await openProject(dir);
+      expect(project.name).toBe(dir.split("/").pop());
+      expect(project.projectYaml).toBeNull();
+      expect(project.projectYamlError).toBeDefined();
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("listContent and stats", () => {
+  it("lists kinds with counts and relative file paths on a generated project", async () => {
+    const dir = await tmpDir();
+    try {
+      const project = await newProject("Test Farm", dir);
+      const listing = await listContent(project.root);
+      const byKind = new Map(listing.kinds.map((k) => [k.kind, k]));
+      expect(byKind.get("map")?.count).toBe(1);
+      expect(byKind.get("item")?.count).toBe(3);
+      expect(byKind.get("crop")?.count).toBe(1);
+      expect(byKind.get("time")?.count).toBe(1);
+      expect(byKind.get("weather")?.count).toBe(1);
+      expect(byKind.get("interact")?.count).toBe(1);
+      expect(byKind.get("scene")?.count).toBe(1);
+      const hoe = byKind.get("item")?.files.find((f) => f.name === "hoe.yaml");
+      expect(hoe?.path).toBe("content/items/hoe.yaml");
+      const time = byKind.get("time")?.files[0];
+      expect(time?.path).toBe("content/time.yaml");
+      expect(listing.mods).toEqual([]);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("returns stats with per-kind counts", async () => {
+    const dir = await tmpDir();
+    try {
+      const project = await newProject("Stats Farm", dir);
+      const result = await stats(project.root);
+      expect(result.kinds.map).toBe(1);
+      expect(result.kinds.item).toBe(3);
+      expect(result.total).toBe(9);
+      expect(result.mods).toBe(0);
+      expect(result.assets).toBe(0);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("counts mods when a mods/ dir has entries", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content"), { recursive: true });
+      await mkdir(join(dir, "mods", "my-mod"), { recursive: true });
+      await writeFile(join(dir, "mods", "my-mod", "index.mjs"), "export const hooks = {}\n", "utf8");
+      const listing = await listContent(dir);
+      expect(listing.mods).toEqual(["my-mod"]);
+      expect((await stats(dir)).mods).toBe(1);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("resolveWithin", () => {
+  it("accepts paths inside the root", () => {
+    expect(resolveWithin("/a/b", "content/maps/farm.yaml")).toBe("/a/b/content/maps/farm.yaml");
+    expect(resolveWithin("/a/b", ".")).toBe("/a/b");
+  });
+
+  it("rejects parent traversal and absolute escapes", () => {
+    expect(() => resolveWithin("/a/b", "../secret.yaml")).toThrow(/escapes project root/);
+    expect(() => resolveWithin("/a/b", "content/../../x.yaml")).toThrow(/escapes project root/);
+    expect(() => resolveWithin("/a/b", "/etc/passwd")).toThrow(/escapes project root/);
+  });
+});
+
+describe("readYaml and writeYaml", () => {
+  it("roundtrips yaml content and creates nested directories", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content"), { recursive: true });
+      await writeYaml(dir, "content/items/hoe.yaml", "id: hoe\n");
+      expect(await readYaml(dir, "content/items/hoe.yaml")).toBe("id: hoe\n");
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("rejects non-yaml paths", async () => {
+    const dir = await tmpDir();
+    try {
+      await expect(writeYaml(dir, "content/notes.txt", "x")).rejects.toThrow(/not a yaml path/);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("rejects traversal in read and write", async () => {
+    const dir = await tmpDir();
+    try {
+      await expect(writeYaml(dir, "../evil.yaml", "x")).rejects.toThrow(/escapes project root/);
+      await expect(readYaml(dir, "content/../../evil.yaml")).rejects.toThrow(/escapes project root/);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("inspectProject", () => {
+  it("inspects a folder without requiring content/", async () => {
+    const dir = await tmpDir();
+    try {
+      const project = await inspectProject(dir);
+      expect(project.hasContent).toBe(false);
+      expect(project.hasMods).toBe(false);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});

+ 65 - 0
apps/editor/test/recent.test.ts

@@ -0,0 +1,65 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { RECENT_CAP, addRecent, loadRecent, saveRecent } from "../src/recent.js";
+import type { EditorRecentEntry } from "../src/ipc.js";
+
+function entry(root: string, name = "p"): EditorRecentEntry {
+  return { root, name };
+}
+
+describe("addRecent", () => {
+  it("prepends new entries and dedupes by root", () => {
+    const base = [entry("/a"), entry("/b")];
+    const next = addRecent(base, entry("/b", "b2"));
+    expect(next.map((e) => e.root)).toEqual(["/b", "/a"]);
+    expect(next[0]?.name).toBe("b2");
+  });
+
+  it("caps the list at RECENT_CAP", () => {
+    let list: EditorRecentEntry[] = [];
+    for (let i = 0; i < RECENT_CAP + 3; i++) list = addRecent(list, entry(`/p${i}`));
+    expect(list.length).toBe(RECENT_CAP);
+    expect(list[0]?.root).toBe(`/p${RECENT_CAP + 2}`);
+  });
+});
+
+describe("recent persistence", () => {
+  it("roundtrips through a json file", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "editor-recent-"));
+    try {
+      const file = join(dir, "recent.json");
+      const entries = [entry("/a", "A"), entry("/b", "B")];
+      await saveRecent(file, entries);
+      expect(await loadRecent(file)).toEqual(entries);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("returns [] for a missing file", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "editor-recent-"));
+    try {
+      expect(await loadRecent(join(dir, "nope.json"))).toEqual([]);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("returns [] for corrupt json and drops invalid entries", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "editor-recent-"));
+    try {
+      const corrupt = join(dir, "corrupt.json");
+      const { writeFile } = await import("node:fs/promises");
+      await writeFile(corrupt, "{ not json", "utf8");
+      expect(await loadRecent(corrupt)).toEqual([]);
+
+      const mixed = join(dir, "mixed.json");
+      await writeFile(mixed, JSON.stringify([{ root: "/ok", name: "Ok" }, { root: 42 }, "junk"]), "utf8");
+      expect(await loadRecent(mixed)).toEqual([{ root: "/ok", name: "Ok" }]);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});

+ 475 - 0
apps/editor/test/scene-model.test.ts

@@ -0,0 +1,475 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { buildingSchema, sceneSchema } from "@nengine/core-schema";
+import type { SceneDef, SceneEntity } from "@nengine/core-schema";
+import { parse, stringify } from "yaml";
+import { describe, expect, it } from "vitest";
+import {
+  ANIMAL_COLOR,
+  DEFAULT_SCENE_ID,
+  NPC_COLOR,
+  addAnimal,
+  addBuilding,
+  addNpc,
+  createSceneModel,
+  entityAt,
+  entityColor,
+  entityFootprint,
+  entityIndexAt,
+  markSaved,
+  moveEntity,
+  removeEntity,
+  serialize,
+} from "../src/renderer/scene-model.js";
+import type { SceneFootprint, SceneModel } from "../src/renderer/scene-model.js";
+
+const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
+
+const FOOTPRINTS: Record<string, SceneFootprint> = {
+  house: { w: 2, h: 2 },
+  shed: { w: 2, h: 2 },
+  well: { w: 1, h: 1 },
+};
+
+function model(mapWidth = 10, mapHeight = 10, footprints: Record<string, SceneFootprint> = FOOTPRINTS): SceneModel {
+  const m = createSceneModel(
+    { id: "farm", entities: [] },
+    { mapWidth, mapHeight, footprints },
+  );
+  if (m === null) throw new Error("expected a valid model");
+  return m;
+}
+
+function entities(m: SceneModel): SceneEntity[] {
+  return m.def.entities;
+}
+
+describe("createSceneModel", () => {
+  it("builds a model from a valid def without mutating the input", () => {
+    const def: SceneDef = {
+      id: "farm",
+      entities: [{ type: "building", buildingId: "well", x: 1, y: 1 }],
+    };
+    const snapshot = JSON.stringify(def);
+    const m = createSceneModel(def, { mapWidth: 10, mapHeight: 10, footprints: FOOTPRINTS });
+    expect(m).not.toBeNull();
+    if (m === null) throw new Error("expected a valid model");
+    expect(m.dirty).toBe(false);
+    addNpc(m, "mayor", 3, 3);
+    expect(JSON.stringify(def)).toBe(snapshot);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("rejects corrupt defs", () => {
+    expect(createSceneModel({ id: 42 }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
+    expect(createSceneModel({ id: "farm", entities: [{ type: "npc", x: 1 }] }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
+    expect(
+      createSceneModel({ id: "farm", entities: [{ type: "building", buildingId: "well", x: -1, y: 0 }] }, { mapWidth: 10, mapHeight: 10 }),
+    ).toBeNull();
+    expect(
+      createSceneModel({ id: "farm", entities: [{ type: "silo", x: 1, y: 1 }] }, { mapWidth: 10, mapHeight: 10 }),
+    ).toBeNull();
+  });
+
+  it("accepts an empty entities array", () => {
+    const m = createSceneModel({ id: "farm", entities: [] }, { mapWidth: 10, mapHeight: 10 });
+    expect(m?.def.entities).toEqual([]);
+  });
+
+  it("creates a new empty scene from undefined input with the default id", () => {
+    const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10 });
+    expect(m).not.toBeNull();
+    expect(m?.def).toEqual({ id: DEFAULT_SCENE_ID, entities: [] });
+    expect(m?.dirty).toBe(false);
+  });
+
+  it("honours a custom default id for new scenes", () => {
+    const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10, defaultId: "farm" });
+    expect(m?.def.id).toBe("farm");
+  });
+
+  it("deep-clones the footprints table", () => {
+    const footprints: Record<string, SceneFootprint> = { house: { w: 2, h: 2 } };
+    const m = model(10, 10, footprints);
+    m.footprints.house = { w: 5, h: 5 };
+    expect(footprints.house).toEqual({ w: 2, h: 2 });
+  });
+});
+
+describe("addBuilding", () => {
+  it("places a building and marks dirty", () => {
+    const m = model();
+    expect(m.dirty).toBe(false);
+    const result = addBuilding(m, "well", 3, 4);
+    expect(result).toEqual({ ok: true });
+    expect(entities(m)).toEqual([{ type: "building", buildingId: "well", x: 3, y: 4 }]);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("clamps out-of-bounds coordinates footprint-aware", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 9, 9);
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 8, y: 8 });
+    addBuilding(m, "well", -3, -3);
+    expect(entities(m)[1]).toEqual({ type: "building", buildingId: "well", x: 0, y: 0 });
+  });
+
+  it("rejects non-integer coordinates", () => {
+    const m = model();
+    expect(addBuilding(m, "well", 1.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
+    expect(addBuilding(m, "well", Number.NaN, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
+    expect(entities(m)).toHaveLength(0);
+    expect(m.dirty).toBe(false);
+  });
+
+  it("rejects an unknown building id", () => {
+    const m = model();
+    const result = addBuilding(m, "silo", 1, 1);
+    expect(result).toEqual({ ok: false, reason: expect.stringContaining('unknown building "silo"') });
+    expect(entities(m)).toHaveLength(0);
+  });
+
+  it("rejects an empty building id", () => {
+    const m = model();
+    expect(addBuilding(m, "", 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("must not be empty") });
+  });
+
+  it("rejects overlap with another building's footprint", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    const result = addBuilding(m, "well", 1, 1);
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.reason).toContain("overlaps");
+    expect(entities(m)).toHaveLength(1);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("rejects a duplicate building id at the same coordinates", () => {
+    const m = model();
+    addBuilding(m, "well", 2, 2);
+    const result = addBuilding(m, "well", 2, 2);
+    expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate building "well" at (2,2)') });
+    expect(entities(m)).toHaveLength(1);
+  });
+
+  it("rejects overlap with a placed npc or animal", () => {
+    const m = model();
+    addNpc(m, "mayor", 2, 2);
+    expect(addBuilding(m, "house", 1, 1).ok).toBe(false);
+    addAnimal(m, "chicken", 5, 5);
+    expect(addBuilding(m, "house", 4, 4).ok).toBe(false);
+    expect(entities(m).filter((e) => e.type === "building")).toHaveLength(0);
+  });
+
+  it("allows adjacent placement that only touches edges", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    const result = addBuilding(m, "well", 2, 0);
+    expect(result).toEqual({ ok: true });
+    expect(entities(m)).toHaveLength(2);
+  });
+
+  it("rejects a footprint larger than the map", () => {
+    const m = model(1, 1);
+    const result = addBuilding(m, "house", 0, 0);
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.reason).toContain("does not fit");
+    expect(entities(m)).toHaveLength(0);
+  });
+});
+
+describe("addNpc", () => {
+  it("places an npc with optional dialogue and label", () => {
+    const m = model();
+    const result = addNpc(m, "mayor", 4, 4, { dialogue: "intro", label: "Mayor" });
+    expect(result).toEqual({ ok: true });
+    expect(entities(m)).toEqual([
+      { type: "npc", npcId: "mayor", x: 4, y: 4, dialogue: "intro", label: "Mayor" },
+    ]);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("omits empty dialogue and label", () => {
+    const m = model();
+    addNpc(m, "mayor", 4, 4, { dialogue: "", label: "" });
+    expect(entities(m)).toEqual([{ type: "npc", npcId: "mayor", x: 4, y: 4 }]);
+  });
+
+  it("rejects a duplicate npc id", () => {
+    const m = model();
+    addNpc(m, "mayor", 1, 1);
+    const result = addNpc(m, "mayor", 5, 5);
+    expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate npc id "mayor"') });
+    expect(entities(m)).toHaveLength(1);
+  });
+
+  it("clamps out-of-bounds coordinates", () => {
+    const m = model(10, 10);
+    addNpc(m, "mayor", -5, 99);
+    expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 0, y: 9 });
+  });
+
+  it("rejects placement inside a building footprint", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    const result = addNpc(m, "mayor", 1, 1);
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.reason).toContain("overlaps");
+    expect(entities(m)).toHaveLength(1);
+  });
+});
+
+describe("addAnimal", () => {
+  it("places an animal and marks dirty", () => {
+    const m = model();
+    const result = addAnimal(m, "chicken", 3, 3);
+    expect(result).toEqual({ ok: true });
+    expect(entities(m)).toEqual([{ type: "animal", animalId: "chicken", x: 3, y: 3 }]);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("clamps out-of-bounds coordinates", () => {
+    const m = model(10, 10);
+    addAnimal(m, "chicken", 10, -2);
+    expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 9, y: 0 });
+  });
+
+  it("allows multiple animals with the same animal id", () => {
+    const m = model();
+    addAnimal(m, "chicken", 1, 1);
+    addAnimal(m, "chicken", 3, 3);
+    expect(entities(m)).toHaveLength(2);
+  });
+
+  it("rejects overlap with a building", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    expect(addAnimal(m, "chicken", 0, 0).ok).toBe(false);
+    expect(entities(m)).toHaveLength(1);
+  });
+});
+
+describe("removeEntity", () => {
+  it("removes the entity at the index and marks dirty", () => {
+    const m = model();
+    addBuilding(m, "well", 1, 1);
+    addNpc(m, "mayor", 3, 3);
+    const result = removeEntity(m, 0);
+    expect(result).toEqual({ ok: true });
+    expect(entities(m)).toHaveLength(1);
+    expect(entities(m)[0]?.type).toBe("npc");
+    expect(m.dirty).toBe(true);
+  });
+
+  it("rejects an out-of-range index", () => {
+    const m = model();
+    expect(removeEntity(m, 0)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
+    addAnimal(m, "chicken", 1, 1);
+    expect(removeEntity(m, 5)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
+    expect(entities(m)).toHaveLength(1);
+  });
+});
+
+describe("moveEntity", () => {
+  it("moves an entity and marks dirty", () => {
+    const m = model();
+    addNpc(m, "mayor", 1, 1);
+    const result = moveEntity(m, 0, 4, 5);
+    expect(result).toEqual({ ok: true });
+    expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 4, y: 5 });
+    expect(m.dirty).toBe(true);
+  });
+
+  it("re-clamps out-of-bounds targets footprint-aware", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 1, 1);
+    moveEntity(m, 0, -5, 99);
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 0, y: 8 });
+  });
+
+  it("rejects a move into another entity's footprint without mutating", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    addNpc(m, "mayor", 6, 6);
+    const before = JSON.stringify(m.def);
+    const result = moveEntity(m, 0, 6, 6);
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.reason).toContain("overlaps");
+    expect(JSON.stringify(m.def)).toBe(before);
+    expect(m.dirty).toBe(true);
+  });
+
+  it("rejects a move into its own footprint (same entity excluded from overlap)", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    addNpc(m, "mayor", 6, 6);
+    const result = moveEntity(m, 0, 1, 1);
+    expect(result).toEqual({ ok: true });
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 1, y: 1 });
+  });
+
+  it("rejects an invalid index and non-integer coordinates", () => {
+    const m = model();
+    expect(moveEntity(m, 0, 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
+    addAnimal(m, "chicken", 1, 1);
+    expect(moveEntity(m, 0, 2.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
+    expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 1, y: 1 });
+  });
+});
+
+describe("serialize", () => {
+  it("roundtrips a valid scene as a fresh object", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    addNpc(m, "mayor", 3, 3, { dialogue: "intro" });
+    const out = serialize(m);
+    expect(out).not.toBeNull();
+    expect(out).toEqual({
+      id: "farm",
+      entities: [
+        { type: "building", buildingId: "house", x: 0, y: 0 },
+        { type: "npc", npcId: "mayor", x: 3, y: 3, dialogue: "intro" },
+      ],
+    });
+    expect(out).not.toBe(m.def);
+    expect(out?.entities[0]).not.toBe(m.def.entities[0]);
+    expect(sceneSchema.safeParse(out as SceneDef).success).toBe(true);
+  });
+
+  it("rejects an invalid working copy", () => {
+    const m = model();
+    addAnimal(m, "chicken", 1, 1);
+    const invalid = entities(m)[0] as { animalId: string };
+    delete invalid.animalId;
+    expect(serialize(m)).toBeNull();
+  });
+
+  it("still serializes after a rejected op (invalid ops never corrupt)", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    addBuilding(m, "well", 1, 1);
+    addNpc(m, "mayor", 1, 1);
+    const out = serialize(m);
+    expect(out).not.toBeNull();
+    expect(out?.entities).toHaveLength(1);
+  });
+
+  it("serializes an empty scene", () => {
+    const m = model();
+    const out = serialize(m);
+    expect(out).toEqual({ id: "farm", entities: [] });
+  });
+});
+
+describe("entityAt / entityIndexAt", () => {
+  it("finds entities by any footprint tile", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    addNpc(m, "mayor", 5, 5);
+    expect(entityIndexAt(m, 0, 0)).toBe(0);
+    expect(entityIndexAt(m, 1, 1)).toBe(0);
+    expect(entityIndexAt(m, 0, 1)).toBe(0);
+    expect(entityIndexAt(m, 5, 5)).toBe(1);
+    expect(entityIndexAt(m, 3, 3)).toBe(-1);
+    expect(entityAt(m, 1, 1)?.type).toBe("building");
+    expect(entityAt(m, 3, 3)).toBeUndefined();
+  });
+
+  it("returns the topmost entity for overlapping loaded defs", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    const raw = m.def as SceneDef;
+    raw.entities.push({ type: "npc", npcId: "mayor", x: 1, y: 1 });
+    expect(entityIndexAt(m, 1, 1)).toBe(1);
+    expect(entityAt(m, 1, 1)?.type).toBe("npc");
+  });
+});
+
+describe("entityFootprint / entityColor", () => {
+  it("uses the footprint table for buildings and 1x1 for others", () => {
+    const m = model();
+    addBuilding(m, "house", 0, 0);
+    addNpc(m, "mayor", 5, 5);
+    addAnimal(m, "chicken", 8, 8);
+    expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 2, h: 2 });
+    expect(entityFootprint(m, entities(m)[1] as SceneEntity)).toEqual({ w: 1, h: 1 });
+    expect(entityFootprint(m, entities(m)[2] as SceneEntity)).toEqual({ w: 1, h: 1 });
+  });
+
+  it("falls back to 1x1 for a building with no footprint entry", () => {
+    const m = model(10, 10, {});
+    const raw = m.def as SceneDef;
+    raw.entities.push({ type: "building", buildingId: "silo", x: 0, y: 0 });
+    expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 1, h: 1 });
+  });
+
+  it("colors npc amber, animal red, buildings a stable hash hue", () => {
+    expect(entityColor({ type: "npc", npcId: "mayor", x: 0, y: 0 })).toBe(NPC_COLOR);
+    expect(entityColor({ type: "animal", animalId: "chicken", x: 0, y: 0 })).toBe(ANIMAL_COLOR);
+    const shed = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
+    const shedAgain = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
+    expect(shed).toBe(shedAgain);
+    expect(shed).toMatch(/^hsl\(\d+ 45% 45%\)$/);
+  });
+});
+
+describe("dirty lifecycle", () => {
+  it("tracks edits and clears on markSaved", () => {
+    const m = model();
+    expect(m.dirty).toBe(false);
+    addBuilding(m, "well", 1, 1);
+    expect(m.dirty).toBe(true);
+    markSaved(m);
+    expect(m.dirty).toBe(false);
+    moveEntity(m, 0, 2, 2);
+    expect(m.dirty).toBe(true);
+    markSaved(m);
+    removeEntity(m, 0);
+    expect(m.dirty).toBe(true);
+  });
+});
+
+describe("real scene fixture end-to-end", () => {
+  it("edits a real scene through read → parse → model → place → serialize → stringify → parse", async () => {
+    const sceneText = await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8");
+    const shedText = await readFile(join(fixturesDir, "buildings", "shed.yaml"), "utf8");
+    const wellText = await readFile(join(fixturesDir, "buildings", "well.yaml"), "utf8");
+    const footprints: Record<string, SceneFootprint> = {};
+    for (const text of [shedText, wellText]) {
+      const def = buildingSchema.safeParse(parse(text));
+      expect(def.success).toBe(true);
+      if (def.success) footprints[def.data.id] = def.data.footprint;
+    }
+
+    const m = createSceneModel(parse(sceneText), { mapWidth: 16, mapHeight: 16, footprints });
+    expect(m).not.toBeNull();
+    if (m === null) throw new Error("fixture must be a valid scene");
+    expect(sceneSchema.safeParse(m.def).success).toBe(true);
+    expect(m.def.id).toBe("farm-yard");
+    expect(entities(m)).toHaveLength(3);
+
+    const placed = addBuilding(m, "well", 8, 8);
+    expect(placed).toEqual({ ok: true });
+    const npc = addNpc(m, "farmer", 10, 10, { label: "Farmer" });
+    expect(npc).toEqual({ ok: true });
+    expect(addBuilding(m, "shed", 0, 0).ok).toBe(false);
+
+    const out = serialize(m);
+    expect(out).not.toBeNull();
+    if (out === null) throw new Error("edited fixture must serialize");
+
+    const yaml = stringify(out);
+    const reparsed = parse(yaml);
+    expect(reparsed).toEqual(out);
+    expect(sceneSchema.safeParse(reparsed).success).toBe(true);
+
+    const m2 = createSceneModel(reparsed, { mapWidth: 16, mapHeight: 16, footprints });
+    expect(m2).not.toBeNull();
+    expect(m2?.def.entities).toHaveLength(5);
+    expect(m2?.def.entities[3]).toEqual({ type: "building", buildingId: "well", x: 8, y: 8 });
+    expect(m2?.def.entities[4]).toEqual({ type: "npc", npcId: "farmer", x: 10, y: 10, label: "Farmer" });
+
+    expect(await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8")).toBe(sceneText);
+  });
+});

+ 118 - 0
apps/editor/test/template.test.ts

@@ -0,0 +1,118 @@
+import { mkdtemp, readFile, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine } from "@nengine/core";
+import { newProject, sanitizeId } from "../src/template.js";
+
+async function tmpDir(): Promise<string> {
+  return mkdtemp(join(tmpdir(), "editor-template-"));
+}
+
+describe("sanitizeId", () => {
+  it("slugs names and falls back for empty input", () => {
+    expect(sanitizeId("My Farm!")).toBe("my-farm");
+    expect(sanitizeId("  山谷 农场")).toBe("farm");
+    expect(sanitizeId("")).toBe("farm");
+    expect(sanitizeId("Turnip_Vale")).toBe("turnipvale");
+  });
+});
+
+describe("newProject", () => {
+  it("generates the full template tree", async () => {
+    const dir = await tmpDir();
+    try {
+      const project = await newProject("Test Farm", dir);
+      expect(project.hasContent).toBe(true);
+      expect(project.hasMods).toBe(true);
+      const expected = [
+        "content/time.yaml",
+        "content/weather.yaml",
+        "content/interact.yaml",
+        "content/maps/test-farm.yaml",
+        "content/items/hoe.yaml",
+        "content/items/test-farm-seed.yaml",
+        "content/items/test-farm.yaml",
+        "content/crops/test-farm.yaml",
+        "content/scenes/test-farm.yaml",
+      ];
+      for (const rel of expected) {
+        await expect(stat(join(dir, rel))).resolves.toBeDefined();
+      }
+      await expect(stat(join(dir, "mods"))).resolves.toBeDefined();
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("boots the generated project through createEngine", async () => {
+    const dir = await tmpDir();
+    try {
+      const project = await newProject("Boot Farm", dir);
+      const engine = createEngine({ contentDir: join(project.root, "content") });
+      await engine.start();
+      try {
+        expect(engine.world.get("player")).toMatchObject({ id: "player", type: "player", x: 8, y: 8 });
+        const mapEntity = engine.world.query({ type: "map" })[0];
+        expect(mapEntity?.id).toBe("boot-farm");
+        const scenes = engine.registry.list("scene");
+        expect(scenes).toHaveLength(1);
+        expect(scenes[0]?.id).toBe("boot-farm");
+        const crops = engine.registry.list("crop");
+        expect(crops[0]?.seedItem).toBe("boot-farm-seed");
+        expect(engine.registry.list("time")).toHaveLength(1);
+        expect(engine.registry.list("weather")).toHaveLength(1);
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("interpolates the sanitized name into file ids", async () => {
+    const dir = await tmpDir();
+    try {
+      await newProject("Valley View", dir);
+      const crop = await readFile(join(dir, "content/crops/valley-view.yaml"), "utf8");
+      expect(crop).toContain("id: valley-view");
+      expect(crop).toContain("seedItem: valley-view-seed");
+      const scene = await readFile(join(dir, "content/scenes/valley-view.yaml"), "utf8");
+      expect(scene).toContain("entities: []");
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+});
+
+async function rmRecursive(dir: string): Promise<void> {
+  const { rm } = await import("node:fs/promises");
+  await rm(dir, { recursive: true, force: true });
+}
+
+describe("template map layout", () => {
+  it("lays out grass, water, and tilled tiles correctly", async () => {
+    const dir = await tmpDir();
+    try {
+      await newProject("Layout", dir);
+      const { parse } = await import("yaml");
+      const map = parse(await readFile(join(dir, "content/maps/layout.yaml"), "utf8")) as {
+        width: number;
+        height: number;
+        spawn: { x: number; y: number };
+        layers: { data: string[][] }[];
+      };
+      expect(map.width).toBe(12);
+      expect(map.height).toBe(12);
+      const data = map.layers[0]?.data ?? [];
+      expect(data[0]?.[0]).toBe("water");
+      expect(data[2]?.[2]).toBe("water");
+      expect(data[4]?.[4]).toBe("tilled");
+      expect(data[5]?.[7]).toBe("tilled");
+      expect(data[11]?.[11]).toBe("grass");
+      expect(data[map.spawn.y]?.[map.spawn.x]).toBe("grass");
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+});

+ 22 - 0
apps/editor/tsconfig.main.json

@@ -0,0 +1,22 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "noEmit": false,
+    "outDir": "dist/main",
+    "rootDir": "src",
+    "types": ["node"]
+  },
+  "include": [
+    "src/main.ts",
+    "src/mcp-server.ts",
+    "src/mcp-stdio.ts",
+    "src/edit-engine.ts",
+    "src/ipc.ts",
+    "src/pack.ts",
+    "src/pack-options.ts",
+    "src/project.ts",
+    "src/project-yaml.ts",
+    "src/recent.ts",
+    "src/template.ts"
+  ]
+}

+ 16 - 0
apps/editor/tsconfig.preload.json

@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "noEmit": false,
+    "outDir": "dist/preload",
+    "rootDir": "src",
+    "module": "CommonJS",
+    "moduleResolution": "Node",
+    "verbatimModuleSyntax": false,
+    "types": ["node"],
+    "paths": {
+      "@nengine/core": ["../../packages/core/dist/index.d.ts"]
+    }
+  },
+  "include": ["src/preload.ts", "src/ipc.ts"]
+}

+ 13 - 0
apps/editor/tsconfig.renderer.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "types": ["node"],
+    "noEmit": true,
+    "paths": {
+      "@nengine/core-schema": ["../../packages/core/src/schema/index.ts"],
+      "@nengine/core-systems": ["../../packages/core/src/systems/index.ts"]
+    }
+  },
+  "include": ["src/renderer/**/*.ts", "src/shim/**/*.ts", "src/ipc.ts", "src/project-yaml.ts"]
+}

+ 51 - 0
apps/editor/vite.config.ts

@@ -0,0 +1,51 @@
+import { fileURLToPath, URL } from "node:url";
+import { defineConfig } from "vitest/config";
+
+// The editor renderer is its own Vite app (root = apps/editor, own index.html).
+// The main process loads the built renderer from dist/renderer/ over a local
+// HTTP server (see src/main.ts), so no fs-shim middleware is needed here.
+// `@nengine/core` aliases to source for dev/test speed; E1 used core TYPES only,
+// E2 (map editing) also needs runtime `mapSchema`/`UNWALKABLE_TILES`. The full
+// core barrel is NOT browser-safe (loader/mods import node:fs) — E2–E4 import it
+// via two editor-only synthetic facets that alias core's PURE source modules
+// (schema/ and systems/ — no node builtins): `@nengine/core-schema` and
+// `@nengine/core-systems` (mirrored by "paths" in tsconfig.renderer.json). E5
+// (playtest) brings the FULL `@nengine/core` barrel into the renderer and
+// neutralizes its node:fs/node:path/node:url imports with the dev-app-proven
+// in-memory shim aliases (src/shim/ — bundle mode performs ZERO fs calls at
+// runtime, D30/D36, so the stubs are never invoked). `@nengine/render` (PixiJS
+// viewport + connectLocal) is browser-safe and aliased to source like core.
+// The node shim aliases are scoped to non-test builds: vitest (mode "test")
+// runs in Node where the real `node:*` builtins must stay intact (tests use
+// mkdtemp/writeFile on real tmp dirs).
+export default defineConfig(({ mode }) => {
+  const shimAliases = mode === "test" ? {} : {
+    "node:fs/promises": fileURLToPath(new URL("./src/shim/fs-promises.ts", import.meta.url)),
+    "node:fs": fileURLToPath(new URL("./src/shim/fs.ts", import.meta.url)),
+    "node:path": fileURLToPath(new URL("./src/shim/path.ts", import.meta.url)),
+    "node:url": fileURLToPath(new URL("./src/shim/url.ts", import.meta.url)),
+  };
+  return {
+    root: fileURLToPath(new URL(".", import.meta.url)),
+    resolve: {
+      alias: {
+        "@nengine/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
+        "@nengine/render": fileURLToPath(new URL("../../packages/render/src/index.ts", import.meta.url)),
+        "@nengine/core-schema": fileURLToPath(new URL("../../packages/core/src/schema/index.ts", import.meta.url)),
+        "@nengine/core-systems": fileURLToPath(new URL("../../packages/core/src/systems/index.ts", import.meta.url)),
+        ...shimAliases,
+      },
+    },
+    build: {
+      outDir: "dist/renderer",
+      emptyOutDir: true,
+      rollupOptions: {
+        input: fileURLToPath(new URL("./index.html", import.meta.url)),
+      },
+    },
+    test: {
+      include: ["test/**/*.test.ts"],
+      environment: "node",
+    },
+  };
+});

+ 17 - 5
apps/mcp-server/test/client-surface.test.ts

@@ -57,7 +57,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
     }
   });
 
-  it("tools surface: exactly 116 tools, all with object inputSchema", async () => {
+  it("tools surface: exactly 124 tools, all with object inputSchema", async () => {
     const { client, close } = await setup();
     try {
       const tools = await client.listTools();
@@ -116,8 +116,11 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
           "item.query_inventory",
           "item.update",
           "item.validate",
+          "map.create",
           "map.get",
           "map.list",
+          "map.update",
+          "map.validate",
           "mod.list",
           "mod.reload",
           "mod.status",
@@ -157,6 +160,11 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
           "shader.list",
           "shader.update",
           "shader.validate",
+          "scene.create",
+          "scene.get",
+          "scene.list",
+          "scene.update",
+          "scene.validate",
           "shop.buy",
           "shop.create",
           "shop.get",
@@ -182,7 +190,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
           "weather.set",
         ].sort(),
       );
-      expect(tools.tools).toHaveLength(116);
+      expect(tools.tools).toHaveLength(124);
       for (const tool of tools.tools) {
         expect(tool.inputSchema).toMatchObject({ type: "object" });
       }
@@ -225,6 +233,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
         "player",
         "rng",
         "save",
+        "scene",
         "server",
         "shader",
         "shop",
@@ -259,6 +268,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
         "player",
         "rng",
         "save",
+        "scene",
         "server",
         "shader",
         "shop",
@@ -755,7 +765,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
     }
   });
 
-  it("resources: 34 template URIs, all readable, live state reflected", async () => {
+  it("resources: 36 template URIs, all readable, live state reflected", async () => {
     const { client, close } = await setup();
     try {
       await client.callTool({ name: "time.advance", arguments: { ticks: 480 } });
@@ -764,7 +774,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
 
       const resources = await client.listResources();
       expect(resources.resources.map((r) => r.uri).sort()).toEqual(
-        ["animal://{id}", "animal://catalog", "animation://{id}", "animation://catalog", "behavior://statemachines", "behavior://triggers", "crop://{id}", "crop://catalog", "dialogue://{id}", "dialogue://catalog", "item://{id}", "item://catalog", "map://{mapId}", "mod://{id}", "npc://{npcId}", "particle://{id}", "particle://catalog", "placement://{id}", "placement://buildings", "placement://placed", "player://state", "rng://state", "save://state", "server://status", "shader://{id}", "shader://catalog", "shop://{shopId}", "shop://catalog", "sound://{id}", "sound://catalog", "sprite://{id}", "sprite://catalog", "time://state", "weather://state"].sort(),
+        ["animal://{id}", "animal://catalog", "animation://{id}", "animation://catalog", "behavior://statemachines", "behavior://triggers", "crop://{id}", "crop://catalog", "dialogue://{id}", "dialogue://catalog", "item://{id}", "item://catalog", "map://{mapId}", "mod://{id}", "npc://{npcId}", "particle://{id}", "particle://catalog", "placement://{id}", "placement://buildings", "placement://placed", "player://state", "rng://state", "save://state", "scene://{id}", "scene://catalog", "server://status", "shader://{id}", "shader://catalog", "shop://{shopId}", "shop://catalog", "sound://{id}", "sound://catalog", "sprite://{id}", "sprite://catalog", "time://state", "weather://state"].sort(),
       );
 
       const status = jsonOf(contentsOf(await client.readResource({ uri: "server://status" })));
@@ -789,7 +799,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
     }
   });
 
-  it("prompts: 15 authoring prompts, get returns guidance; missing prompt rejects", async () => {
+  it("prompts: 17 authoring prompts, get returns guidance; missing prompt rejects", async () => {
     const { client, close } = await setup();
     try {
       const prompts = await client.listPrompts();
@@ -801,9 +811,11 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
         "crop.create-prompt",
         "dialogue.create-prompt",
         "item.create-prompt",
+        "map.create-prompt",
         "particle.create-prompt",
         "placement.create-prompt",
         "save.authoring-prompt",
+        "scene.create-prompt",
         "server.orient",
         "shader.create-prompt",
         "shop.create-prompt",

Різницю між файлами не показано, бо вона завелика
+ 6 - 3
docs/api.md


Різницю між файлами не показано, бо вона завелика
+ 25 - 0
docs/decisions.md


+ 354 - 0
docs/editor.md

@@ -0,0 +1,354 @@
+# 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/<id>.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 <project>` — 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:<port>/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<kind, count>, total, mods, assets }`. |
+| `playtest:start` | `root` | `{ bundle: ContentBundle } \| { error: string }` | E5. Main runs `compileBundle(<root>/content, <root>/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:<port>/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 `<root>/content`, mods `<root>/mods` when the dialog asks AND the dir exists, outDir default `<root>/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 <dir>` 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 <modsDir>
+  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/<id>.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/<id>-seed.yaml # seed
+content/items/<id>.yaml      # the harvested crop (material)
+content/crops/<id>.yaml      # 3 stages (2/2/1 days, water required through grown), seasons: [spring], drops 1× <id>
+content/scenes/<id>.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/<id>.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/<id>.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:<id>:<n>`); 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 <dir>`). 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 <project> (content=<dir>/content, mods=<dir>/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 <project>   # 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 <project>` (pure Node, no Electron needed — this is what the tests spawn) or `electron . --mcp-stdio --dir <project>` (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/…/<id>.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:<port>/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 <project>` or `electron . --mcp-stdio --dir <project>` | 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 <root>/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`) | `<root>/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 `<root>/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 <dir>` + 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 → <outDir>` + 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
+
+`<outDir>/` 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/<id>.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.

Різницю між файлами не показано, бо вона завелика
+ 26 - 5
docs/mcp.md


+ 52 - 1
packages/core/src/engine.ts

@@ -18,9 +18,10 @@ import { PlacementSystem } from "./systems/placement.js";
 import { MovementSystem } from "./systems/movement.js";
 import { IntentSystem } from "./systems/intent.js";
 import type { System, SystemInitCtx } from "./systems/system.js";
+import type { NpcEntity } from "./systems/movement.js";
 import { World } from "./world/world.js";
 import { WorldEventBus } from "./world/event-bus.js";
-import type { MapDef } from "./schema/index.js";
+import type { MapDef, SceneDef } from "./schema/index.js";
 import { randomSeed, seedRandom, type RandomSource } from "./random.js";
 
 export interface EngineContentSourceOptions {
@@ -103,6 +104,7 @@ export function createEngine(opts: EngineOptions): Engine {
       }
       seedWorld();
       await initSystems();
+      seedScenes();
       if (opts.modsDir) {
         modStatuses = await modLoader.load(opts.modsDir);
       } else if ("bundle" in opts && opts.bundle.mods) {
@@ -186,6 +188,55 @@ export function createEngine(opts: EngineOptions): Engine {
     });
   }
 
+  function seedScenes(): void {
+    for (const scene of registry.list<SceneDef>("scene")) {
+      for (const entity of scene.entities) {
+        if (entity.type === "building") {
+          const placement = systems.get<PlacementSystem>("placement");
+          if (!placement) {
+            throw new Error(`scene: placement system not booted for scene ${scene.id}`);
+          }
+          try {
+            placement.place(entity.buildingId, entity.x, entity.y);
+          } catch (err) {
+            throw new Error(
+              `scene ${scene.id}: failed to place building ${entity.buildingId} at (${entity.x},${entity.y}): ${
+                err instanceof Error ? err.message : String(err)
+              }`,
+            );
+          }
+        } else if (entity.type === "animal") {
+          const growth = systems.get<GrowthSystem>("growth");
+          if (!growth) {
+            throw new Error(`scene: growth system not booted for scene ${scene.id}`);
+          }
+          try {
+            growth.spawnAnimal(entity.animalId, entity.x, entity.y);
+          } catch (err) {
+            throw new Error(
+              `scene ${scene.id}: failed to spawn animal ${entity.animalId} at (${entity.x},${entity.y}): ${
+                err instanceof Error ? err.message : String(err)
+              }`,
+            );
+          }
+        } else {
+          if (world.get(entity.npcId)) {
+            throw new Error(`scene ${scene.id}: duplicate npc entity id: ${entity.npcId}`);
+          }
+          const npc: NpcEntity = {
+            id: entity.npcId,
+            type: "npc",
+            x: entity.x,
+            y: entity.y,
+            ...(entity.dialogue !== undefined ? { dialogue: entity.dialogue } : {}),
+            ...(entity.label !== undefined ? { label: entity.label } : {}),
+          };
+          world.create(npc);
+        }
+      }
+    }
+  }
+
   function bundleModSources(mods: Record<string, BundledMod>): ModSource[] {
     return Object.values(mods).map((entry) => ({
       id: entry.manifest.id,

+ 10 - 0
packages/core/src/index.ts

@@ -114,6 +114,11 @@ export {
   animationSchema,
   animationStateSchema,
   animationTransitionSchema,
+  sceneSchema,
+  sceneEntitySchema,
+  sceneBuildingEntitySchema,
+  sceneNpcEntitySchema,
+  sceneAnimalEntitySchema,
   SAVE_FORMAT,
   CURRENT_SAVE_VERSION,
   worldSnapshotSchema,
@@ -172,6 +177,11 @@ export type {
   AnimationDef,
   AnimationState,
   AnimationTransition,
+  SceneDef,
+  SceneEntity,
+  SceneBuildingEntity,
+  SceneNpcEntity,
+  SceneAnimalEntity,
   WorldSnapshot,
 } from "./schema/index.js";
 

+ 5 - 0
packages/core/src/schema/index.ts

@@ -16,6 +16,7 @@ import { interactSchema, INTERACT_ACTIONS, DEFAULT_INTERACT_ORDER } from "./inte
 import { particleSchema, particleRangeSchema } from "./particle.js";
 import { shaderSchema } from "./shader.js";
 import { animationSchema, animationStateSchema, animationTransitionSchema } from "./animation.js";
+import { sceneSchema, sceneEntitySchema, sceneBuildingEntitySchema, sceneNpcEntitySchema, sceneAnimalEntitySchema } from "./scene.js";
 import { SAVE_FORMAT, CURRENT_SAVE_VERSION, worldSnapshotSchema } from "./save.js";
 
 export { mapSchema, mapLayerSchema } from "./map.js";
@@ -62,6 +63,8 @@ export { shaderSchema } from "./shader.js";
 export type { ShaderDef, ShaderFilterKind } from "./shader.js";
 export { animationSchema, animationStateSchema, animationTransitionSchema } from "./animation.js";
 export type { AnimationDef, AnimationState, AnimationTransition } from "./animation.js";
+export { sceneSchema, sceneEntitySchema, sceneBuildingEntitySchema, sceneNpcEntitySchema, sceneAnimalEntitySchema } from "./scene.js";
+export type { SceneDef, SceneEntity, SceneBuildingEntity, SceneNpcEntity, SceneAnimalEntity } from "./scene.js";
 export { SAVE_FORMAT, CURRENT_SAVE_VERSION, worldSnapshotSchema } from "./save.js";
 export type { WorldSnapshot } from "./save.js";
 
@@ -83,6 +86,7 @@ export const contentSchemas = {
   particle: particleSchema,
   shader: shaderSchema,
   animation: animationSchema,
+  scene: sceneSchema,
 } as const;
 
 export const contentDirNames: Record<ContentKind, string> = {
@@ -103,6 +107,7 @@ export const contentDirNames: Record<ContentKind, string> = {
   particle: "particles",
   shader: "shaders",
   animation: "animations",
+  scene: "scenes",
 };
 
 export type ContentKind = keyof typeof contentSchemas;

+ 51 - 0
packages/core/src/schema/scene.ts

@@ -0,0 +1,51 @@
+import { z } from "zod";
+
+const sceneCoordSchema = z.number().int().nonnegative();
+
+export const sceneBuildingEntitySchema = z
+  .object({
+    type: z.literal("building"),
+    buildingId: z.string().min(1),
+    x: sceneCoordSchema,
+    y: sceneCoordSchema,
+  })
+  .strict();
+
+export const sceneNpcEntitySchema = z
+  .object({
+    type: z.literal("npc"),
+    npcId: z.string().min(1),
+    x: sceneCoordSchema,
+    y: sceneCoordSchema,
+    dialogue: z.string().min(1).optional(),
+    label: z.string().min(1).optional(),
+  })
+  .strict();
+
+export const sceneAnimalEntitySchema = z
+  .object({
+    type: z.literal("animal"),
+    animalId: z.string().min(1),
+    x: sceneCoordSchema,
+    y: sceneCoordSchema,
+  })
+  .strict();
+
+export const sceneEntitySchema = z.discriminatedUnion("type", [
+  sceneBuildingEntitySchema,
+  sceneNpcEntitySchema,
+  sceneAnimalEntitySchema,
+]);
+
+export const sceneSchema = z
+  .object({
+    id: z.string().min(1),
+    entities: z.array(sceneEntitySchema),
+  })
+  .strict();
+
+export type SceneBuildingEntity = z.infer<typeof sceneBuildingEntitySchema>;
+export type SceneNpcEntity = z.infer<typeof sceneNpcEntitySchema>;
+export type SceneAnimalEntity = z.infer<typeof sceneAnimalEntitySchema>;
+export type SceneEntity = z.infer<typeof sceneEntitySchema>;
+export type SceneDef = z.infer<typeof sceneSchema>;

+ 284 - 0
packages/core/test/scene.test.ts

@@ -0,0 +1,284 @@
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine, sceneSchema } from "../src/index.js";
+
+const TIME_YAML = [
+  "id: default",
+  "dayLengthTicks: 480",
+  "seasons:",
+  "  - { name: spring, dayCount: 28 }",
+  "start: { day: 1, season: spring }",
+  "",
+].join("\n");
+
+const FARM_YAML = [
+  "id: farm",
+  "width: 5",
+  "height: 5",
+  'tilesetRef: "tiles"',
+  "spawn: { x: 0, y: 0 }",
+  "layers:",
+  "  - name: ground",
+  "    data:",
+  "      - [grass, grass, grass, grass, grass]",
+  "      - [grass, grass, grass, grass, grass]",
+  "      - [grass, grass, grass, grass, water]",
+  "      - [grass, grass, grass, grass, water]",
+  "      - [grass, grass, grass, grass, grass]",
+  "",
+].join("\n");
+
+const SHED_YAML = [
+  "id: shed",
+  "name: Shed",
+  "footprint: { w: 2, h: 2 }",
+  "buildable: any",
+  "blocksMovement: true",
+  "",
+].join("\n");
+
+const FENCE_YAML = [
+  "id: fence",
+  "name: Fence",
+  "footprint: { w: 1, h: 1 }",
+  "buildable: { tiles: [grass] }",
+  "blocksMovement: true",
+  "",
+].join("\n");
+
+const CHICKEN_YAML = [
+  "id: chicken",
+  "stages:",
+  "  - { name: chick, days: 2, feedRequired: false }",
+  "  - { name: grown, days: 2, feedRequired: true }",
+  "produces:",
+  "  - { itemId: egg, qty: 1 }",
+  "",
+].join("\n");
+
+interface Setup {
+  engine: ReturnType<typeof createEngine>;
+  close: () => Promise<void>;
+}
+
+async function boot(sceneYamls: Record<string, string> = {}): Promise<Setup> {
+  const tmp = await mkdtemp(join(tmpdir(), "scene-"));
+  await mkdir(join(tmp, "maps"), { recursive: true });
+  await mkdir(join(tmp, "buildings"), { recursive: true });
+  await mkdir(join(tmp, "animals"), { recursive: true });
+  await writeFile(join(tmp, "time.yaml"), TIME_YAML);
+  await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
+  await writeFile(join(tmp, "buildings", "shed.yaml"), SHED_YAML);
+  await writeFile(join(tmp, "buildings", "fence.yaml"), FENCE_YAML);
+  await writeFile(join(tmp, "animals", "chicken.yaml"), CHICKEN_YAML);
+  if (Object.keys(sceneYamls).length > 0) {
+    await mkdir(join(tmp, "scenes"), { recursive: true });
+    for (const [id, yaml] of Object.entries(sceneYamls)) {
+      await writeFile(join(tmp, "scenes", `${id}.yaml`), yaml);
+    }
+  }
+  const engine = createEngine({ contentDir: tmp });
+  try {
+    await engine.start();
+  } catch (err) {
+    await rm(tmp, { recursive: true, force: true });
+    throw err;
+  }
+  return {
+    engine,
+    close: async () => {
+      await engine.stop();
+      await rm(tmp, { recursive: true, force: true });
+    },
+  };
+}
+
+function sceneYaml(id: string, entities: string[]): string {
+  return [`id: ${id}`, "entities:", ...entities.map((entity) => `  - ${entity}`), ""].join("\n");
+}
+
+describe("sceneSchema", () => {
+  it("accepts a valid building/npc/animal mix and an empty entity list", () => {
+    expect(
+      sceneSchema.safeParse({
+        id: "farm-start",
+        entities: [
+          { type: "building", buildingId: "shed", x: 0, y: 0 },
+          { type: "npc", npcId: "mayor", x: 1, y: 1, dialogue: "welcome", label: "Mayor" },
+          { type: "animal", animalId: "chicken", x: 2, y: 2 },
+        ],
+      }).success,
+    ).toBe(true);
+
+    expect(sceneSchema.safeParse({ id: "empty", entities: [] }).success).toBe(true);
+  });
+
+  it("rejects discriminated-union misuse and unknown entity types", () => {
+    const npcWithBuildingId = sceneSchema.safeParse({
+      id: "s",
+      entities: [{ type: "npc", buildingId: "shed", x: 0, y: 0 }],
+    });
+    expect(npcWithBuildingId.success).toBe(false);
+
+    const unknownType = sceneSchema.safeParse({
+      id: "s",
+      entities: [{ type: "plot", x: 0, y: 0 }],
+    });
+    expect(unknownType.success).toBe(false);
+
+    const missingType = sceneSchema.safeParse({
+      id: "s",
+      entities: [{ buildingId: "shed", x: 0, y: 0 }],
+    });
+    expect(missingType.success).toBe(false);
+  });
+
+  it("rejects negative coords, non-integer coords, and unknown keys", () => {
+    expect(
+      sceneSchema.safeParse({ id: "s", entities: [{ type: "building", buildingId: "shed", x: -1, y: 0 }] }).success,
+    ).toBe(false);
+
+    expect(
+      sceneSchema.safeParse({ id: "s", entities: [{ type: "npc", npcId: "m", x: 0, y: 1.5 }] }).success,
+    ).toBe(false);
+
+    expect(
+      sceneSchema.safeParse({ id: "s", entities: [{ type: "animal", animalId: "c", x: 0, y: 0, extra: 1 }] }).success,
+    ).toBe(false);
+  });
+
+  it("rejects a missing id and non-array entities", () => {
+    expect(sceneSchema.safeParse({ entities: [] }).success).toBe(false);
+    expect(sceneSchema.safeParse({ id: "s", entities: "nope" }).success).toBe(false);
+  });
+});
+
+describe("scene seed instantiation", () => {
+  it("instantiates a building via PlacementSystem semantics with the runtime entity id", async () => {
+    const { engine, close } = await boot({
+      "farm-start": sceneYaml("farm-start", [
+        "{ type: building, buildingId: shed, x: 0, y: 0 }",
+        "{ type: building, buildingId: fence, x: 3, y: 3 }",
+      ]),
+    });
+    try {
+      const shed = engine.world.get("building:shed:0:0");
+      expect(shed).toMatchObject({
+        id: "building:shed:0:0",
+        type: "building",
+        buildingId: "shed",
+        x: 0,
+        y: 0,
+        w: 2,
+        h: 2,
+        blocksMovement: true,
+      });
+      expect(engine.world.get("building:fence:3:3")).toMatchObject({ type: "building", buildingId: "fence" });
+    } finally {
+      await close();
+    }
+  });
+
+  it("fails boot loudly when two scene buildings overlap", async () => {
+    await expect(
+      boot({
+        "farm-start": sceneYaml("farm-start", [
+          "{ type: building, buildingId: shed, x: 0, y: 0 }",
+          "{ type: building, buildingId: fence, x: 1, y: 1 }",
+        ]),
+      }),
+    ).rejects.toThrow(/overlaps existing building/);
+  });
+
+  it("fails boot loudly when a scene building is not buildable on its tile", async () => {
+    await expect(
+      boot({
+        "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: fence, x: 4, y: 2 }"]),
+      }),
+    ).rejects.toThrow(/not buildable for fence/);
+  });
+
+  it("fails boot loudly when a scene references an unknown building def", async () => {
+    await expect(
+      boot({
+        "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: barn, x: 0, y: 0 }"]),
+      }),
+    ).rejects.toThrow(/unknown building: barn/);
+  });
+
+  it("creates an npc entity with optional dialogue and label fields", async () => {
+    const { engine, close } = await boot({
+      "farm-start": sceneYaml("farm-start", [
+        "{ type: npc, npcId: mayor, x: 1, y: 1, dialogue: welcome, label: Mayor }",
+        "{ type: npc, npcId: villager, x: 3, y: 1 }",
+      ]),
+    });
+    try {
+      expect(engine.world.get("mayor")).toMatchObject({
+        id: "mayor",
+        type: "npc",
+        x: 1,
+        y: 1,
+        dialogue: "welcome",
+        label: "Mayor",
+      });
+      expect(engine.world.get("villager")).toEqual({ id: "villager", type: "npc", x: 3, y: 1 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("fails boot loudly on a duplicate npc entity id", async () => {
+    await expect(
+      boot({
+        "farm-start": sceneYaml("farm-start", [
+          "{ type: npc, npcId: mayor, x: 1, y: 1 }",
+          "{ type: npc, npcId: mayor, x: 2, y: 2 }",
+        ]),
+      }),
+    ).rejects.toThrow(/duplicate npc entity id: mayor/);
+  });
+
+  it("spawns animals via GrowthSystem.spawnAnimal with the runtime entity id", async () => {
+    const { engine, close } = await boot({
+      "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: chicken, x: 2, y: 2 }"]),
+    });
+    try {
+      expect(engine.world.get("animal:chicken:1")).toMatchObject({
+        id: "animal:chicken:1",
+        type: "animal",
+        animalId: "chicken",
+        x: 2,
+        y: 2,
+        stageIndex: 0,
+        daysInStage: 0,
+        fed: false,
+      });
+    } finally {
+      await close();
+    }
+  });
+
+  it("fails boot loudly when a scene references an unknown animal def", async () => {
+    await expect(
+      boot({
+        "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: cow, x: 2, y: 2 }"]),
+      }),
+    ).rejects.toThrow(/unknown animal def: cow/);
+  });
+
+  it("boots unchanged when zero scenes are registered", async () => {
+    const { engine, close } = await boot();
+    try {
+      expect(engine.registry.list("scene")).toEqual([]);
+      expect(engine.world.query({ type: "building" })).toHaveLength(0);
+      expect(engine.world.query({ type: "npc" })).toHaveLength(0);
+      expect(engine.world.query({ type: "animal" })).toHaveLength(0);
+      expect(engine.world.get("player")).toMatchObject({ id: "player", type: "player", x: 0, y: 0 });
+    } finally {
+      await close();
+    }
+  });
+});

+ 133 - 0
packages/mcp/src/authoring.ts

@@ -0,0 +1,133 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { stringify } from "yaml";
+import { contentDirNames, contentSchemas, type ContentDef, type ContentKind, type Engine } from "@nengine/core";
+import type { McpTool } from "./namespace.js";
+import { errorResult, requireContentDir, textResult } from "./result.js";
+
+export type AuthoringResult = { ok: true; def: ContentDef<ContentKind> } | { ok: false; message: string };
+
+export function validateDef(kind: ContentKind, raw: unknown, tool: string): AuthoringResult {
+  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
+    return { ok: false, message: `${tool}: def must be a JSON object` };
+  }
+  const result = contentSchemas[kind].safeParse(raw);
+  if (!result.success) {
+    return {
+      ok: false,
+      message: `${tool}: ${result.error.issues
+        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
+        .join("; ")}`,
+    };
+  }
+  return { ok: true, def: result.data };
+}
+
+export async function writeContentFile(engine: Engine, kind: ContentKind, def: ContentDef<ContentKind>): Promise<void> {
+  const dir = resolve(requireContentDir(engine, kind), contentDirNames[kind]);
+  await mkdir(dir, { recursive: true });
+  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
+  engine.registry.register(kind, def);
+}
+
+export interface UpdateContentFileOptions {
+  tool: string;
+  errorNoun: string;
+}
+
+export async function updateContentFile(
+  engine: Engine,
+  kind: ContentKind,
+  id: string,
+  patch: Record<string, unknown>,
+  options: UpdateContentFileOptions,
+): Promise<AuthoringResult> {
+  const existing = engine.registry.get<ContentDef<ContentKind>>(kind, id);
+  if (!existing) {
+    return { ok: false, message: `${options.tool}.update: ${options.errorNoun} not found: ${id}` };
+  }
+  const merged = { ...existing, ...patch, id };
+  const parsed = validateDef(kind, merged, `${options.tool}.update`);
+  if (!parsed.ok) return parsed;
+  engine.registry.remove(kind, id);
+  await writeContentFile(engine, kind, parsed.def);
+  return parsed;
+}
+
+export interface AuthoringToolOptions extends UpdateContentFileOptions {
+  subname: string;
+  title: string;
+  article: "a" | "an";
+  schemaLabel?: string;
+  full?: boolean;
+}
+
+export function authoringTools(engine: Engine, kind: ContentKind, options: AuthoringToolOptions): McpTool[] {
+  const schemaLabel = options.schemaLabel ?? `${options.title} schema`;
+  const validateDescription =
+    `Validate ${options.article} ${options.full ? "full " : ""}${options.title} definition (JSON) against the ${schemaLabel}; returns the normalized def.`;
+  const createDescription =
+    `Create ${options.article} ${options.title} definition: validates, writes content/${contentDirNames[kind]}/<id>.yaml, and registers it live.`;
+  const updateDescription =
+    `Patch an existing ${options.title} definition (merge + validate + rewrite content/${contentDirNames[kind]}/<id>.yaml + re-register).`;
+
+  return [
+    {
+      name: `${options.subname}validate`,
+      description: validateDescription,
+      inputSchema: {
+        type: "object",
+        properties: { def: { type: "object", description: `${options.title} definition to validate.` } },
+        required: ["def"],
+      },
+      handler: (args) => {
+        const parsed = validateDef(kind, args["def"], `${options.tool}.validate`);
+        if (!parsed.ok) return errorResult(parsed.message);
+        return textResult(parsed.def);
+      },
+    },
+    {
+      name: `${options.subname}create`,
+      description: createDescription,
+      inputSchema: {
+        type: "object",
+        properties: { def: { type: "object", description: `${options.title} definition to create.` } },
+        required: ["def"],
+      },
+      handler: async (args) => {
+        const parsed = validateDef(kind, args["def"], `${options.tool}.create`);
+        if (!parsed.ok) return errorResult(parsed.message);
+        await writeContentFile(engine, kind, parsed.def);
+        return textResult(parsed.def);
+      },
+    },
+    {
+      name: `${options.subname}update`,
+      description: updateDescription,
+      inputSchema: {
+        type: "object",
+        properties: {
+          id: { type: "string", description: `Id of the ${options.title} to update.` },
+          patch: { type: "object", description: `Partial ${options.title} definition to merge over the existing one.` },
+        },
+        required: ["id", "patch"],
+      },
+      handler: async (args) => {
+        const id = args["id"];
+        if (typeof id !== "string" || id.length === 0) {
+          return errorResult(`${options.tool}.update: id must be a non-empty string`);
+        }
+        if (!engine.registry.get(kind, id)) {
+          return errorResult(`${options.tool}.update: ${options.errorNoun} not found: ${id}`);
+        }
+        const patch = args["patch"];
+        if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
+          return errorResult(`${options.tool}.update: patch must be a JSON object`);
+        }
+        const parsed = await updateContentFile(engine, kind, id, patch as Record<string, unknown>, options);
+        if (!parsed.ok) return errorResult(parsed.message);
+        return textResult(parsed.def);
+      },
+    },
+  ];
+}

+ 3 - 0
packages/mcp/src/index.ts

@@ -22,6 +22,7 @@ import { createRngNamespace } from "./namespaces/rng.js";
 import { createParticleNamespace } from "./namespaces/particle.js";
 import { createShaderNamespace } from "./namespaces/shader.js";
 import { createAnimationNamespace } from "./namespaces/animation.js";
+import { createSceneNamespace } from "./namespaces/scene.js";
 
 export { McpGateway, McpGatewayError } from "./gateway.js";
 export type {
@@ -65,6 +66,7 @@ export { createRngNamespace } from "./namespaces/rng.js";
 export { createParticleNamespace } from "./namespaces/particle.js";
 export { createShaderNamespace } from "./namespaces/shader.js";
 export { createAnimationNamespace } from "./namespaces/animation.js";
+export { createSceneNamespace } from "./namespaces/scene.js";
 
 export interface EngineNamespacesOptions {
   gateway?: McpGateway | undefined;
@@ -93,5 +95,6 @@ export function createEngineNamespaces(engine: Engine, options: EngineNamespaces
     createParticleNamespace(engine),
     createShaderNamespace(engine),
     createAnimationNamespace(engine),
+    createSceneNamespace(engine),
   ];
 }

+ 10 - 97
packages/mcp/src/namespaces/animal.ts

@@ -1,86 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import {
-  animalSchema,
-  contentDirNames,
-  type AnimalDef,
-  type AnimalEntity,
-  type Engine,
-  type GrowthSystem,
-} from "@nengine/core";
+import type { AnimalDef, AnimalEntity, Engine, GrowthSystem } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createAnimalNamespace(engine: Engine): McpNamespace {
   return {
     name: "animal",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate an animal definition (JSON) against the animal schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Animal definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "animal.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create an animal definition: validates, writes content/animals/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Animal definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "animal.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeAnimalFile(engine, parsed.def);
-          engine.registry.register("animal", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing animal definition (merge + validate + rewrite content/animals/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the animal to update." },
-            patch: { type: "object", description: "Partial animal definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("animal.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<AnimalDef>("animal", id);
-          if (!existing) {
-            return errorResult(`animal.update: animal not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("animal.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "animal.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("animal", id);
-          await writeAnimalFile(engine, parsed.def);
-          engine.registry.register("animal", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "animal", {
+        tool: "animal",
+        subname: "",
+        title: "animal",
+        errorNoun: "animal",
+        article: "an",
+      }),
       {
         name: "list",
         description: "List registered animal definitions with one-line summaries.",
@@ -259,28 +194,6 @@ function isCoord(value: unknown): value is number {
   return typeof value === "number" && Number.isInteger(value) && value >= 0;
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: AnimalDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = animalSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeAnimalFile(engine: Engine, def: AnimalDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "animal"), contentDirNames["animal"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring an animal definition for the nEngine.",
   "",

+ 10 - 90
packages/mcp/src/namespaces/animation.ts

@@ -1,79 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import { animationSchema, contentDirNames, type AnimationDef, type Engine } from "@nengine/core";
+import type { AnimationDef, Engine } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createAnimationNamespace(engine: Engine): McpNamespace {
   return {
     name: "animation",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate an animation definition (JSON) against the animation schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Animation definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "animation.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create an animation definition: validates, writes content/animations/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Animation definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "animation.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeAnimationFile(engine, parsed.def);
-          engine.registry.register("animation", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing animation definition (merge + validate + rewrite content/animations/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the animation to update." },
-            patch: { type: "object", description: "Partial animation definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("animation.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<AnimationDef>("animation", id);
-          if (!existing) {
-            return errorResult(`animation.update: animation not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("animation.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "animation.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("animation", id);
-          await writeAnimationFile(engine, parsed.def);
-          engine.registry.register("animation", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "animation", {
+        tool: "animation",
+        subname: "",
+        title: "animation",
+        errorNoun: "animation",
+        article: "an",
+      }),
       {
         name: "list",
         description: "List registered animation ids with one-line summaries.",
@@ -136,28 +78,6 @@ function animationSummaries(engine: Engine): { id: string; stateCount: number }[
     .map((animation) => ({ id: animation.id, stateCount: animation.states.length }));
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: AnimationDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = animationSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeAnimationFile(engine: Engine, def: AnimationDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "animation"), contentDirNames["animation"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring an animation definition for the nEngine.",
   "",

+ 21 - 180
packages/mcp/src/namespaces/behavior.ts

@@ -1,18 +1,7 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import {
-  conditionExprSchema,
-  contentDirNames,
-  stateMachineSchema,
-  triggerSchema,
-  type Engine,
-  type StateMachineDef,
-  type TriggerDef,
-  type TriggerSystem,
-} from "@nengine/core";
+import { conditionExprSchema, type Engine, type StateMachineDef, type TriggerDef, type TriggerSystem } from "@nengine/core";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createBehaviorNamespace(engine: Engine): McpNamespace {
   const triggerSystem = (): TriggerSystem | undefined => engine.systems.get<TriggerSystem>("trigger");
@@ -21,69 +10,15 @@ export function createBehaviorNamespace(engine: Engine): McpNamespace {
     name: "behavior",
     enabled: true,
     tools: [
-      {
-        name: "trigger.validate",
-        description: "Validate a full trigger definition (JSON) against the behavior schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Trigger definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "behavior.trigger.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "trigger.create",
-        description: "Create a trigger definition: validates, writes content/behaviors/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Trigger definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "behavior.trigger.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeTriggerFile(engine, parsed.def);
-          engine.registry.register("behavior", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "trigger.update",
-        description: "Patch an existing trigger definition (merge + validate + rewrite content/behaviors/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the trigger to update." },
-            patch: { type: "object", description: "Partial trigger definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("behavior.trigger.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<TriggerDef>("behavior", id);
-          if (!existing) {
-            return errorResult(`behavior.trigger.update: trigger not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("behavior.trigger.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "behavior.trigger.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("behavior", id);
-          await writeTriggerFile(engine, parsed.def);
-          engine.registry.register("behavior", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "behavior", {
+        tool: "behavior.trigger",
+        subname: "trigger.",
+        title: "trigger",
+        errorNoun: "trigger",
+        article: "a",
+        schemaLabel: "behavior schema",
+        full: true,
+      }),
       {
         name: "trigger.list",
         description: "List registered trigger ids with one-line summaries.",
@@ -144,69 +79,15 @@ export function createBehaviorNamespace(engine: Engine): McpNamespace {
           return textResult(validateCondition(args["condition"]));
         },
       },
-      {
-        name: "statemachine.validate",
-        description: "Validate a full state machine definition (JSON) against the statemachine schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "State machine definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseMachineDef(args["def"], "behavior.statemachine.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "statemachine.create",
-        description: "Create a state machine definition: validates, writes content/statemachines/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "State machine definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseMachineDef(args["def"], "behavior.statemachine.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeMachineFile(engine, parsed.def);
-          engine.registry.register("statemachine", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "statemachine.update",
-        description: "Patch an existing state machine definition (merge + validate + rewrite content/statemachines/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the state machine to update." },
-            patch: { type: "object", description: "Partial state machine definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("behavior.statemachine.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<StateMachineDef>("statemachine", id);
-          if (!existing) {
-            return errorResult(`behavior.statemachine.update: machine not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("behavior.statemachine.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseMachineDef(merged, "behavior.statemachine.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("statemachine", id);
-          await writeMachineFile(engine, parsed.def);
-          engine.registry.register("statemachine", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "statemachine", {
+        tool: "behavior.statemachine",
+        subname: "statemachine.",
+        title: "state machine",
+        errorNoun: "machine",
+        article: "a",
+        schemaLabel: "statemachine schema",
+        full: true,
+      }),
       {
         name: "statemachine.list",
         description: "List registered state machine ids with one-line summaries.",
@@ -333,50 +214,10 @@ function validateCondition(raw: unknown): { ok: true } | { ok: false; errors: st
   return { ok: false, errors: formatIssues(result.error) };
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: TriggerDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = triggerSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${formatIssues(result.error).join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-function parseMachineDef(raw: unknown, tool: string): { ok: true; def: StateMachineDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = stateMachineSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${formatIssues(result.error).join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
 function formatIssues(error: { issues: { path: (string | number)[]; message: string }[] }): string[] {
   return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
 }
 
-async function writeTriggerFile(engine: Engine, def: TriggerDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "behavior"), contentDirNames["behavior"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
-async function writeMachineFile(engine: Engine, def: StateMachineDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "statemachine"), contentDirNames["statemachine"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const triggerPromptText = [
   "You are authoring a trigger definition for the nEngine (behavior language v1).",
   "",

+ 10 - 99
packages/mcp/src/namespaces/crop.ts

@@ -1,88 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import {
-  contentDirNames,
-  cropSchema,
-  plotIdFor,
-  type CropDef,
-  type Engine,
-  type GrowthSystem,
-  type PlantingSystem,
-  type PlotEntity,
-} from "@nengine/core";
+import { plotIdFor, type CropDef, type Engine, type GrowthSystem, type PlantingSystem, type PlotEntity } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createCropNamespace(engine: Engine): McpNamespace {
   return {
     name: "crop",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate a crop definition (JSON) against the crop schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Crop definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "crop.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create a crop definition: validates, writes content/crops/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Crop definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "crop.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeCropFile(engine, parsed.def);
-          engine.registry.register("crop", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing crop definition (merge + validate + rewrite content/crops/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the crop to update." },
-            patch: { type: "object", description: "Partial crop definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("crop.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<CropDef>("crop", id);
-          if (!existing) {
-            return errorResult(`crop.update: crop not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("crop.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "crop.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("crop", id);
-          await writeCropFile(engine, parsed.def);
-          engine.registry.register("crop", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "crop", {
+        tool: "crop",
+        subname: "",
+        title: "crop",
+        errorNoun: "crop",
+        article: "a",
+      }),
       {
         name: "list",
         description: "List registered crop ids with one-line summaries.",
@@ -342,28 +275,6 @@ function resolveCoordArgs(engine: Engine, args: Record<string, unknown>): { x: n
   throw new Error("crop: provide plotId or x/y coordinates");
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: CropDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = cropSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeCropFile(engine: Engine, def: CropDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "crop"), contentDirNames["crop"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring a crop definition for the nEngine.",
   "",

+ 10 - 96
packages/mcp/src/namespaces/dialogue.ts

@@ -1,16 +1,8 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import {
-  contentDirNames,
-  dialogueSchema,
-  type DialogueDef,
-  type DialogueSystem,
-  type Engine,
-} from "@nengine/core";
+import type { DialogueDef, DialogueSystem, Engine } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createDialogueNamespace(engine: Engine): McpNamespace {
   const dialogueSystem = (): DialogueSystem | undefined => engine.systems.get<DialogueSystem>("dialogue");
@@ -19,69 +11,13 @@ export function createDialogueNamespace(engine: Engine): McpNamespace {
     name: "dialogue",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate a dialogue definition (JSON) against the dialogue schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Dialogue definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "dialogue.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create a dialogue definition: validates, writes content/dialogues/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Dialogue definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "dialogue.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeDialogueFile(engine, parsed.def);
-          engine.registry.register("dialogue", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing dialogue definition (merge + validate + rewrite content/dialogues/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the dialogue to update." },
-            patch: { type: "object", description: "Partial dialogue definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("dialogue.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<DialogueDef>("dialogue", id);
-          if (!existing) {
-            return errorResult(`dialogue.update: dialogue not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("dialogue.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "dialogue.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("dialogue", id);
-          await writeDialogueFile(engine, parsed.def);
-          engine.registry.register("dialogue", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "dialogue", {
+        tool: "dialogue",
+        subname: "",
+        title: "dialogue",
+        errorNoun: "dialogue",
+        article: "a",
+      }),
       {
         name: "list",
         description: "List registered dialogue ids with one-line summaries.",
@@ -190,28 +126,6 @@ function dialogueSummaries(engine: Engine): { id: string; nodeCount: number; sta
   }));
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: DialogueDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = dialogueSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeDialogueFile(engine: Engine, def: DialogueDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "dialogue"), contentDirNames["dialogue"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring a dialogue definition for the nEngine (dialogue content kind).",
   "",

+ 10 - 90
packages/mcp/src/namespaces/item.ts

@@ -1,79 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import { contentDirNames, itemSchema, type Engine, type InventorySystem, type ItemDef } from "@nengine/core";
+import type { Engine, InventorySystem, ItemDef } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createItemNamespace(engine: Engine): McpNamespace {
   return {
     name: "item",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate an item definition (JSON) against the item schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Item definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "item.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create an item definition: validates, writes content/items/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Item definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "item.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeItemFile(engine, parsed.def);
-          engine.registry.register("item", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing item definition (merge + validate + rewrite content/items/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the item to update." },
-            patch: { type: "object", description: "Partial item definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("item.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<ItemDef>("item", id);
-          if (!existing) {
-            return errorResult(`item.update: item not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("item.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "item.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("item", id);
-          await writeItemFile(engine, parsed.def);
-          engine.registry.register("item", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "item", {
+        tool: "item",
+        subname: "",
+        title: "item",
+        errorNoun: "item",
+        article: "an",
+      }),
       {
         name: "list",
         description: "List registered item ids with one-line summaries.",
@@ -177,28 +119,6 @@ function inventorySystem(engine: Engine): InventorySystem | undefined {
   return engine.systems.get<InventorySystem>("inventory");
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: ItemDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = itemSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeItemFile(engine: Engine, def: ItemDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "item"), contentDirNames["item"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring an item definition for the nEngine.",
   "",

+ 54 - 2
packages/mcp/src/namespaces/map.ts

@@ -1,7 +1,8 @@
 import type { Engine, MapDef } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, textResult } from "../result.js";
+import { errorResult, promptResult, textResult } from "../result.js";
 
 export function createMapNamespace(engine: Engine): McpNamespace {
   return {
@@ -30,6 +31,13 @@ export function createMapNamespace(engine: Engine): McpNamespace {
           }
         },
       },
+      ...authoringTools(engine, "map", {
+        tool: "map",
+        subname: "",
+        title: "map",
+        errorNoun: "map",
+        article: "a",
+      }),
     ],
     resources: [
       {
@@ -41,7 +49,13 @@ export function createMapNamespace(engine: Engine): McpNamespace {
         },
       },
     ],
-    prompts: [],
+    prompts: [
+      {
+        name: "create-prompt",
+        description: "Guided template for authoring a well-formed map definition.",
+        get: () => promptResult(createPromptText),
+      },
+    ],
   };
 }
 
@@ -67,3 +81,41 @@ function resolveMap(engine: Engine, mapId: string | undefined): MapDef | undefin
   if (mapId) return engine.registry.get<MapDef>("map", mapId);
   return engine.registry.list<MapDef>("map")[0];
 }
+
+const createPromptText = [
+  "You are authoring a map definition for the nEngine.",
+  "",
+  "A map is a data-only definition (no code): a rectangular tile grid with",
+  "layers of tile ids, a player spawn point, and optional visual/walkability",
+  "metadata.",
+  "",
+  "- id: unique kebab-case string (e.g. farm)",
+  "- width: positive integer — the grid width in tiles (every layer row must be exactly this wide)",
+  "- height: positive integer — the grid height in tiles (every layer must have exactly this many rows)",
+  "- tilesetRef: string — the tileset/palette this map is drawn with (e.g. farm-tiles)",
+  "- spawn: { x, y } — the tile where the player entity starts (non-negative integers)",
+  "- layers: array of 1+ layers; each is { name, data }",
+  "  - name: the layer label (the terrain layer is conventionally named \"ground\" — placement/npc/intent read it first, else the first layer)",
+  "  - data: array of rows, each an array of tile-id strings of length == width; row count == height",
+  "- walkable: optional Record<tileId, boolean> (D33) — declared tile walkability; an undeclared tile id falls back to the",
+  "  UNWALKABLE_TILES constant (default {\"water\"} is not walkable)",
+  "- tiles: optional Record<tileId, assetId> (D31) — maps a tile id to an image asset the viewport draws; absent/unmapped ids fall back to the palette",
+  "",
+  "Example:",
+  "",
+  "  id: farm",
+  "  width: 2",
+  "  height: 2",
+  '  tilesetRef: "farm-tiles"',
+  "  spawn: { x: 0, y: 0 }",
+  "  walkable: { water: false }",
+  "  layers:",
+  "    - name: ground",
+  "      data:",
+  "        - [grass, grass]",
+  "        - [grass, water]",
+  "",
+  "Workflow: call map.validate with your candidate definition first; on success",
+  "call map.create to write content/maps/<id>.yaml and register it live. Use",
+  "map.update to patch an existing map.",
+].join("\n");

+ 10 - 90
packages/mcp/src/namespaces/particle.ts

@@ -1,79 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import { contentDirNames, particleSchema, type Engine, type ParticleDef } from "@nengine/core";
+import type { Engine, ParticleDef } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createParticleNamespace(engine: Engine): McpNamespace {
   return {
     name: "particle",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate a particle definition (JSON) against the particle schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Particle definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "particle.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create a particle definition: validates, writes content/particles/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Particle definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "particle.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeParticleFile(engine, parsed.def);
-          engine.registry.register("particle", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing particle definition (merge + validate + rewrite content/particles/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the particle to update." },
-            patch: { type: "object", description: "Partial particle definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("particle.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<ParticleDef>("particle", id);
-          if (!existing) {
-            return errorResult(`particle.update: particle not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("particle.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "particle.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("particle", id);
-          await writeParticleFile(engine, parsed.def);
-          engine.registry.register("particle", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "particle", {
+        tool: "particle",
+        subname: "",
+        title: "particle",
+        errorNoun: "particle",
+        article: "a",
+      }),
       {
         name: "list",
         description: "List registered particle ids with one-line summaries.",
@@ -134,28 +76,6 @@ function particleSummaries(engine: Engine): { id: string; emitRate: number | und
   return engine.registry.list<ParticleDef>("particle").map((particle) => ({ id: particle.id, emitRate: particle.emitRate }));
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: ParticleDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = particleSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeParticleFile(engine: Engine, def: ParticleDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "particle"), contentDirNames["particle"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring a particle definition for the nEngine.",
   "",

+ 10 - 90
packages/mcp/src/namespaces/placement.ts

@@ -1,10 +1,8 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import { contentDirNames, buildingSchema, type BuildingDef, type Engine, type PlacementSystem } from "@nengine/core";
+import type { BuildingDef, Engine, PlacementSystem } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createPlacementNamespace(engine: Engine): McpNamespace {
   const placementSystem = (): PlacementSystem | undefined => engine.systems.get<PlacementSystem>("placement");
@@ -13,69 +11,13 @@ export function createPlacementNamespace(engine: Engine): McpNamespace {
     name: "placement",
     enabled: true,
     tools: [
-      {
-        name: "building.validate",
-        description: "Validate a building definition (JSON) against the building schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Building definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "placement.building.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "building.create",
-        description: "Create a building definition: validates, writes content/buildings/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Building definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "placement.building.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeBuildingFile(engine, parsed.def);
-          engine.registry.register("building", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "building.update",
-        description: "Patch an existing building definition (merge + validate + rewrite content/buildings/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the building to update." },
-            patch: { type: "object", description: "Partial building definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("placement.building.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<BuildingDef>("building", id);
-          if (!existing) {
-            return errorResult(`placement.building.update: building not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("placement.building.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "placement.building.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("building", id);
-          await writeBuildingFile(engine, parsed.def);
-          engine.registry.register("building", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "building", {
+        tool: "placement.building",
+        subname: "building.",
+        title: "building",
+        errorNoun: "building",
+        article: "a",
+      }),
       {
         name: "building.list",
         description: "List registered building ids with one-line summaries.",
@@ -240,28 +182,6 @@ function buildingSummaries(engine: Engine): { id: string; name?: string; w: numb
   }));
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: BuildingDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = buildingSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeBuildingFile(engine: Engine, def: BuildingDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "building"), contentDirNames["building"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 function isCoord(value: unknown): value is number {
   return typeof value === "number" && Number.isInteger(value) && value >= 0;
 }

+ 124 - 0
packages/mcp/src/namespaces/scene.ts

@@ -0,0 +1,124 @@
+import type { Engine, SceneDef } from "@nengine/core";
+import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
+import type { McpNamespace } from "../namespace.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
+
+export function createSceneNamespace(engine: Engine): McpNamespace {
+  return {
+    name: "scene",
+    enabled: true,
+    tools: [
+      ...authoringTools(engine, "scene", {
+        tool: "scene",
+        subname: "",
+        title: "scene",
+        errorNoun: "scene",
+        article: "a",
+      }),
+      {
+        name: "list",
+        description: "List registered scene ids with one-line summaries.",
+        inputSchema: { type: "object", properties: {} },
+        handler: () => textResult({ scenes: sceneSummaries(engine) }),
+      },
+      {
+        name: "get",
+        description: "Fetch one scene definition as normalized JSON.",
+        inputSchema: {
+          type: "object",
+          properties: { sceneId: { type: "string", description: "Scene id." } },
+          required: ["sceneId"],
+        },
+        handler: (args) => {
+          const sceneId = args["sceneId"];
+          if (typeof sceneId !== "string" || sceneId.length === 0) {
+            return errorResult("scene.get: sceneId must be a non-empty string");
+          }
+          const def = engine.registry.get<SceneDef>("scene", sceneId);
+          if (!def) {
+            return errorResult(`scene.get: scene not found: ${sceneId}`);
+          }
+          return textResult(def);
+        },
+      },
+    ],
+    resources: [
+      {
+        uri: "scene://catalog",
+        name: "scene catalog",
+        read: () => resourceContents("scene://catalog", { scenes: engine.registry.list<SceneDef>("scene") }),
+      },
+      {
+        uri: "scene://{id}",
+        name: "scene definition",
+        read: (params) => {
+          const sceneId = params?.["id"] ?? "";
+          const def = engine.registry.get<SceneDef>("scene", sceneId);
+          if (!def) {
+            throw new McpGatewayError(`scene not found: ${sceneId}`);
+          }
+          return { contents: [{ uri: `scene://${def.id}`, text: JSON.stringify(def, null, 2) }] };
+        },
+      },
+    ],
+    prompts: [
+      {
+        name: "create-prompt",
+        description: "Guided template for authoring a well-formed scene definition.",
+        get: () => promptResult(createPromptText),
+      },
+    ],
+  };
+}
+
+function sceneSummaries(engine: Engine): { id: string; entityCount: number }[] {
+  return engine.registry
+    .list<SceneDef>("scene")
+    .map((scene) => ({ id: scene.id, entityCount: scene.entities.length }));
+}
+
+const createPromptText = [
+  "You are authoring a scene definition for the nEngine.",
+  "",
+  "A scene is a data-only initial-entity list: every entity in `entities` is",
+  "instantiated by the engine at seed time (AFTER systems boot, BEFORE Mods",
+  "load). Scene instances use the SAME entity id formats as the runtime",
+  "systems, so they are indistinguishable from runtime-placed entities —",
+  "save snapshots, the WS snapshot, and viewport rendering all work for free.",
+  "",
+  "- id: unique kebab-case string (e.g. farm-start)",
+  "- entities: an ordered array of initial entities; each is one of:",
+  "",
+  "  1. building:  { type: building, buildingId, x, y }",
+  "     -> PlacementSystem.place(buildingId, x, y) — footprint/bounds/",
+  "        buildable/overlap validation, entity id building:<buildingId>:<x>:<y>",
+  "        An unknown buildingId or a placement conflict FAILS boot loudly.",
+  "  2. npc:       { type: npc, npcId, x, y, dialogue?, label? }",
+  "     -> a plain npc entity { id: npcId, type: npc, x, y, dialogue?, label? }",
+  "        npcId must be unique across the whole world (a duplicate id FAILS",
+  "        boot loudly). dialogue = a dialogue def id; label = display label.",
+  "  3. animal:    { type: animal, animalId, x, y }",
+  "     -> GrowthSystem.spawnAnimal(animalId, x, y), entity id",
+  "        animal:<animalId>:<n>. An unknown animalId FAILS boot loudly.",
+  "",
+  "- x and y are non-negative integer tile coordinates for every entity type.",
+  "- entities are instantiated in ORDER — a building placed earlier in the",
+  "  list overlaps-checks against a later one at seed time.",
+  "",
+  "Example:",
+  "",
+  "  id: farm-start",
+  "  entities:",
+  "    - { type: building, buildingId: shed, x: 4, y: 4 }",
+  "    - { type: npc, npcId: mayor, x: 6, y: 2, dialogue: welcome, label: Mayor }",
+  "    - { type: animal, animalId: chicken, x: 3, y: 1 }",
+  "",
+  "References to building/animal defs are validated at seed time, not schema",
+  "time (loading order makes schema-time reference checks unsafe).",
+  "",
+  "Workflow: call scene.validate with your candidate definition first; on",
+  "success call scene.create to write content/scenes/<id>.yaml and register it",
+  "live. Use scene.update to patch an existing scene. A NEW scene only takes",
+  "effect on the next engine start (instances are seeded at boot).",
+].join("\n");

+ 11 - 3
packages/mcp/src/namespaces/server.ts

@@ -123,6 +123,9 @@ function orientText(engine: Engine, gateway?: McpGateway | undefined): string {
         "server.disable_subsystem",
         "map.list",
         "map.get",
+        "map.validate",
+        "map.create",
+        "map.update",
         "item.validate",
         "item.create",
         "item.update",
@@ -228,6 +231,11 @@ function orientText(engine: Engine, gateway?: McpGateway | undefined): string {
         "shader.update",
         "shader.list",
         "shader.get",
+        "scene.validate",
+        "scene.create",
+        "scene.update",
+        "scene.list",
+        "scene.get",
         "time.tick",
         "mod.list",
         "mod.status",
@@ -245,10 +253,10 @@ function orientText(engine: Engine, gateway?: McpGateway | undefined): string {
     `Loaded content: ${content}`,
     "",
     "Authoring workflow:",
-    "1. Content lives in the repo's content/ directory as YAML (content/maps/<id>.yaml, content/time.yaml, content/items/<id>.yaml, content/crops/<id>.yaml, content/animals/<id>.yaml, content/shops/<id>.yaml, content/behaviors/<id>.yaml, content/statemachines/<id>.yaml, content/dialogues/<id>.yaml, content/weather.yaml, content/buildings/<id>.yaml, content/sprites/<id>.yaml, content/sounds/<id>.yaml, content/interact.yaml, content/particles/<id>.yaml, content/shaders/<id>.yaml, content/animations/<id>.yaml).",
+    "1. Content lives in the repo's content/ directory as YAML (content/maps/<id>.yaml, content/time.yaml, content/items/<id>.yaml, content/crops/<id>.yaml, content/animals/<id>.yaml, content/shops/<id>.yaml, content/behaviors/<id>.yaml, content/statemachines/<id>.yaml, content/dialogues/<id>.yaml, content/weather.yaml, content/buildings/<id>.yaml, content/sprites/<id>.yaml, content/sounds/<id>.yaml, content/interact.yaml, content/particles/<id>.yaml, content/shaders/<id>.yaml, content/animations/<id>.yaml, content/scenes/<id>.yaml).",
     "2. YAML is the authoring surface; the MCP surface only exposes normalized JSON.",
-    "3. Inspect state with server.info, map.list/map.get, item.list/item.get, crop.list/crop.get, animal.list/animal.get, shop.list/shop.get, sprite.list/sprite.get, sound.list/sound.get, particle.list/particle.get, shader.list/shader.get, animation.list/animation.get, time.get, and save.snapshot.",
-    "4. Creation tools: item.validate/create/update/list/get, crop.validate/create/update/list/get, animal.validate/create/update/list/get, shop.validate/create/update/list/get, sprite.validate/create/update/list/get, sound.validate/create/update/list/get, particle.validate/create/update/list/get, shader.validate/create/update/list/get, animation.validate/create/update/list/get, behavior.trigger.validate/create/update/list/get, behavior.statemachine.validate/create/update/list/get, dialogue.validate/create/update/list/get, and placement.building.validate/create/update/list/get validate, write, and hot-register content. Debug economy via shop.buy/shop.sell. Debug triggers via behavior.fire. Drive dialogues headlessly via dialogue.start/dialogue.choose. Debug weather via weather.get/weather.set. Place/remove buildings via placement.place/placement.remove. Move NPCs via npc.move/npc.teleport. Spawn/feed/stage animals via animal.spawn/animal.feed/animal.stage. Inspect/reset the deterministic RNG via rng.get/rng.set (world randomness replays from the reported seed). Step the simulation via time.tick. Serialize/restore world state via save.snapshot/save.restore (mechanism only — save policy is creator content). Author presentation data via sprite.*/sound.*/particle.*/shader.*/animation.*; play-sound actions emit the sound-play event the render layer plays, emit-event particle actions trigger particle bursts (render layer), and animation defs drive the render layer's per-entity state machine (explicit entity.anim field or walk/idle motion inference).",
+    "3. Inspect state with server.info, map.list/map.get, item.list/item.get, crop.list/crop.get, animal.list/animal.get, shop.list/shop.get, sprite.list/sprite.get, sound.list/sound.get, particle.list/particle.get, shader.list/shader.get, animation.list/animation.get, scene.list/scene.get, time.get, and save.snapshot.",
+    "4. Creation tools: item.validate/create/update/list/get, crop.validate/create/update/list/get, animal.validate/create/update/list/get, shop.validate/create/update/list/get, sprite.validate/create/update/list/get, sound.validate/create/update/list/get, particle.validate/create/update/list/get, shader.validate/create/update/list/get, animation.validate/create/update/list/get, scene.validate/create/update/list/get, behavior.trigger.validate/create/update/list/get, behavior.statemachine.validate/create/update/list/get, dialogue.validate/create/update/list/get, and placement.building.validate/create/update/list/get validate, write, and hot-register content. Debug economy via shop.buy/shop.sell. Debug triggers via behavior.fire. Drive dialogues headlessly via dialogue.start/dialogue.choose. Debug weather via weather.get/weather.set. Place/remove buildings via placement.place/placement.remove. Move NPCs via npc.move/npc.teleport. Spawn/feed/stage animals via animal.spawn/animal.feed/animal.stage. Inspect/reset the deterministic RNG via rng.get/rng.set (world randomness replays from the reported seed). Step the simulation via time.tick. Serialize/restore world state via save.snapshot/save.restore (mechanism only — save policy is creator content). Author presentation data via sprite.*/sound.*/particle.*/shader.*/animation.*; play-sound actions emit the sound-play event the render layer plays, emit-event particle actions trigger particle bursts (render layer), and animation defs drive the render layer's per-entity state machine (explicit entity.anim field or walk/idle motion inference). Author initial worlds via scene.* — scene entities are instantiated at seed time through the same system APIs as runtime placement (buildings via placement.place with overlap/buildability checks, NPCs as entities, animals via spawnAnimal).",
   ].join("\n");
 }
 

+ 10 - 90
packages/mcp/src/namespaces/shader.ts

@@ -1,79 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import { contentDirNames, shaderSchema, type Engine, type ShaderDef } from "@nengine/core";
+import type { Engine, ShaderDef } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createShaderNamespace(engine: Engine): McpNamespace {
   return {
     name: "shader",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate a shader definition (JSON) against the shader schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Shader definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "shader.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create a shader definition: validates, writes content/shaders/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Shader definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "shader.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeShaderFile(engine, parsed.def);
-          engine.registry.register("shader", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing shader definition (merge + validate + rewrite content/shaders/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the shader to update." },
-            patch: { type: "object", description: "Partial shader definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("shader.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<ShaderDef>("shader", id);
-          if (!existing) {
-            return errorResult(`shader.update: shader not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("shader.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "shader.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("shader", id);
-          await writeShaderFile(engine, parsed.def);
-          engine.registry.register("shader", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "shader", {
+        tool: "shader",
+        subname: "",
+        title: "shader",
+        errorNoun: "shader",
+        article: "a",
+      }),
       {
         name: "list",
         description: "List registered shader ids with one-line summaries.",
@@ -134,28 +76,6 @@ function shaderSummaries(engine: Engine): { id: string; filter: string }[] {
   return engine.registry.list<ShaderDef>("shader").map((shader) => ({ id: shader.id, filter: shader.filter }));
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: ShaderDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = shaderSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeShaderFile(engine: Engine, def: ShaderDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "shader"), contentDirNames["shader"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring a shader definition for the nEngine.",
   "",

+ 10 - 96
packages/mcp/src/namespaces/shop.ts

@@ -1,85 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import {
-  contentDirNames,
-  shopSchema,
-  type EconomySystem,
-  type Engine,
-  type ShopDef,
-} from "@nengine/core";
+import type { EconomySystem, Engine, ShopDef } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createShopNamespace(engine: Engine): McpNamespace {
   return {
     name: "shop",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate a shop definition (JSON) against the shop schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Shop definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "shop.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create a shop definition: validates, writes content/shops/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Shop definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "shop.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeShopFile(engine, parsed.def);
-          engine.registry.register("shop", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing shop definition (merge + validate + rewrite content/shops/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the shop to update." },
-            patch: { type: "object", description: "Partial shop definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("shop.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<ShopDef>("shop", id);
-          if (!existing) {
-            return errorResult(`shop.update: shop not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("shop.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "shop.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("shop", id);
-          await writeShopFile(engine, parsed.def);
-          engine.registry.register("shop", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "shop", {
+        tool: "shop",
+        subname: "",
+        title: "shop",
+        errorNoun: "shop",
+        article: "a",
+      }),
       {
         name: "list",
         description: "List registered shop ids with one-line summaries.",
@@ -216,28 +152,6 @@ function economySystem(engine: Engine): EconomySystem | undefined {
   return engine.systems.get<EconomySystem>("economy");
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: ShopDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = shopSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeShopFile(engine: Engine, def: ShopDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "shop"), contentDirNames["shop"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring a shop definition for the nEngine.",
   "",

+ 10 - 90
packages/mcp/src/namespaces/sound.ts

@@ -1,79 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import { contentDirNames, soundSchema, type Engine, type SoundDef } from "@nengine/core";
+import type { Engine, SoundDef } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createSoundNamespace(engine: Engine): McpNamespace {
   return {
     name: "sound",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate a sound definition (JSON) against the sound schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Sound definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "sound.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create a sound definition: validates, writes content/sounds/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Sound definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "sound.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeSoundFile(engine, parsed.def);
-          engine.registry.register("sound", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing sound definition (merge + validate + rewrite content/sounds/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the sound to update." },
-            patch: { type: "object", description: "Partial sound definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("sound.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<SoundDef>("sound", id);
-          if (!existing) {
-            return errorResult(`sound.update: sound not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("sound.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "sound.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("sound", id);
-          await writeSoundFile(engine, parsed.def);
-          engine.registry.register("sound", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "sound", {
+        tool: "sound",
+        subname: "",
+        title: "sound",
+        errorNoun: "sound",
+        article: "a",
+      }),
       {
         name: "list",
         description: "List registered sound ids with one-line summaries.",
@@ -138,28 +80,6 @@ function soundSummaries(engine: Engine): { id: string; hasSynth: boolean; hasSrc
   }));
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: SoundDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = soundSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeSoundFile(engine: Engine, def: SoundDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "sound"), contentDirNames["sound"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring a sound definition for the nEngine.",
   "",

+ 10 - 90
packages/mcp/src/namespaces/sprite.ts

@@ -1,79 +1,21 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { stringify } from "yaml";
-import { contentDirNames, spriteSchema, type Engine, type SpriteDef } from "@nengine/core";
+import type { Engine, SpriteDef } from "@nengine/core";
 import { McpGatewayError } from "../gateway.js";
+import { authoringTools } from "../authoring.js";
 import type { McpNamespace } from "../namespace.js";
-import { errorResult, promptResult, requireContentDir, resourceContents, textResult } from "../result.js";
+import { errorResult, promptResult, resourceContents, textResult } from "../result.js";
 
 export function createSpriteNamespace(engine: Engine): McpNamespace {
   return {
     name: "sprite",
     enabled: true,
     tools: [
-      {
-        name: "validate",
-        description: "Validate a sprite definition (JSON) against the sprite schema; returns the normalized def.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Sprite definition to validate." } },
-          required: ["def"],
-        },
-        handler: (args) => {
-          const parsed = parseDef(args["def"], "sprite.validate");
-          if (!parsed.ok) return errorResult(parsed.message);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "create",
-        description: "Create a sprite definition: validates, writes content/sprites/<id>.yaml, and registers it live.",
-        inputSchema: {
-          type: "object",
-          properties: { def: { type: "object", description: "Sprite definition to create." } },
-          required: ["def"],
-        },
-        handler: async (args) => {
-          const parsed = parseDef(args["def"], "sprite.create");
-          if (!parsed.ok) return errorResult(parsed.message);
-          await writeSpriteFile(engine, parsed.def);
-          engine.registry.register("sprite", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
-      {
-        name: "update",
-        description: "Patch an existing sprite definition (merge + validate + rewrite content/sprites/<id>.yaml + re-register).",
-        inputSchema: {
-          type: "object",
-          properties: {
-            id: { type: "string", description: "Id of the sprite to update." },
-            patch: { type: "object", description: "Partial sprite definition to merge over the existing one." },
-          },
-          required: ["id", "patch"],
-        },
-        handler: async (args) => {
-          const id = args["id"];
-          if (typeof id !== "string" || id.length === 0) {
-            return errorResult("sprite.update: id must be a non-empty string");
-          }
-          const existing = engine.registry.get<SpriteDef>("sprite", id);
-          if (!existing) {
-            return errorResult(`sprite.update: sprite not found: ${id}`);
-          }
-          const patch = args["patch"];
-          if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
-            return errorResult("sprite.update: patch must be a JSON object");
-          }
-          const merged = { ...existing, ...(patch as Record<string, unknown>), id };
-          const parsed = parseDef(merged, "sprite.update");
-          if (!parsed.ok) return errorResult(parsed.message);
-          engine.registry.remove("sprite", id);
-          await writeSpriteFile(engine, parsed.def);
-          engine.registry.register("sprite", parsed.def);
-          return textResult(parsed.def);
-        },
-      },
+      ...authoringTools(engine, "sprite", {
+        tool: "sprite",
+        subname: "",
+        title: "sprite",
+        errorNoun: "sprite",
+        article: "a",
+      }),
       {
         name: "list",
         description: "List registered sprite ids with one-line summaries.",
@@ -134,28 +76,6 @@ function spriteSummaries(engine: Engine): { id: string; frameCount: number }[] {
   return engine.registry.list<SpriteDef>("sprite").map((sprite) => ({ id: sprite.id, frameCount: sprite.frames.length }));
 }
 
-function parseDef(raw: unknown, tool: string): { ok: true; def: SpriteDef } | { ok: false; message: string } {
-  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
-    return { ok: false, message: `${tool}: def must be a JSON object` };
-  }
-  const result = spriteSchema.safeParse(raw);
-  if (!result.success) {
-    return {
-      ok: false,
-      message: `${tool}: ${result.error.issues
-        .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
-        .join("; ")}`,
-    };
-  }
-  return { ok: true, def: result.data };
-}
-
-async function writeSpriteFile(engine: Engine, def: SpriteDef): Promise<void> {
-  const dir = resolve(requireContentDir(engine, "sprite"), contentDirNames["sprite"]);
-  await mkdir(dir, { recursive: true });
-  await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
-}
-
 const createPromptText = [
   "You are authoring a sprite definition for the nEngine.",
   "",

+ 252 - 0
packages/mcp/test/map-namespace.test.ts

@@ -0,0 +1,252 @@
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine, type MapDef } from "@nengine/core";
+import { createEngineNamespaces, McpGateway } from "../src/index.js";
+
+const TIME_YAML = [
+  "id: default",
+  "dayLengthTicks: 480",
+  "seasons:",
+  "  - { name: spring, dayCount: 28 }",
+  "start: { day: 1, season: spring }",
+  "",
+].join("\n");
+
+const FARM_YAML = [
+  "id: farm",
+  "width: 2",
+  "height: 2",
+  'tilesetRef: "tiles"',
+  "spawn: { x: 0, y: 0 }",
+  "layers:",
+  "  - name: ground",
+  "    data:",
+  "      - [a, b]",
+  "      - [b, a]",
+  "",
+].join("\n");
+
+interface Setup {
+  engine: ReturnType<typeof createEngine>;
+  gateway: McpGateway;
+  tmp: string;
+  close: () => Promise<void>;
+}
+
+async function setup(): Promise<Setup> {
+  const tmp = await mkdtemp(join(tmpdir(), "farm-map-"));
+  await mkdir(join(tmp, "maps"), { recursive: true });
+  await writeFile(join(tmp, "time.yaml"), TIME_YAML);
+  await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
+
+  const engine = createEngine({ contentDir: tmp });
+  const gateway = new McpGateway();
+  for (const namespace of createEngineNamespaces(engine, { gateway })) {
+    gateway.register(namespace);
+  }
+  await engine.start();
+  return {
+    engine,
+    gateway,
+    tmp,
+    close: async () => {
+      await engine.stop();
+      await rm(tmp, { recursive: true, force: true });
+    },
+  };
+}
+
+async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
+  ok: boolean;
+  text: string;
+}> {
+  const result = await gateway.callTool(name, args);
+  return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
+}
+
+const MEADOW_DEF: MapDef = {
+  id: "meadow",
+  width: 2,
+  height: 2,
+  tilesetRef: "tiles",
+  spawn: { x: 0, y: 0 },
+  layers: [{ name: "ground", data: [["grass", "grass"], ["grass", "grass"]] }],
+};
+
+describe("map namespace", () => {
+  it("map.create writes content/maps/<id>.yaml, registers live, and persists across restart", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      const created = await callText(gateway, "map.create", { def: MEADOW_DEF });
+      expect(created.ok).toBe(true);
+      expect(JSON.parse(created.text) as MapDef).toEqual(MEADOW_DEF);
+
+      expect(engine.registry.get<MapDef>("map", "meadow")).toEqual(MEADOW_DEF);
+
+      const file = await readFile(join(tmp, "maps", "meadow.yaml"), "utf8");
+      expect(file).toContain("id: meadow");
+      expect(file).toContain("grass");
+
+      await engine.stop();
+      const reloaded = createEngine({ contentDir: tmp });
+      await reloaded.start();
+      try {
+        expect(reloaded.registry.get<MapDef>("map", "meadow")).toEqual(MEADOW_DEF);
+      } finally {
+        await reloaded.stop();
+      }
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.validate accepts a valid def and rejects an invalid one", async () => {
+    const { gateway, close } = await setup();
+    try {
+      const valid = await callText(gateway, "map.validate", { def: MEADOW_DEF });
+      expect(valid.ok).toBe(true);
+      expect(JSON.parse(valid.text)).toEqual(MEADOW_DEF);
+
+      const invalid = await callText(gateway, "map.validate", {
+        def: { ...MEADOW_DEF, layers: [{ name: "ground", data: [["grass"]] }] },
+      });
+      expect(invalid.ok).toBe(false);
+      expect(invalid.text).toMatch(/expected 2/);
+
+      const badWidth = await callText(gateway, "map.validate", { def: { ...MEADOW_DEF, width: 0 } });
+      expect(badWidth.ok).toBe(false);
+      expect(badWidth.text).toMatch(/width/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.create rejects an invalid def and does not write or register", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      const bad = await callText(gateway, "map.create", {
+        def: { ...MEADOW_DEF, layers: [{ name: "ground", data: [["grass"]] }] },
+      });
+      expect(bad.ok).toBe(false);
+      expect(engine.registry.get("map", "meadow")).toBeUndefined();
+      await expect(readFile(join(tmp, "maps", "meadow.yaml"))).rejects.toThrow();
+    } finally {
+      await close();
+    }
+  });
+
+  it("duplicate map.create overwrites the existing definition", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      await callText(gateway, "map.create", { def: MEADOW_DEF });
+      const recreated = await callText(gateway, "map.create", {
+        def: { ...MEADOW_DEF, tilesetRef: "meadow-tiles", walkable: { water: false } },
+      });
+      expect(recreated.ok).toBe(true);
+
+      const current = engine.registry.get<MapDef>("map", "meadow");
+      expect(current?.tilesetRef).toBe("meadow-tiles");
+      expect(current?.walkable).toEqual({ water: false });
+
+      const file = await readFile(join(tmp, "maps", "meadow.yaml"), "utf8");
+      expect(file).toContain("meadow-tiles");
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.update merges a patch, validates, rewrites the file, and re-registers", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      await callText(gateway, "map.create", { def: MEADOW_DEF });
+      const updated = await callText(gateway, "map.update", {
+        id: "meadow",
+        patch: { tilesetRef: "renamed", spawn: { x: 1, y: 1 } },
+      });
+      expect(updated.ok).toBe(true);
+      const def = JSON.parse(updated.text) as MapDef;
+      expect(def.tilesetRef).toBe("renamed");
+      expect(def.spawn).toEqual({ x: 1, y: 1 });
+      expect(def.width).toBe(2);
+
+      const file = await readFile(join(tmp, "maps", "meadow.yaml"), "utf8");
+      expect(file).toContain("renamed");
+
+      const missing = await callText(gateway, "map.update", { id: "nope", patch: { tilesetRef: "x" } });
+      expect(missing.ok).toBe(false);
+      expect(missing.text).toMatch(/map not found: nope/);
+
+      const badPatch = await callText(gateway, "map.update", { id: "meadow", patch: { layers: [] } });
+      expect(badPatch.ok).toBe(false);
+      expect(badPatch.text).toMatch(/layers/);
+      expect(engine.registry.get<MapDef>("map", "meadow")?.tilesetRef).toBe("renamed");
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.list returns one-line summaries including created maps", async () => {
+    const { gateway, close } = await setup();
+    try {
+      await callText(gateway, "map.create", { def: MEADOW_DEF });
+      const listed = await callText(gateway, "map.list");
+      expect(listed.ok).toBe(true);
+      expect(JSON.parse(listed.text)).toEqual({
+        maps: [
+          { id: "farm", width: 2, height: 2, tilesetRef: "tiles" },
+          { id: "meadow", width: 2, height: 2, tilesetRef: "tiles" },
+        ],
+      });
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.get returns the full def plus the player entity; unknown id is an error result", async () => {
+    const { gateway, close } = await setup();
+    try {
+      await callText(gateway, "map.create", { def: MEADOW_DEF });
+      const got = await callText(gateway, "map.get", { mapId: "meadow" });
+      expect(got.ok).toBe(true);
+      const map = JSON.parse(got.text) as Record<string, unknown>;
+      expect(map.id).toBe("meadow");
+      expect(map.width).toBe(2);
+      expect(map.player).toMatchObject({ id: "player", type: "player", x: 0, y: 0 });
+
+      const missing = await callText(gateway, "map.get", { mapId: "nope" });
+      expect(missing.ok).toBe(false);
+      expect(missing.text).toMatch(/map not found: nope/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("map://{mapId} resource stays readable; missing id rejects", async () => {
+    const { gateway, close } = await setup();
+    try {
+      await callText(gateway, "map.create", { def: MEADOW_DEF });
+      const single = await gateway.readResource("map://meadow");
+      expect(JSON.parse(single.contents[0]?.text ?? "{}")).toMatchObject({ id: "meadow", width: 2 });
+
+      await expect(gateway.readResource("map://missing")).rejects.toThrow(/map not found: missing/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("serves the map.create-prompt prompt", async () => {
+    const { gateway, close } = await setup();
+    try {
+      const prompt = await gateway.getPrompt("map.create-prompt", {});
+      const text = prompt.messages[0]?.content.text ?? "";
+      expect(text).toContain("layers");
+      expect(text).toContain("spawn");
+      expect(text).toContain("map.validate");
+      expect(text).toContain("content/maps/<id>.yaml");
+    } finally {
+      await close();
+    }
+  });
+});

+ 1 - 1
packages/mcp/test/namespaces.test.ts

@@ -46,7 +46,7 @@ describe("engine namespaces", () => {
       expect(info.engine).toBe("nengine");
       expect(info.content["map"]).toContain("farm");
       expect(info.content["time"]).toContain("default");
-      expect(info.subsystems.map((s) => s.name).sort()).toEqual(["animal", "animation", "behavior", "crop", "dialogue", "item", "map", "mod", "npc", "particle", "placement", "player", "rng", "save", "server", "shader", "shop", "sound", "sprite", "time", "weather"]);
+      expect(info.subsystems.map((s) => s.name).sort()).toEqual(["animal", "animation", "behavior", "crop", "dialogue", "item", "map", "mod", "npc", "particle", "placement", "player", "rng", "save", "scene", "server", "shader", "shop", "sound", "sprite", "time", "weather"]);
       expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
       expect(engine.registry.ids("map")).toEqual(["farm"]);
     } finally {

+ 276 - 0
packages/mcp/test/scene-namespace.test.ts

@@ -0,0 +1,276 @@
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine, type SceneDef } from "@nengine/core";
+import { createEngineNamespaces, McpGateway } from "../src/index.js";
+
+const TIME_YAML = [
+  "id: default",
+  "dayLengthTicks: 480",
+  "seasons:",
+  "  - { name: spring, dayCount: 28 }",
+  "start: { day: 1, season: spring }",
+  "",
+].join("\n");
+
+const FARM_YAML = [
+  "id: farm",
+  "width: 2",
+  "height: 2",
+  'tilesetRef: "tiles"',
+  "spawn: { x: 0, y: 0 }",
+  "layers:",
+  "  - name: ground",
+  "    data:",
+  "      - [a, b]",
+  "      - [b, a]",
+  "",
+].join("\n");
+
+const CHICKEN_YAML = [
+  "id: chicken",
+  "stages:",
+  "  - { name: chick, days: 2, feedRequired: false }",
+  "produces:",
+  "  - { itemId: egg, qty: 1 }",
+  "",
+].join("\n");
+
+interface Setup {
+  engine: ReturnType<typeof createEngine>;
+  gateway: McpGateway;
+  tmp: string;
+  close: () => Promise<void>;
+}
+
+async function setup(): Promise<Setup> {
+  const tmp = await mkdtemp(join(tmpdir(), "scene-ns-"));
+  await mkdir(join(tmp, "maps"), { recursive: true });
+  await mkdir(join(tmp, "animals"), { recursive: true });
+  await writeFile(join(tmp, "time.yaml"), TIME_YAML);
+  await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
+  await writeFile(join(tmp, "animals", "chicken.yaml"), CHICKEN_YAML);
+
+  const engine = createEngine({ contentDir: tmp });
+  const gateway = new McpGateway();
+  for (const namespace of createEngineNamespaces(engine, { gateway })) {
+    gateway.register(namespace);
+  }
+  await engine.start();
+  return {
+    engine,
+    gateway,
+    tmp,
+    close: async () => {
+      await engine.stop();
+      await rm(tmp, { recursive: true, force: true });
+    },
+  };
+}
+
+async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
+  ok: boolean;
+  text: string;
+}> {
+  const result = await gateway.callTool(name, args);
+  return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
+}
+
+const FARM_START_DEF: SceneDef = {
+  id: "farm-start",
+  entities: [
+    { type: "npc", npcId: "mayor", x: 1, y: 1, dialogue: "welcome", label: "Mayor" },
+    { type: "animal", animalId: "chicken", x: 0, y: 1 },
+  ],
+};
+
+describe("scene namespace", () => {
+  it("scene.create writes content/scenes/<id>.yaml, registers live, and seeds on restart", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      const created = await callText(gateway, "scene.create", { def: FARM_START_DEF });
+      expect(created.ok).toBe(true);
+      expect(JSON.parse(created.text) as SceneDef).toEqual(FARM_START_DEF);
+
+      expect(engine.registry.get<SceneDef>("scene", "farm-start")).toEqual(FARM_START_DEF);
+
+      const file = await readFile(join(tmp, "scenes", "farm-start.yaml"), "utf8");
+      expect(file).toContain("id: farm-start");
+      expect(file).toContain("npcId: mayor");
+
+      expect(engine.world.get("mayor")).toBeUndefined();
+
+      await engine.stop();
+      const reloaded = createEngine({ contentDir: tmp });
+      await reloaded.start();
+      try {
+        expect(reloaded.registry.get<SceneDef>("scene", "farm-start")).toEqual(FARM_START_DEF);
+        expect(reloaded.world.get("mayor")).toMatchObject({ id: "mayor", type: "npc", x: 1, y: 1 });
+        expect(reloaded.world.get("animal:chicken:1")).toMatchObject({ type: "animal", animalId: "chicken" });
+      } finally {
+        await reloaded.stop();
+      }
+    } finally {
+      await close();
+    }
+  });
+
+  it("scene.validate accepts a valid def and rejects an invalid one", async () => {
+    const { gateway, close } = await setup();
+    try {
+      const valid = await callText(gateway, "scene.validate", { def: FARM_START_DEF });
+      expect(valid.ok).toBe(true);
+      expect(JSON.parse(valid.text)).toEqual(FARM_START_DEF);
+
+      const invalid = await callText(gateway, "scene.validate", {
+        def: { id: "bad", entities: [{ type: "plot", x: 0, y: 0 }] },
+      });
+      expect(invalid.ok).toBe(false);
+      expect(invalid.text).toMatch(/Invalid discriminator value/);
+      expect(invalid.text).toMatch(/entities/);
+
+      const negative = await callText(gateway, "scene.validate", {
+        def: { id: "bad", entities: [{ type: "npc", npcId: "m", x: -1, y: 0 }] },
+      });
+      expect(negative.ok).toBe(false);
+      expect(negative.text).toMatch(/x/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("scene.create rejects an invalid def and does not write or register", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      const bad = await callText(gateway, "scene.create", {
+        def: { id: "broken", entities: [{ type: "npc", npcId: "m", x: 0 }] },
+      });
+      expect(bad.ok).toBe(false);
+      expect(engine.registry.get("scene", "broken")).toBeUndefined();
+      await expect(readFile(join(tmp, "scenes", "broken.yaml"))).rejects.toThrow();
+    } finally {
+      await close();
+    }
+  });
+
+  it("duplicate scene.create overwrites the existing definition", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      await callText(gateway, "scene.create", { def: FARM_START_DEF });
+      const recreated = await callText(gateway, "scene.create", {
+        def: { id: "farm-start", entities: [{ type: "npc", npcId: "villager", x: 0, y: 0 }] },
+      });
+      expect(recreated.ok).toBe(true);
+
+      const current = engine.registry.get<SceneDef>("scene", "farm-start");
+      expect(current?.entities).toEqual([{ type: "npc", npcId: "villager", x: 0, y: 0 }]);
+
+      const file = await readFile(join(tmp, "scenes", "farm-start.yaml"), "utf8");
+      expect(file).toContain("npcId: villager");
+    } finally {
+      await close();
+    }
+  });
+
+  it("scene.update merges a patch, validates, rewrites the file, and re-registers", async () => {
+    const { engine, gateway, tmp, close } = await setup();
+    try {
+      await callText(gateway, "scene.create", { def: FARM_START_DEF });
+      const updated = await callText(gateway, "scene.update", {
+        id: "farm-start",
+        patch: { entities: [{ type: "building", buildingId: "shed", x: 0, y: 0 }] },
+      });
+      expect(updated.ok).toBe(true);
+      const def = JSON.parse(updated.text) as SceneDef;
+      expect(def.entities).toEqual([{ type: "building", buildingId: "shed", x: 0, y: 0 }]);
+      expect(def.id).toBe("farm-start");
+
+      const file = await readFile(join(tmp, "scenes", "farm-start.yaml"), "utf8");
+      expect(file).toContain("buildingId: shed");
+
+      const missing = await callText(gateway, "scene.update", { id: "nope", patch: { entities: [] } });
+      expect(missing.ok).toBe(false);
+      expect(missing.text).toMatch(/scene not found: nope/);
+
+      const badPatch = await callText(gateway, "scene.update", { id: "farm-start", patch: { entities: "nope" } });
+      expect(badPatch.ok).toBe(false);
+      expect(badPatch.text).toMatch(/entities/);
+      expect(engine.registry.get<SceneDef>("scene", "farm-start")?.entities).toEqual([
+        { type: "building", buildingId: "shed", x: 0, y: 0 },
+      ]);
+    } finally {
+      await close();
+    }
+  });
+
+  it("scene.list returns one-line summaries", async () => {
+    const { gateway, close } = await setup();
+    try {
+      await callText(gateway, "scene.create", { def: FARM_START_DEF });
+      await callText(gateway, "scene.create", {
+        def: { id: "farm-empty", entities: [] },
+      });
+      const listed = await callText(gateway, "scene.list");
+      expect(listed.ok).toBe(true);
+      expect(JSON.parse(listed.text)).toEqual({
+        scenes: [
+          { id: "farm-start", entityCount: 2 },
+          { id: "farm-empty", entityCount: 0 },
+        ],
+      });
+    } finally {
+      await close();
+    }
+  });
+
+  it("scene.get returns the full def; unknown id is an error result", async () => {
+    const { gateway, close } = await setup();
+    try {
+      await callText(gateway, "scene.create", { def: FARM_START_DEF });
+      const got = await callText(gateway, "scene.get", { sceneId: "farm-start" });
+      expect(got.ok).toBe(true);
+      expect(JSON.parse(got.text)).toEqual(FARM_START_DEF);
+
+      const missing = await callText(gateway, "scene.get", { sceneId: "nope" });
+      expect(missing.ok).toBe(false);
+      expect(missing.text).toMatch(/scene not found: nope/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("resources: scene://catalog and scene://{id}; missing id rejects", async () => {
+    const { engine, gateway, close } = await setup();
+    try {
+      await callText(gateway, "scene.create", { def: FARM_START_DEF });
+
+      const catalog = await gateway.readResource("scene://catalog");
+      expect(JSON.parse(catalog.contents[0]?.text ?? "{}")).toEqual({ scenes: [FARM_START_DEF] });
+
+      const single = await gateway.readResource("scene://farm-start");
+      expect(JSON.parse(single.contents[0]?.text ?? "{}")).toEqual(FARM_START_DEF);
+
+      await expect(gateway.readResource("scene://missing")).rejects.toThrow(/scene not found: missing/);
+      expect(engine.registry.get("scene", "missing")).toBeUndefined();
+    } finally {
+      await close();
+    }
+  });
+
+  it("serves the scene.create-prompt prompt", async () => {
+    const { gateway, close } = await setup();
+    try {
+      const prompt = await gateway.getPrompt("scene.create-prompt", {});
+      const text = prompt.messages[0]?.content.text ?? "";
+      expect(text).toContain("building");
+      expect(text).toContain("npc");
+      expect(text).toContain("animal");
+      expect(text).toContain("scene.validate");
+      expect(text).toContain("content/scenes/<id>.yaml");
+      expect(text).toContain("overlap");
+    } finally {
+      await close();
+    }
+  });
+});

+ 49 - 1
pnpm-lock.yaml

@@ -34,6 +34,46 @@ importers:
         specifier: ^3.0.0
         version: 3.2.7(@types/node@24.13.3)(yaml@2.9.0)
 
+  apps/editor:
+    dependencies:
+      '@nengine/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      '@nengine/mcp':
+        specifier: workspace:*
+        version: link:../../packages/mcp
+      '@nengine/pack':
+        specifier: workspace:*
+        version: link:../pack
+      '@nengine/render':
+        specifier: workspace:*
+        version: link:../../packages/render
+      yaml:
+        specifier: ^2.6.0
+        version: 2.9.0
+      zod:
+        specifier: ^3.23.8
+        version: 3.25.76
+    devDependencies:
+      '@modelcontextprotocol/sdk':
+        specifier: ^1.30.0
+        version: 1.30.0(zod@3.25.76)
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.20.1
+      electron:
+        specifier: ^43.4.0
+        version: 43.4.0
+      typescript:
+        specifier: ^5.6.0
+        version: 5.9.3
+      vite:
+        specifier: ^7.0.0
+        version: 7.3.6(@types/node@22.20.1)(yaml@2.9.0)
+      vitest:
+        specifier: ^3.0.0
+        version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0)
+
   apps/mcp-server:
     dependencies:
       '@modelcontextprotocol/sdk':
@@ -1406,6 +1446,14 @@ snapshots:
       chai: 5.3.3
       tinyrainbow: 2.0.0
 
+  '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0))':
+    dependencies:
+      '@vitest/spy': 3.2.7
+      estree-walker: 3.0.3
+      magic-string: 0.30.21
+    optionalDependencies:
+      vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0)
+
   '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(yaml@2.9.0))':
     dependencies:
       '@vitest/spy': 3.2.7
@@ -2078,7 +2126,7 @@ snapshots:
     dependencies:
       '@types/chai': 5.2.3
       '@vitest/expect': 3.2.7
-      '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(yaml@2.9.0))
+      '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0))
       '@vitest/pretty-format': 3.2.7
       '@vitest/runner': 3.2.7
       '@vitest/snapshot': 3.2.7

Деякі файли не було показано, через те що забагато файлів було змінено