Ver Fonte

D41: editor distribution + full-coverage gaps (F1/F2/F4)

F1 Mod management: project.yaml mods.enabled list + engine enabledMods
filter (ModLoader load/reload + bundle mode, all consumers unified:
edit engine, playtest compileBundle, packGame) + editor Mods panel
(mods-model/mods-panel, explicit-toggle persistence, mods:reload
stop+recreate) + inspectModEntry; @nengine/pack additive enabledMods.

F2 Asset management: assets:list/import/delete IPC + main-side
assets.ts (sanitize/-2 dedupe/never overwrite, refreshEditEngineAssets
registry re-read) + Assets panel (thumbnails/audio rows/import/delete)
+ form ref dropdowns include asset ids (ASSET_RECORD_VALUE_FIELDS for
map.tiles record values).

F4 Editor distribution: apps/editor electron-builder config (appId
com.nengine.editor, productName 'nEngine Editor', output dist-app/,
linux AppImage + win nsis, executableName nengine-editor) + dist/
dist:win/build:dist scripts; workspace deps auto-collected via pnpm
symlinks (@nengine/render -> devDeps, @modelcontextprotocol/sdk ->
deps); sandboxed preload bundled to one CJS file (vite.preload.config)
fixing a latent require-relative-module bug; real Linux AppImage
verified (138 MB, launch smoke + packaged --mcp-stdio handshake).

D41 documented in decisions.md/editor.md/api.md/AGENTS.md. 1001 tests green.
nonvioce há 2 meses atrás
pai
commit
cec99f7030
43 ficheiros alterados com 2606 adições e 77 exclusões
  1. 2 0
      .gitignore
  2. 1 1
      AGENTS.md
  3. 3 0
      README.md
  4. 38 0
      apps/editor/electron-builder.yml
  5. 119 0
      apps/editor/index.html
  6. 8 4
      apps/editor/package.json
  7. 163 0
      apps/editor/src/assets.ts
  8. 5 2
      apps/editor/src/edit-engine.ts
  9. 91 0
      apps/editor/src/ipc.ts
  10. 85 4
      apps/editor/src/main.ts
  11. 3 0
      apps/editor/src/pack.ts
  12. 16 1
      apps/editor/src/preload.ts
  13. 17 0
      apps/editor/src/project-yaml.ts
  14. 8 1
      apps/editor/src/project.ts
  15. 26 0
      apps/editor/src/renderer/assets-model.ts
  16. 146 0
      apps/editor/src/renderer/assets-panel.ts
  17. 5 1
      apps/editor/src/renderer/form-panel.ts
  18. 12 0
      apps/editor/src/renderer/form-schema.ts
  19. 50 0
      apps/editor/src/renderer/main.ts
  20. 26 0
      apps/editor/src/renderer/mods-model.ts
  21. 96 0
      apps/editor/src/renderer/mods-panel.ts
  22. 16 2
      apps/editor/src/renderer/nengine.d.ts
  23. 36 0
      apps/editor/test/assets-model.test.ts
  24. 202 0
      apps/editor/test/assets.test.ts
  25. 176 0
      apps/editor/test/electron-builder-config.test.ts
  26. 62 0
      apps/editor/test/form-ref-assets.test.ts
  27. 130 0
      apps/editor/test/mods-consumers.test.ts
  28. 50 0
      apps/editor/test/mods-model.test.ts
  29. 61 1
      apps/editor/test/project-yaml.test.ts
  30. 1 0
      apps/editor/tsconfig.main.json
  31. 1 3
      apps/editor/tsconfig.preload.json
  32. 22 0
      apps/editor/vite.preload.config.ts
  33. 20 8
      apps/pack/src/index.ts
  34. 29 0
      apps/pack/test/pack.test.ts
  35. 2 2
      docs/api.md
  36. 15 0
      docs/decisions.md
  37. 16 14
      docs/editor.md
  38. 10 3
      packages/core/src/engine.ts
  39. 1 1
      packages/core/src/index.ts
  40. 1 1
      packages/core/src/mods/index.ts
  41. 55 11
      packages/core/src/mods/loader.ts
  42. 170 0
      packages/core/test/enabled-mods.test.ts
  43. 610 17
      pnpm-lock.yaml

+ 2 - 0
.gitignore

@@ -6,3 +6,5 @@ coverage/
 .vite/
 *.local
 dist-pack/
+dist-pack/
+apps/editor/dist-app/

Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 1
AGENTS.md


+ 3 - 0
README.md

@@ -16,6 +16,8 @@
 
 下载/安装本引擎、以及如何把游戏打包为桌面软件(AppImage 等)的完整步骤,见 **[`docs/engine-guide-zh.md` §4](docs/engine-guide-zh.md)**(快速开始 / 打包为桌面软件)。
 
+**图形化编辑器本身也是可分发桌面应用**(D41 F4,nEngine Editor):`NODE_OPTIONS=--use-system-ca pnpm --filter @nengine/editor dist` → `apps/editor/dist-app/nEngine Editor-*.AppImage`;`ELECTRON_DISABLE_SANDBOX=1 DISPLAY=:1` 启动,也支持 `--mcp-stdio --dir <project>` 作为无头 MCP 服务器。详见 `docs/editor.md`(Distribution (F4) 一节)。
+
 ## 文档地图
 
 | 路径 | 用途 |
@@ -26,5 +28,6 @@
 | `docs/api.md` | 引擎 API 参考(活的 API 文档) |
 | `docs/boundaries.md` | 引擎/游戏边界(三判据) |
 | `docs/engine-guide-zh.md` | 中文引擎指南:安装、游玩、制作游戏、打包发布 |
+| `docs/editor.md` | **编辑器架构(D40)**:编辑器设计、IPC、E1–E7/F1–F4 进度、Distribution (F4) |
 | `AGENTS.md` | 项目规则 + 当前状态摘要 |
 | `examples/sample-game/README.md` | 示例游戏 Pinefield Valley 的完整游玩/驱动/打包说明 |

+ 38 - 0
apps/editor/electron-builder.yml

@@ -0,0 +1,38 @@
+# nEngine Editor — desktop distribution via electron-builder (F4, D41 #4/#6).
+#
+# Packages the D40 editor itself as a distributable desktop application. This
+# is the EDITOR's own electron-builder config — nengine-pack (apps/pack, D30/
+# D32/D36) stays the GAME packer and is deliberately NOT extended. Output goes
+# to `dist-app/` (kept separate from the build artifacts in `dist/` so
+# electron-builder's emptyOutDir can never wipe the tsc/vite outputs).
+appId: com.nengine.editor
+productName: nEngine Editor
+# The package name `@nengine/editor` would produce the unsafe executable name
+# `@nengineeditor` — AppImage refuses `@` in the internal executable path.
+executableName: nengine-editor
+directories:
+  output: dist-app
+files:
+  # The compiled app: main process (ESM, tsc.main -> dist/main), sandboxed
+  # preload (CJS, tsc.preload -> dist/preload) and the vite renderer bundle
+  # (dist/renderer). `main` in package.json points at dist/main/main.js; the
+  # main process serves dist/renderer over the local loopback HTTP server.
+  - dist/**
+  - package.json
+# Runtime node_modules (production dependencies) are auto-collected by
+# electron-builder: @nengine/core / @nengine/mcp / @nengine/pack (workspace
+# symlinks -> their built dist), @modelcontextprotocol/sdk (used directly by
+# the embedded MCP server), yaml, zod, and transitively vite (via @nengine/pack
+# — the package dialog runs the real vite build). @nengine/render stays a
+# devDependency: the vite-built renderer bundle inlines it and the main process
+# never imports it at runtime, so pixi.js is not dragged into the packaged app.
+# electron-builder v26 handles the pnpm workspace symlink layout (verified F4).
+linux:
+  target:
+    - AppImage
+  category: Development
+win:
+  target:
+    - nsis
+nsis:
+  oneClick: true

+ 119 - 0
apps/editor/index.html

@@ -699,6 +699,125 @@
         line-height: 1.1;
         flex: none;
       }
+      .mods-body {
+        flex: 1;
+        min-height: 0;
+        overflow: auto;
+      }
+      .mods-list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+      }
+      .mod-row {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 8px 10px;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        background: #1c2127;
+      }
+      .mod-info {
+        flex: 1;
+        min-width: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+      }
+      .mod-id {
+        font-weight: 600;
+        color: #cfd8dc;
+      }
+      .mod-meta {
+        color: #7f8c99;
+        font-size: 12px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .mod-toggle {
+        flex: none;
+        font-size: 12px;
+        padding: 5px 12px;
+      }
+      .assets-toolbar {
+        display: flex;
+        gap: 8px;
+      }
+      .assets-body {
+        flex: 1;
+        min-height: 0;
+        overflow: auto;
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+      }
+      .assets-grid {
+        display: grid;
+        grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
+        gap: 8px;
+      }
+      .asset-tile {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 4px;
+        padding: 8px;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        background: #1c2127;
+      }
+      .asset-tile img {
+        width: 64px;
+        height: 64px;
+        object-fit: contain;
+        image-rendering: pixelated;
+      }
+      .asset-label {
+        font-size: 11px;
+        color: #cfd8dc;
+        max-width: 100%;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .asset-delete {
+        padding: 3px 10px;
+        font-size: 11px;
+      }
+      .assets-audio {
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+      }
+      .asset-row {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 8px 10px;
+        border: 1px solid #2d343d;
+        border-radius: 6px;
+        background: #1c2127;
+      }
+      .asset-info {
+        flex: 1;
+        min-width: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+      }
+      .asset-id {
+        font-weight: 600;
+        color: #cfd8dc;
+      }
+      .asset-meta {
+        color: #7f8c99;
+        font-size: 12px;
+      }
       #playtest {
         flex: 1;
         display: flex;

+ 8 - 4
apps/editor/package.json

@@ -6,23 +6,27 @@
   "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",
+    "build": "tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json && tsc -p tsconfig.renderer.json && vite build && vite build -c vite.preload.config.ts",
     "dev": "pnpm build && electron .",
     "start": "electron .",
-    "test": "vitest run"
+    "test": "vitest run",
+    "dist": "electron-builder --linux",
+    "dist:win": "electron-builder --win",
+    "build:dist": "pnpm build && pnpm dist"
   },
   "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.30.0",
     "@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",
+    "@nengine/render": "workspace:*",
     "@types/node": "^22.0.0",
     "electron": "^43.4.0",
+    "electron-builder": "^26.0.0",
     "typescript": "^5.6.0",
     "vite": "^7.0.0",
     "vitest": "^3.0.0"

+ 163 - 0
apps/editor/src/assets.ts

@@ -0,0 +1,163 @@
+import { copyFile, mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
+import { basename, extname, join, resolve } from "node:path";
+import type { AssetEntry, Engine } from "@nengine/core";
+import type {
+  EditorAssetImportError,
+  EditorAssetInfo,
+  EditorAssetsDeleteResult,
+  EditorAssetsImportResult,
+} from "./ipc.js";
+import { isSupportedAssetName, resolveWithin } from "./project.js";
+
+const ASSET_EXT_META: Record<string, { kind: AssetEntry["kind"]; mime: string }> = {
+  ".png": { kind: "image", mime: "image/png" },
+  ".ogg": { kind: "audio", mime: "audio/ogg" },
+};
+
+const ASSET_EXT: Record<AssetEntry["kind"], string> = {
+  image: "png",
+  audio: "ogg",
+};
+
+export async function listAssets(root: string): Promise<EditorAssetInfo[]> {
+  const assetDir = assetDirOf(root);
+  const files = await listSupportedFiles(assetDir);
+  const assets: EditorAssetInfo[] = [];
+  for (const file of files) {
+    const full = join(assetDir, file);
+    const meta = ASSET_EXT_META[extname(file).toLowerCase()];
+    if (!meta) continue;
+    const entry: EditorAssetInfo = {
+      id: assetIdOf(file),
+      kind: meta.kind,
+      mime: meta.mime,
+      size: (await stat(full)).size,
+    };
+    if (meta.kind === "image") {
+      const data = await readFile(full);
+      entry.dataUrl = `data:${meta.mime};base64,${data.toString("base64")}`;
+    }
+    assets.push(entry);
+  }
+  return assets.sort((a, b) => a.id.localeCompare(b.id));
+}
+
+export async function importAssets(root: string, sourcePaths: string[]): Promise<EditorAssetsImportResult> {
+  const assetDir = assetDirOf(root);
+  await mkdir(assetDir, { recursive: true });
+  const existing = await listSupportedFiles(assetDir);
+  const existingIds = new Set(existing.map((file) => assetIdOf(file).toLowerCase()));
+  const imported: EditorAssetInfo[] = [];
+  const errors: EditorAssetImportError[] = [];
+  for (const sourcePath of sourcePaths) {
+    try {
+      const full = resolve(sourcePath);
+      const ext = extname(full).toLowerCase();
+      const meta = ASSET_EXT_META[ext];
+      if (!meta) {
+        errors.push({ path: sourcePath, message: `unsupported asset type — import PNG or OGG files` });
+        continue;
+      }
+      const rawId = sanitizeAssetId(basename(full, extname(full)));
+      if (rawId === "") {
+        errors.push({ path: sourcePath, message: "cannot derive an asset id from the file name" });
+        continue;
+      }
+      const id = uniqueAssetId(rawId, existingIds);
+      existingIds.add(id.toLowerCase());
+      const targetFile = `${id}.${ASSET_EXT[meta.kind]}`;
+      await copyFile(full, join(assetDir, targetFile));
+      const entry: EditorAssetInfo = {
+        id,
+        kind: meta.kind,
+        mime: meta.mime,
+        size: (await stat(join(assetDir, targetFile))).size,
+      };
+      if (meta.kind === "image") {
+        const data = await readFile(full);
+        entry.dataUrl = `data:${meta.mime};base64,${data.toString("base64")}`;
+      }
+      imported.push(entry);
+    } catch (err) {
+      errors.push({ path: sourcePath, message: err instanceof Error ? err.message : String(err) });
+    }
+  }
+  const result: EditorAssetsImportResult = { imported };
+  if (errors.length > 0) result.errors = errors;
+  return result;
+}
+
+export async function deleteAsset(root: string, id: string): Promise<EditorAssetsDeleteResult> {
+  const assetDir = assetDirOf(root);
+  const target = (await listSupportedFiles(assetDir)).find((file) => assetIdOf(file) === id);
+  if (target === undefined) return { ok: false, error: `asset not found: ${id}` };
+  await unlink(join(assetDir, target));
+  return { ok: true };
+}
+
+/** Fresh `AssetEntry` table for `content/assets/` (D31 parity with the
+ * ContentLoader's asset scan: id = filename without extension, base64 data,
+ * unreadable files skipped best-effort). */
+export async function readAssetEntries(root: string): Promise<Map<string, AssetEntry>> {
+  const assetDir = assetDirOf(root);
+  const files = await listSupportedFiles(assetDir);
+  const entries = new Map<string, AssetEntry>();
+  for (const file of files) {
+    const meta = ASSET_EXT_META[extname(file).toLowerCase()];
+    if (!meta) continue;
+    try {
+      const data = await readFile(join(assetDir, file));
+      entries.set(assetIdOf(file), { kind: meta.kind, mime: meta.mime, data: data.toString("base64") });
+    } catch {
+      // best-effort: an unreadable asset file is skipped, not fatal (D31 parity)
+    }
+  }
+  return entries;
+}
+
+/** F2 registry refresh: the editing engine's asset table is rebuilt from disk
+ * after an import/delete. The ContentLoader's D31 `watch()` is a mechanism the
+ * engine never wires (assets hot-reload is exercised only in core tests), so
+ * the editor performs a main-side re-read instead — no core change (D31's
+ * "no asset MCP namespace / not a content kind" architecture stands). */
+export async function refreshEditEngineAssets(engine: Engine, root: string): Promise<void> {
+  const entries = await readAssetEntries(root);
+  engine.registry.assets.clear();
+  for (const [id, entry] of entries) engine.registry.assets.set(id, entry);
+}
+
+function assetDirOf(root: string): string {
+  return resolveWithin(root, join("content", "assets"));
+}
+
+async function listSupportedFiles(dir: string): Promise<string[]> {
+  try {
+    if (!(await stat(dir)).isDirectory()) return [];
+    return (await readdir(dir)).filter(isSupportedAssetName).sort();
+  } catch {
+    return [];
+  }
+}
+
+function assetIdOf(file: string): string {
+  return basename(file, extname(file));
+}
+
+function sanitizeAssetId(raw: string): string {
+  return raw
+    .toLowerCase()
+    .replace(/\s+/g, "-")
+    .replace(/[^a-z0-9._-]+/g, "")
+    .replace(/-+/g, "-")
+    .replace(/^[-.]+|[-.]+$/g, "");
+}
+
+function uniqueAssetId(rawId: string, existing: Set<string>): string {
+  let candidate = rawId;
+  let n = 2;
+  while (existing.has(candidate.toLowerCase())) {
+    candidate = `${rawId}-${n}`;
+    n++;
+  }
+  return candidate;
+}

+ 5 - 2
apps/editor/src/edit-engine.ts

@@ -55,12 +55,15 @@ export async function syncContentWrite(engine: Engine, root: string, rel: string
 
 /** 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 {
+ * when the folder exists (the mod loader needs a real dir). F1: an optional
+ * `enabledMods` list (from project.yaml `mods.enabled`; undefined = all
+ * enabled) filters which Mods the edit instance loads. */
+export function createEditEngine(root: string, enabledMods?: string[]): Engine {
   const contentDir = resolveWithin(root, "content");
   const modsPath = join(root, "mods");
   return createEngine({
     contentDir,
     ...(existsSync(modsPath) ? { modsDir: modsPath } : {}),
+    ...(enabledMods !== undefined ? { enabledMods } : {}),
   });
 }

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

@@ -16,6 +16,11 @@ export const IpcChannels = {
   playtestStart: "playtest:start",
   mcpStatus: "mcp:status",
   packRun: "pack:run",
+  listMods: "mods:list",
+  reloadMods: "mods:reload",
+  assetsList: "assets:list",
+  assetsImport: "assets:import",
+  assetsDelete: "assets:delete",
 } as const;
 
 export interface EditorProjectInfo {
@@ -163,3 +168,89 @@ export interface EditorProjectApi {
   listContent(root: string): Promise<EditorListContentResult>;
   stats(root: string): Promise<EditorStatsResult>;
 }
+
+/** F1 Mods panel: one mod's facts gathered in main (manifest read via
+ * `inspectModEntry` + the editing engine's live `ModStatus` when loaded). */
+export interface EditorModInfo {
+  id: string;
+  version: string;
+  hooks: string[];
+  loaded: boolean;
+  errors?: string[];
+  unreadable?: boolean;
+}
+
+/** F1: the Mods panel payload — `enabled` = project.yaml `mods.enabled`
+ * (null when absent = all enabled), `mods` = one entry per `mods/` dir. */
+export interface EditorModsList {
+  enabled: string[] | null;
+  mods: EditorModInfo[];
+}
+
+export interface EditorModsListResult {
+  mods: EditorModsList;
+}
+
+export interface EditorModsReloadResult {
+  ok: boolean;
+  error?: string;
+}
+
+export interface EditorModsApi {
+  list(root: string): Promise<EditorModsListResult>;
+  reload(): Promise<EditorModsReloadResult>;
+}
+
+/** F2: one asset from `content/assets/` (D31 — binary PNG/OGG, NOT a content
+ * kind, no MCP namespace). `dataUrl` is present for images only (renderer
+ * thumbnails); audio entries carry size alone (no playback in v1). */
+export interface EditorAssetInfo {
+  id: string;
+  kind: "image" | "audio";
+  mime: string;
+  size: number;
+  /** `data:<mime>;base64,...` for image assets (renderer thumbnails); absent
+   * for audio. */
+  dataUrl?: string;
+}
+
+export interface EditorAssetsListResult {
+  assets: EditorAssetInfo[];
+}
+
+/** F2: `sourcePaths` is an explicit file-path list (tests / scripted imports).
+ * Absent/empty → main opens a native multi-select file dialog (main owns all
+ * fs). Files are copied into `content/assets/` with a sanitized, deduped id
+ * (`-2`/`-3` suffix on collision — never overwrites). */
+export interface EditorAssetsImportArgs {
+  root: string;
+  sourcePaths?: string[];
+}
+
+export interface EditorAssetImportError {
+  path: string;
+  message: string;
+}
+
+export interface EditorAssetsImportResult {
+  imported: EditorAssetInfo[];
+  /** Per-file failures (unsupported extension, unreadable source, …) — the
+   * valid files still import. Absent when every file imported. */
+  errors?: EditorAssetImportError[];
+}
+
+export interface EditorAssetsDeleteArgs {
+  root: string;
+  id: string;
+}
+
+export interface EditorAssetsDeleteResult {
+  ok: boolean;
+  error?: string;
+}
+
+export interface EditorAssetsApi {
+  list(root: string): Promise<EditorAssetsListResult>;
+  import(args: EditorAssetsImportArgs): Promise<EditorAssetsImportResult>;
+  delete(args: EditorAssetsDeleteArgs): Promise<EditorAssetsDeleteResult>;
+}

+ 85 - 4
apps/editor/src/main.ts

@@ -6,10 +6,15 @@ 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 { inspectModEntry } from "@nengine/core";
+import { deleteAsset, importAssets, listAssets, refreshEditEngineAssets } from "./assets.js";
 import { createEditEngine, syncContentWrite } from "./edit-engine.js";
 import { IpcChannels } from "./ipc.js";
 import type {
+  EditorAssetsImportArgs,
+  EditorAssetsDeleteArgs,
   EditorMcpStatusResult,
+  EditorModsReloadResult,
   EditorOpenArgs,
   EditorPackRunArgs,
   EditorPackRunResult,
@@ -24,7 +29,7 @@ import { createEditorMcpHttpServer, resolveEditorMcpDirs, runStdioMcp } from "./
 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 { readProjectEnabledMods, readProjectYaml, writeProjectYaml } from "./project-yaml.js";
 import { addRecent, loadRecent, saveRecent } from "./recent.js";
 import type { EditorRecentEntry } from "./ipc.js";
 import { newProject } from "./template.js";
@@ -105,18 +110,24 @@ async function pushRecent(project: EditorProjectInfo): Promise<void> {
 /** 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. */
+ * attach on open, detach on switch/close, always on while a project is open.
+ * F1: the engine is (re)created with the project's `enabledMods` filter — a
+ * Mod toggle stops + recreates the instance (the filter is a createEngine
+ * option, so the existing reload path cannot change it). */
 let editingEngine: Engine | null = null;
+let editingRoot: string | null = null;
 let mcpHttp: EditorMcpHttpHandle | null = null;
 
 async function startEditingEngine(root: string): Promise<void> {
   await stopEditingEngine();
   try {
-    const engine = createEditEngine(root);
+    const engine = createEditEngine(root, await readProjectEnabledMods(root));
     await engine.start();
     editingEngine = engine;
+    editingRoot = root;
     if (mcpHttp !== null) await mcpHttp.attach(engine);
   } catch (err) {
+    editingRoot = null;
     console.error(`editing engine failed to start for ${root}:`, err);
   }
 }
@@ -217,7 +228,12 @@ function registerProjectIpc(): void {
       try {
         const contentDir = resolveWithin(root, "content");
         const modsDir = join(root, "mods");
-        const bundle = await compileBundle(contentDir, existsSync(modsDir) ? modsDir : undefined);
+        const bundle = await compileBundle(
+          contentDir,
+          existsSync(modsDir) ? modsDir : undefined,
+          false,
+          await readProjectEnabledMods(root),
+        );
         return { bundle };
       } catch (err) {
         return { error: err instanceof Error ? err.message : String(err) };
@@ -231,6 +247,71 @@ function registerProjectIpc(): void {
       return runEditorPack(args.root, args.options, existsSync(join(args.root, "mods")));
     },
   );
+
+  ipcMain.handle(IpcChannels.listMods, async (_event, root: string) => {
+    const listing = await listContent(root);
+    const enabled = await readProjectEnabledMods(root);
+    const loaded = new Map((editingEngine?.mods ?? []).map((status) => [status.id, status]));
+    const mods = [];
+    for (const dir of listing.mods) {
+      const inspected = await inspectModEntry(resolveWithin(root, join("mods", dir)));
+      const id = inspected?.id ?? dir;
+      const status = loaded.get(id);
+      if (status !== undefined) {
+        mods.push({
+          id: status.id,
+          version: status.version,
+          hooks: status.hooks,
+          loaded: status.loaded,
+          ...(status.errors !== undefined ? { errors: status.errors } : {}),
+        });
+        continue;
+      }
+      if (inspected !== undefined) {
+        mods.push({ id, version: inspected.version, hooks: inspected.hooks, loaded: false });
+      } else {
+        mods.push({ id: dir, version: "0.0.0", hooks: [], loaded: false, unreadable: true });
+      }
+    }
+    return { mods: { enabled: enabled ?? null, mods } };
+  });
+
+  ipcMain.handle(IpcChannels.reloadMods, async (): Promise<EditorModsReloadResult> => {
+    if (editingRoot === null) return { ok: false, error: "no project open" };
+    await startEditingEngine(editingRoot);
+    return { ok: true };
+  });
+
+  ipcMain.handle(IpcChannels.assetsList, async (_event, root: string) => {
+    return { assets: await listAssets(root) };
+  });
+
+  ipcMain.handle(IpcChannels.assetsImport, async (_event, args: EditorAssetsImportArgs) => {
+    let sourcePaths = args.sourcePaths;
+    if (sourcePaths === undefined || sourcePaths.length === 0) {
+      const picked = await dialog.showOpenDialog({
+        title: "Import assets into content/assets",
+        properties: ["openFile", "multiSelections"],
+        filters: [{ name: "Assets", extensions: ["png", "ogg"] }],
+        buttonLabel: "Import",
+      });
+      if (picked.canceled || picked.filePaths.length === 0) return { imported: [] };
+      sourcePaths = picked.filePaths;
+    }
+    const result = await importAssets(args.root, sourcePaths);
+    if (result.imported.length > 0 && editingEngine !== null) {
+      await refreshEditEngineAssets(editingEngine, args.root);
+    }
+    return result;
+  });
+
+  ipcMain.handle(IpcChannels.assetsDelete, async (_event, args: EditorAssetsDeleteArgs) => {
+    const result = await deleteAsset(args.root, args.id);
+    if (result.ok && editingEngine !== null) {
+      await refreshEditEngineAssets(editingEngine, args.root);
+    }
+    return result;
+  });
 }
 
 function createWindow(): BrowserWindow {

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

@@ -3,6 +3,7 @@ import { join } from "node:path";
 import { packGame } from "@nengine/pack";
 import type { EditorPackOptions, EditorPackRunResult, EditorPackStats } from "./ipc.js";
 import { resolvePackRequest } from "./pack-options.js";
+import { readProjectEnabledMods } from "./project-yaml.js";
 
 export async function runEditorPack(
   root: string,
@@ -10,6 +11,7 @@ export async function runEditorPack(
   hasMods: boolean,
 ): Promise<EditorPackRunResult> {
   const request = resolvePackRequest(root, options, hasMods);
+  const enabledMods = request.modsDir !== undefined ? await readProjectEnabledMods(root) : undefined;
   try {
     const result = await packGame({
       contentDir: request.contentDir,
@@ -19,6 +21,7 @@ export async function runEditorPack(
       splitAssets: request.splitAssets,
       ...(request.modsDir !== undefined ? { modsDir: request.modsDir } : {}),
       ...(request.name !== undefined ? { name: request.name } : {}),
+      ...(enabledMods !== undefined ? { enabledMods } : {}),
     });
     const stats: EditorPackStats = {
       defCount: Object.values(result.bundle.content).reduce((sum, defs) => sum + defs.length, 0),

+ 16 - 1
apps/editor/src/preload.ts

@@ -1,7 +1,11 @@
 import { contextBridge, ipcRenderer } from "electron";
 import { IpcChannels } from "./ipc.js";
 import type {
+  EditorAssetsApi,
+  EditorAssetsDeleteArgs,
+  EditorAssetsImportArgs,
   EditorMcpApi,
+  EditorModsApi,
   EditorOpenArgs,
   EditorPackApi,
   EditorPackRunArgs,
@@ -38,4 +42,15 @@ const pack: EditorPackApi = {
   run: (args: EditorPackRunArgs) => ipcRenderer.invoke(IpcChannels.packRun, args),
 };
 
-contextBridge.exposeInMainWorld("nengine", { project: api, playtest, mcp, pack });
+const mods: EditorModsApi = {
+  list: (root: string) => ipcRenderer.invoke(IpcChannels.listMods, root),
+  reload: () => ipcRenderer.invoke(IpcChannels.reloadMods),
+};
+
+const assets: EditorAssetsApi = {
+  list: (root: string) => ipcRenderer.invoke(IpcChannels.assetsList, root),
+  import: (args: EditorAssetsImportArgs) => ipcRenderer.invoke(IpcChannels.assetsImport, args),
+  delete: (args: EditorAssetsDeleteArgs) => ipcRenderer.invoke(IpcChannels.assetsDelete, args),
+};
+
+contextBridge.exposeInMainWorld("nengine", { project: api, playtest, mcp, pack, mods, assets });

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

@@ -18,6 +18,12 @@ export const projectYamlSchema = z
       })
       .strict()
       .optional(),
+    mods: z
+      .object({
+        enabled: z.array(z.string().min(1)).optional(),
+      })
+      .strict()
+      .optional(),
   })
   .strict();
 
@@ -55,3 +61,14 @@ export async function writeProjectYaml(root: string, data: EditorProjectYaml): P
   }
   await writeFile(join(root, PROJECT_YAML_NAME), `${stringify(parsed.data)}`, "utf8");
 }
+
+/** The project's enabled-Mods list (F1), or `undefined` when the field is
+ * absent (= all enabled) — tolerant of a malformed project.yaml. */
+export async function readProjectEnabledMods(root: string): Promise<string[] | undefined> {
+  try {
+    const yaml = await readProjectYaml(root);
+    return yaml?.mods?.enabled;
+  } catch {
+    return undefined;
+  }
+}

+ 8 - 1
apps/editor/src/project.ts

@@ -81,10 +81,17 @@ export async function listContent(root: string): Promise<EditorContentListing> {
     kinds.push({ kind, count: files.length, files: fileEntries });
   }
   const mods = await listNames(join(root, "mods"));
-  const assets = await listNames(join(contentRoot, "assets"));
+  const assets = (await listNames(join(contentRoot, "assets"))).filter(isSupportedAssetName);
   return { kinds, mods, assets };
 }
 
+/** The engine's supported binary asset extensions (D31: PNG images + OGG
+ * audio); other files under `content/assets/` are not assets and never reach
+ * the asset table. */
+export function isSupportedAssetName(name: string): boolean {
+  return /\.(png|ogg)$/i.test(name);
+}
+
 export async function stats(root: string): Promise<EditorProjectStats> {
   const listing = await listContent(root);
   const kinds: Record<string, number> = {};

+ 26 - 0
apps/editor/src/renderer/assets-model.ts

@@ -0,0 +1,26 @@
+import type { EditorAssetInfo } from "../ipc.js";
+
+export function imageAssets(assets: EditorAssetInfo[]): EditorAssetInfo[] {
+  return assets.filter((asset) => asset.kind === "image");
+}
+
+export function audioAssets(assets: EditorAssetInfo[]): EditorAssetInfo[] {
+  return assets.filter((asset) => asset.kind === "audio");
+}
+
+export function mergeImported(current: EditorAssetInfo[], imported: EditorAssetInfo[]): EditorAssetInfo[] {
+  const byId = new Map<string, EditorAssetInfo>();
+  for (const entry of current) byId.set(entry.id, entry);
+  for (const entry of imported) byId.set(entry.id, entry);
+  return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
+}
+
+export function removeById(current: EditorAssetInfo[], id: string): EditorAssetInfo[] {
+  return current.filter((entry) => entry.id !== id);
+}
+
+export function formatAssetSize(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}

+ 146 - 0
apps/editor/src/renderer/assets-panel.ts

@@ -0,0 +1,146 @@
+import type { EditorAssetInfo } from "../ipc.js";
+import { audioAssets, formatAssetSize, imageAssets, mergeImported, removeById } from "./assets-model.js";
+
+export interface AssetsPanelOptions {
+  root: string;
+  onStatus: (message: string, isError?: boolean) => void;
+}
+
+export function mountAssetsPanel(container: HTMLElement, opts: AssetsPanelOptions): () => void {
+  const heading = document.createElement("h3");
+  heading.textContent = "Assets";
+  const hint = document.createElement("p");
+  hint.className = "placeholder";
+  hint.textContent =
+    "Binary content (PNG images + OGG audio) referenced by id from content — sprite image layers (`src`), map tile mappings, and sound `src`.";
+  const toolbar = document.createElement("div");
+  toolbar.className = "assets-toolbar";
+  const importButton = document.createElement("button");
+  importButton.type = "button";
+  importButton.className = "primary";
+  importButton.textContent = "Import…";
+  importButton.addEventListener("click", () => void doImport());
+  toolbar.appendChild(importButton);
+  const body = document.createElement("div");
+  body.className = "assets-body";
+  container.append(heading, hint, toolbar, body);
+
+  let list: EditorAssetInfo[] = [];
+  void load();
+
+  return () => {};
+
+  async function load(): Promise<void> {
+    try {
+      const result = await window.nengine.assets.list(opts.root);
+      list = result.assets;
+    } catch (err) {
+      const p = document.createElement("p");
+      p.className = "muted";
+      p.textContent = "Failed to load assets.";
+      body.appendChild(p);
+      opts.onStatus(err instanceof Error ? err.message : String(err), true);
+      return;
+    }
+    render();
+  }
+
+  function render(): void {
+    body.replaceChildren();
+    if (list.length === 0) {
+      const p = document.createElement("p");
+      p.className = "placeholder";
+      p.textContent = "No assets — import PNG images or OGG audio files into content/assets/.";
+      body.appendChild(p);
+      return;
+    }
+    const images = imageAssets(list);
+    if (images.length > 0) {
+      const grid = document.createElement("div");
+      grid.className = "assets-grid";
+      for (const entry of images) grid.appendChild(imageTile(entry));
+      body.appendChild(grid);
+    }
+    const audio = audioAssets(list);
+    if (audio.length > 0) {
+      const section = document.createElement("div");
+      section.className = "assets-audio";
+      for (const entry of audio) section.appendChild(audioRow(entry));
+      body.appendChild(section);
+    }
+  }
+
+  function imageTile(entry: EditorAssetInfo): HTMLElement {
+    const tile = document.createElement("div");
+    tile.className = "asset-tile";
+    const img = document.createElement("img");
+    img.src = entry.dataUrl ?? "";
+    img.alt = entry.id;
+    const label = document.createElement("span");
+    label.className = "asset-label";
+    label.textContent = entry.id;
+    label.title = `${entry.id} · ${entry.mime} · ${formatAssetSize(entry.size)}`;
+    const remove = document.createElement("button");
+    remove.type = "button";
+    remove.className = "asset-delete";
+    remove.textContent = "Delete";
+    remove.addEventListener("click", () => void doDelete(entry.id));
+    tile.append(img, label, remove);
+    return tile;
+  }
+
+  function audioRow(entry: EditorAssetInfo): HTMLElement {
+    const row = document.createElement("div");
+    row.className = "asset-row";
+    const info = document.createElement("div");
+    info.className = "asset-info";
+    const idLine = document.createElement("span");
+    idLine.className = "asset-id";
+    idLine.textContent = entry.id;
+    const meta = document.createElement("span");
+    meta.className = "asset-meta";
+    meta.textContent = `audio · ${entry.mime} · ${formatAssetSize(entry.size)}`;
+    info.append(idLine, meta);
+    const remove = document.createElement("button");
+    remove.type = "button";
+    remove.className = "asset-delete";
+    remove.textContent = "Delete";
+    remove.addEventListener("click", () => void doDelete(entry.id));
+    row.append(info, remove);
+    return row;
+  }
+
+  async function doImport(): Promise<void> {
+    try {
+      const result = await window.nengine.assets.import({ root: opts.root });
+      if (result.imported.length > 0) {
+        list = mergeImported(list, result.imported);
+        const names = result.imported.map((entry) => entry.id).join(", ");
+        opts.onStatus(`imported ${result.imported.length} asset${result.imported.length === 1 ? "" : "s"}: ${names}`);
+        if (result.errors !== undefined) {
+          opts.onStatus(result.errors.map((e) => `${e.path}: ${e.message}`).join("; "), true);
+        }
+      } else if (result.errors !== undefined) {
+        opts.onStatus(result.errors.map((e) => `${e.path}: ${e.message}`).join("; "), true);
+      } else {
+        opts.onStatus("no assets imported");
+      }
+      render();
+    } catch (err) {
+      opts.onStatus(err instanceof Error ? err.message : String(err), true);
+    }
+  }
+
+  async function doDelete(id: string): Promise<void> {
+    if (!confirm(`Delete asset "${id}"? Content referencing it will fall back to shapes and colors.`)) return;
+    try {
+      const result = await window.nengine.assets.delete({ root: opts.root, id });
+      if (!result.ok) throw new Error(result.error ?? "delete failed");
+      list = removeById(list, id);
+      opts.onStatus(`deleted ${id}`);
+      render();
+    } catch (err) {
+      opts.onStatus(err instanceof Error ? err.message : String(err), true);
+    }
+  }
+}

+ 5 - 1
apps/editor/src/renderer/form-panel.ts

@@ -711,7 +711,11 @@ function buildRecordValue(valueField: FormFieldSchema | undefined, valuePath: Pa
   return buildField(valueField, valuePath, ctx);
 }
 
-function refIdList(listing: EditorContentListing, refKind: string): string[] {
+/** The reference-id list for a form `ref` field (F2): asset references resolve
+ * to the project's asset ids (`listing.assets` — the D31 asset table); content
+ * references resolve to the matching kind's file ids. Empty → the field
+ * renders as free text (an arbitrary/unlisted id is always selectable). */
+export 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) : [];

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

@@ -130,6 +130,13 @@ const REF_KIND_BY_NAME: Record<string, string> = {
   filters: "shader",
 };
 
+/** Record fields whose VALUES reference an asset (map `tiles`: tile id →
+ * asset id, D31). Kept OUT of REF_KIND_BY_NAME because the same field name
+ * means something different in another schema (building `tiles` is an array of
+ * footprint tile ids, NOT asset ids) — only record values get the asset marker
+ * (F2, documented in docs/editor.md). */
+const ASSET_RECORD_VALUE_FIELDS: Record<string, string> = { tiles: "asset" };
+
 /**
  * Cross-kind reference heuristic: a string field whose name ends in `Id`/`Ref`/
  * `Src` references the content kind named by the base (`itemId` → item,
@@ -375,6 +382,11 @@ function recordField(
     throw new FormSchemaError(`record field "${name}" has no value schema`);
   }
   const value = deriveField(name, valueSchema, depth + 1);
+  const recordRefKind = ASSET_RECORD_VALUE_FIELDS[name];
+  if (recordRefKind !== undefined && value.type === "string") {
+    value.type = "ref";
+    value.refKind = recordRefKind;
+  }
   return { name, label, type: "record", optional, nullable, recordValue: value };
 }
 

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

@@ -13,6 +13,8 @@ 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";
+import { mountModsPanel } from "./mods-panel.js";
+import { mountAssetsPanel } from "./assets-panel.js";
 
 function element(id: string): HTMLElement {
   const el = document.getElementById(id);
@@ -209,9 +211,57 @@ async function renderContent(): Promise<void> {
     contentList.appendChild(li);
   }
   const { stats } = await window.nengine.project.stats(currentProject.root);
+  const modsLi = document.createElement("li");
+  modsLi.className = "clickable";
+  modsLi.title = listing.mods.map((id) => `mods/${id}`).join("\n") || "no mods";
+  const modsName = document.createElement("span");
+  modsName.className = "kind-name";
+  modsName.textContent = "Mods";
+  const modsCount = document.createElement("span");
+  modsCount.className = "kind-count";
+  modsCount.textContent = String(listing.mods.length);
+  modsLi.append(modsName, modsCount);
+  modsLi.addEventListener("click", () => void openMods());
+  contentList.appendChild(modsLi);
+  const assetsLi = document.createElement("li");
+  assetsLi.className = "clickable";
+  assetsLi.title = listing.assets.map((id) => `content/assets/${id}.png|.ogg`).join("\n") || "no assets";
+  const assetsName = document.createElement("span");
+  assetsName.className = "kind-name";
+  assetsName.textContent = "Assets";
+  const assetsCount = document.createElement("span");
+  assetsCount.className = "kind-count";
+  assetsCount.textContent = String(listing.assets.length);
+  assetsLi.append(assetsName, assetsCount);
+  assetsLi.addEventListener("click", () => void openAssets());
+  contentList.appendChild(assetsLi);
   setStatus(`${stats.total} content files · ${stats.mods} mods · ${stats.assets} assets`);
 }
 
+async function openMods(): Promise<void> {
+  if (currentProject === null) return;
+  disposeCurrentEditor();
+  disposeEditor = mountModsPanel(editorPanel, {
+    root: currentProject.root,
+    onStatus: setStatus,
+  });
+  setStatus(
+    currentListing !== null && currentListing.mods.length > 0
+      ? "Mods — toggle which Mods load in the edit engine, playtest, and packaging"
+      : "No mods directory",
+  );
+}
+
+async function openAssets(): Promise<void> {
+  if (currentProject === null) return;
+  disposeCurrentEditor();
+  disposeEditor = mountAssetsPanel(editorPanel, {
+    root: currentProject.root,
+    onStatus: setStatus,
+  });
+  setStatus("Assets — import PNG images and OGG audio; content references them by id");
+}
+
 function disposeCurrentEditor(): void {
   flushCollector = null;
   if (disposeEditor !== null) {

+ 26 - 0
apps/editor/src/renderer/mods-model.ts

@@ -0,0 +1,26 @@
+import type { EditorModsList } from "../ipc.js";
+
+export function isModEnabled(list: EditorModsList, id: string): boolean {
+  return list.enabled === null || list.enabled.includes(id);
+}
+
+export function enabledModsCount(list: EditorModsList): number {
+  if (list.enabled === null) return list.mods.length;
+  const enabled = new Set(list.enabled);
+  return list.mods.filter((mod) => enabled.has(mod.id)).length;
+}
+
+/** The next `mods.enabled` list after toggling `id` to `on`. A null current
+ * list (absent = all enabled) becomes explicit on the first disable: every
+ * known mod minus the disabled one, so the remaining list still reflects
+ * "everything on except this". An empty result stays `[]` (all disabled) —
+ * deleting the field would mean all enabled again. */
+export function toggleEnabled(list: EditorModsList, id: string, on: boolean): string[] {
+  const allIds = list.mods.map((mod) => mod.id);
+  if (on) {
+    const base = list.enabled ?? allIds;
+    return base.includes(id) ? base : [...base, id];
+  }
+  if (list.enabled === null) return allIds.filter((x) => x !== id);
+  return list.enabled.filter((x) => x !== id);
+}

+ 96 - 0
apps/editor/src/renderer/mods-panel.ts

@@ -0,0 +1,96 @@
+import type { EditorModsList } from "../ipc.js";
+import type { EditorProjectYaml } from "../project-yaml.js";
+import { isModEnabled, toggleEnabled } from "./mods-model.js";
+
+export interface ModsPanelOptions {
+  root: string;
+  onStatus: (message: string, isError?: boolean) => void;
+}
+
+export function mountModsPanel(container: HTMLElement, opts: ModsPanelOptions): () => void {
+  const heading = document.createElement("h3");
+  heading.textContent = "Mods";
+  const hint = document.createElement("p");
+  hint.className = "placeholder";
+  hint.textContent = "Toggle which Mods participate in the edit engine, playtest, and packaging — saved to project.yaml `mods.enabled`.";
+  const body = document.createElement("div");
+  body.className = "mods-body";
+  const listEl = document.createElement("ul");
+  listEl.className = "mods-list";
+  body.appendChild(listEl);
+  container.append(heading, hint, body);
+  void render();
+  return () => {};
+
+  async function render(): Promise<void> {
+    listEl.replaceChildren();
+    let list: EditorModsList;
+    try {
+      const result = await window.nengine.mods.list(opts.root);
+      list = result.mods;
+    } catch (err) {
+      const li = document.createElement("li");
+      li.className = "muted";
+      li.textContent = "Failed to load mods.";
+      listEl.appendChild(li);
+      opts.onStatus(err instanceof Error ? err.message : String(err), true);
+      return;
+    }
+    if (list.mods.length === 0) {
+      const li = document.createElement("li");
+      li.className = "muted";
+      li.textContent = "No mods — create mods/<id>/index.mjs (or index.js) under the project root.";
+      listEl.appendChild(li);
+      return;
+    }
+    for (const mod of list.mods) {
+      const li = document.createElement("li");
+      li.className = "mod-row";
+      const info = document.createElement("div");
+      info.className = "mod-info";
+      const idLine = document.createElement("span");
+      idLine.className = "mod-id";
+      idLine.textContent = mod.id;
+      const meta = document.createElement("span");
+      meta.className = "mod-meta";
+      const parts = [`v${mod.version}`];
+      if (mod.hooks.length > 0) parts.push(`hooks: ${mod.hooks.join(", ")}`);
+      if (mod.loaded) parts.push("loaded");
+      if (mod.unreadable) parts.push("unreadable");
+      if (mod.errors !== undefined) parts.push(mod.errors.join("; "));
+      meta.textContent = parts.join(" · ");
+      info.append(idLine, meta);
+      const on = isModEnabled(list, mod.id);
+      const toggle = document.createElement("button");
+      toggle.type = "button";
+      toggle.className = "mod-toggle";
+      toggle.textContent = on ? "Disable" : "Enable";
+      toggle.addEventListener("click", () => void setEnabled(mod.id, !on, list));
+      li.append(info, toggle);
+      listEl.appendChild(li);
+    }
+  }
+
+  async function setEnabled(id: string, on: boolean, list: EditorModsList): Promise<void> {
+    const data: EditorProjectYaml = { ...(await readProjectYaml()), mods: { enabled: toggleEnabled(list, id, on) } };
+    try {
+      await window.nengine.project.writeProjectYaml({ root: opts.root, data });
+      const reload = await window.nengine.mods.reload();
+      if (!reload.ok) throw new Error(reload.error ?? "mods reload failed");
+      opts.onStatus(on ? `Enabled ${id}` : `Disabled ${id}`);
+      void render();
+    } catch (err) {
+      opts.onStatus(err instanceof Error ? err.message : String(err), true);
+    }
+  }
+
+  async function readProjectYaml(): Promise<EditorProjectYaml | null> {
+    try {
+      const current = await window.nengine.project.readProjectYaml(opts.root);
+      return current.data;
+    } catch (err) {
+      opts.onStatus(err instanceof Error ? err.message : String(err), true);
+      return null;
+    }
+  }
+}

+ 16 - 2
apps/editor/src/renderer/nengine.d.ts

@@ -1,8 +1,22 @@
-import type { EditorMcpApi, EditorPackApi, EditorPlaytestApi, EditorProjectApi } from "../ipc.js";
+import type {
+  EditorAssetsApi,
+  EditorMcpApi,
+  EditorModsApi,
+  EditorPackApi,
+  EditorPlaytestApi,
+  EditorProjectApi,
+} from "../ipc.js";
 
 declare global {
   interface Window {
-    nengine: { project: EditorProjectApi; playtest: EditorPlaytestApi; mcp: EditorMcpApi; pack: EditorPackApi };
+    nengine: {
+      project: EditorProjectApi;
+      playtest: EditorPlaytestApi;
+      mcp: EditorMcpApi;
+      pack: EditorPackApi;
+      mods: EditorModsApi;
+      assets: EditorAssetsApi;
+    };
   }
 }
 

+ 36 - 0
apps/editor/test/assets-model.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import type { EditorAssetInfo } from "../src/ipc.js";
+import { audioAssets, formatAssetSize, imageAssets, mergeImported, removeById } from "../src/renderer/assets-model.js";
+
+function image(id: string): EditorAssetInfo {
+  return { id, kind: "image", mime: "image/png", size: 10, dataUrl: `data:image/png;base64,${id}` };
+}
+
+function audio(id: string): EditorAssetInfo {
+  return { id, kind: "audio", mime: "audio/ogg", size: 20 };
+}
+
+describe("assets-model (F2)", () => {
+  it("splits the list into images and audio", () => {
+    const list = [image("a"), audio("b"), image("c")];
+    expect(imageAssets(list).map((a) => a.id)).toEqual(["a", "c"]);
+    expect(audioAssets(list).map((a) => a.id)).toEqual(["b"]);
+  });
+
+  it("mergeImported adds new ids, replaces existing ones, and sorts by id", () => {
+    const current = [image("b"), image("a")];
+    const merged = mergeImported(current, [audio("c"), image("a")]);
+    expect(merged.map((a) => a.id)).toEqual(["a", "b", "c"]);
+    expect(merged[0]?.kind).toBe("image");
+    expect(merged[2]?.kind).toBe("audio");
+  });
+
+  it("removeById drops the deleted entry and formatAssetSize is human-readable", () => {
+    const list = [image("a"), audio("b")];
+    expect(removeById(list, "a").map((a) => a.id)).toEqual(["b"]);
+    expect(removeById(list, "missing").map((a) => a.id)).toEqual(["a", "b"]);
+    expect(formatAssetSize(512)).toBe("512 B");
+    expect(formatAssetSize(2048)).toBe("2.0 KB");
+    expect(formatAssetSize(3 * 1024 * 1024)).toBe("3.0 MB");
+  });
+});

+ 202 - 0
apps/editor/test/assets.test.ts

@@ -0,0 +1,202 @@
+import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { deleteAsset, importAssets, listAssets, readAssetEntries, refreshEditEngineAssets } from "../src/assets.js";
+import { createEditEngine } from "../src/edit-engine.js";
+
+const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
+const OGG_BASE64 = "T2dnUwAAAAAAAAAAAAAAAAAAAAAAAE9nZ1M=";
+
+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: 2",
+    "height: 2",
+    "tilesetRef: farm-tiles",
+    "spawn: { x: 0, y: 0 }",
+    "layers:",
+    "  - name: ground",
+    "    data:",
+    "      - [grass, grass]",
+    "      - [grass, grass]",
+    "",
+  ].join("\n"),
+};
+
+async function tmpDir(): Promise<string> {
+  return mkdtemp(join(tmpdir(), "editor-assets-"));
+}
+
+async function fixtureProject(): Promise<string> {
+  const dir = await tmpDir();
+  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");
+  }
+  await mkdir(join(dir, "content", "assets"), { recursive: true });
+  await mkdir(join(dir, "sources"), { recursive: true });
+  return dir;
+}
+
+async function writeSource(dir: string, name: string, base64: string): Promise<string> {
+  const full = join(dir, "sources", name);
+  await mkdir(dirname(full), { recursive: true });
+  await writeFile(full, Buffer.from(base64, "base64"));
+  return full;
+}
+
+describe("listAssets (F2)", () => {
+  it("scans png + ogg with dataUrl on images only, sorted by id", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content", "assets"), { recursive: true });
+      await writeFile(join(dir, "content", "assets", "b-1.ogg"), Buffer.from(OGG_BASE64, "base64"));
+      await writeFile(join(dir, "content", "assets", "a-1.png"), Buffer.from(PNG_BASE64, "base64"));
+      const assets = await listAssets(dir);
+      expect(assets.map((a) => a.id)).toEqual(["a-1", "b-1"]);
+      const image = assets[0];
+      expect(image?.kind).toBe("image");
+      expect(image?.mime).toBe("image/png");
+      expect(image?.dataUrl).toBe(`data:image/png;base64,${PNG_BASE64}`);
+      expect(image?.size).toBe(Buffer.from(PNG_BASE64, "base64").length);
+      const audio = assets[1];
+      expect(audio?.kind).toBe("audio");
+      expect(audio?.mime).toBe("audio/ogg");
+      expect(audio?.dataUrl).toBeUndefined();
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("ignores non-asset files and a missing assets dir", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content", "assets"), { recursive: true });
+      await writeFile(join(dir, "content", "assets", "notes.txt"), "not an asset");
+      expect(await listAssets(dir)).toEqual([]);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("importAssets (F2)", () => {
+  it("copies source files into content/assets with a sanitized id", async () => {
+    const dir = await tmpDir();
+    try {
+      const src = await writeSource(dir, "My Icon (1).png", PNG_BASE64);
+      const src2 = await writeSource(dir, "click-1.ogg", OGG_BASE64);
+      const result = await importAssets(dir, [src, src2]);
+      expect(result.errors).toBeUndefined();
+      expect(result.imported.map((a) => a.id)).toEqual(["my-icon-1", "click-1"]);
+      expect(await readdir(join(dir, "content", "assets"))).toEqual(["click-1.ogg", "my-icon-1.png"]);
+      const original = await readFile(src);
+      expect(await readFile(join(dir, "content", "assets", "my-icon-1.png"))).toEqual(original);
+      expect(result.imported.find((a) => a.id === "my-icon-1")?.dataUrl).toBe(`data:image/png;base64,${PNG_BASE64}`);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("dedupes a colliding basename with a -2 suffix and never overwrites", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content", "assets"), { recursive: true });
+      await writeFile(join(dir, "content", "assets", "tile-1.png"), Buffer.from(OGG_BASE64, "base64"));
+      const src = await writeSource(dir, "tile-1.png", PNG_BASE64);
+      const srcAgain = await writeSource(dir, "tile-1.png", PNG_BASE64);
+      const first = await importAssets(dir, [src]);
+      expect(first.imported.map((a) => a.id)).toEqual(["tile-1-2"]);
+      expect((await stat(join(dir, "content", "assets", "tile-1.png"))).size).toBe(
+        Buffer.from(OGG_BASE64, "base64").length,
+      );
+      const second = await importAssets(dir, [srcAgain]);
+      expect(second.imported.map((a) => a.id)).toEqual(["tile-1-3"]);
+      expect(await readdir(join(dir, "content", "assets"))).toEqual(["tile-1-2.png", "tile-1-3.png", "tile-1.png"]);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("rejects unsupported extensions per-file while still importing the valid ones", async () => {
+    const dir = await tmpDir();
+    try {
+      const ok = await writeSource(dir, "grass-1.png", PNG_BASE64);
+      const bad = await writeSource(dir, "readme.txt", "hello");
+      const result = await importAssets(dir, [bad, ok]);
+      expect(result.imported.map((a) => a.id)).toEqual(["grass-1"]);
+      expect(result.errors).toHaveLength(1);
+      expect(result.errors?.[0]?.path).toBe(bad);
+      expect(result.errors?.[0]?.message).toMatch(/unsupported asset type/);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("deleteAsset (F2)", () => {
+  it("removes the file for a known id and errors for an unknown one", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content", "assets"), { recursive: true });
+      await writeFile(join(dir, "content", "assets", "a-1.png"), Buffer.from(PNG_BASE64, "base64"));
+      await writeFile(join(dir, "content", "assets", "b-1.ogg"), Buffer.from(OGG_BASE64, "base64"));
+      expect(await deleteAsset(dir, "a-1")).toEqual({ ok: true });
+      expect(await readdir(join(dir, "content", "assets"))).toEqual(["b-1.ogg"]);
+      const missing = await deleteAsset(dir, "a-1");
+      expect(missing.ok).toBe(false);
+      expect(missing.error).toBe("asset not found: a-1");
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("readAssetEntries + refreshEditEngineAssets (F2)", () => {
+  it("reads the same table shape the ContentLoader produces (D31 parity)", async () => {
+    const dir = await tmpDir();
+    try {
+      await mkdir(join(dir, "content", "assets"), { recursive: true });
+      await writeFile(join(dir, "content", "assets", "grass-1.png"), Buffer.from(PNG_BASE64, "base64"));
+      await writeFile(join(dir, "content", "assets", "click-1.ogg"), Buffer.from(OGG_BASE64, "base64"));
+      const entries = await readAssetEntries(dir);
+      expect(entries.get("grass-1")).toEqual({ kind: "image", mime: "image/png", data: PNG_BASE64 });
+      expect(entries.get("click-1")).toEqual({ kind: "audio", mime: "audio/ogg", data: OGG_BASE64 });
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("rebuilds the edit engine's asset table after an import + delete", async () => {
+    const dir = await fixtureProject();
+    try {
+      const engine = createEditEngine(dir);
+      await engine.start();
+      try {
+        expect(engine.assets.size).toBe(0);
+        const src = await writeSource(dir, "grass-1.png", PNG_BASE64);
+        const imported = await importAssets(dir, [src]);
+        expect(imported.imported.map((a) => a.id)).toEqual(["grass-1"]);
+        await refreshEditEngineAssets(engine, dir);
+        expect(engine.assets.get("grass-1")?.kind).toBe("image");
+        await expect(deleteAsset(dir, "grass-1")).resolves.toEqual({ ok: true });
+        await refreshEditEngineAssets(engine, dir);
+        expect(engine.assets.size).toBe(0);
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});

+ 176 - 0
apps/editor/test/electron-builder-config.test.ts

@@ -0,0 +1,176 @@
+import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
+import { dirname, join, relative, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { parse } from "yaml";
+
+// F4 (D41 #4/#6) packaging contract: the editor's electron-builder config must
+// stay valid, its files globs must cover the compiled entrypoints, and every
+// main-process runtime import must resolve inside the production dependency
+// closure (so the packaged app's auto-collected node_modules contains it).
+
+const APP_DIR = resolve(process.cwd());
+const CONFIG = readFileSync(join(APP_DIR, "electron-builder.yml"), "utf8");
+const config = parse(CONFIG) as Record<string, unknown>;
+
+const PACKAGE_JSON = JSON.parse(readFileSync(join(APP_DIR, "package.json"), "utf8")) as {
+  main?: string;
+  scripts?: Record<string, string>;
+  dependencies?: Record<string, string>;
+};
+
+function asStringList(value: unknown): string[] {
+  return Array.isArray(value) ? value.map((v) => String(v)) : [];
+}
+
+function escapeRegex(text: string): string {
+  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/** Minimal glob matcher for electron-builder-style patterns (`**` + `*`). */
+function globMatches(pattern: string, relPath: string): boolean {
+  const parts = pattern.split("/");
+  const pathParts = relPath.split("/");
+  const queue = [{ p: 0, q: 0 }];
+  const seen = new Set<string>();
+  while (queue.length > 0) {
+    const { p, q } = queue.pop() as { p: number; q: number };
+    const key = `${p}:${q}`;
+    if (seen.has(key)) continue;
+    seen.add(key);
+    if (p === parts.length) {
+      if (q === pathParts.length) return true;
+      continue;
+    }
+    const part = parts[p] ?? "";
+    if (part === "**") {
+      queue.push({ p: p + 1, q });
+      if (q < pathParts.length) queue.push({ p, q: q + 1 });
+      continue;
+    }
+    if (q >= pathParts.length) continue;
+    const re = new RegExp(`^${part.split("*").map(escapeRegex).join("[^/]*")}$`);
+    if (re.test(pathParts[q] ?? "")) queue.push({ p: p + 1, q: q + 1 });
+  }
+  return false;
+}
+
+function walkFiles(dir: string, base: string, out: string[]): string[] {
+  for (const entry of readdirSync(dir)) {
+    const abs = join(dir, entry);
+    const rel = relative(base, abs);
+    if (statSync(abs).isDirectory()) walkFiles(abs, base, out);
+    else out.push(rel);
+  }
+  return out;
+}
+
+function fileList(): string[] {
+  return walkFiles(join(APP_DIR, "dist"), APP_DIR, []);
+}
+
+function productionDeps(): Set<string> {
+  const closure = new Set<string>();
+  const visit = (pkgDir: string): void => {
+    const pkgPath = join(pkgDir, "package.json");
+    if (!existsSync(pkgPath)) return;
+    const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies?: Record<string, string> };
+    for (const dep of Object.keys(pkg.dependencies ?? {})) {
+      if (closure.has(dep)) continue;
+      closure.add(dep);
+      const found = join(pkgDir, "node_modules", dep);
+      if (existsSync(found)) visit(found);
+    }
+  };
+  visit(APP_DIR);
+  return closure;
+}
+
+function mainProcessImports(): Map<string, string[]> {
+  const imports = new Map<string, string[]>();
+  const source = readFileSync(join(APP_DIR, "dist/main/main.js"), "utf8");
+  const specs = [...source.matchAll(/^\s*(?:import|export)[^'"`]*?\bfrom\s+["']([^"']+)["']/gm)].map((m) => m[1] ?? "");
+  specs.push(...[...source.matchAll(/^\s*import\s+["']([^"']+)["']/gm)].map((m) => m[1] ?? ""));
+  for (const spec of specs) {
+    if (spec.startsWith(".") || spec.startsWith("node:")) continue;
+    const top = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0] ?? spec;
+    if (imports.has(top)) continue;
+    imports.set(top, []);
+    try {
+      findPkgDir(top);
+    } catch {
+      imports.set(top, [spec]);
+    }
+  }
+  return imports;
+}
+
+/** Node-style node_modules walk upward from the app dir (follows symlinks). */
+function findPkgDir(top: string): string {
+  let dir = APP_DIR;
+  for (;;) {
+    const candidate = join(dir, "node_modules", top);
+    if (existsSync(join(candidate, "package.json"))) return candidate;
+    const parent = dirname(dir);
+    if (parent === dir) throw new Error(`cannot find package ${top}`);
+    dir = parent;
+  }
+}
+
+describe("electron-builder config (F4 editor distribution)", () => {
+  it("is valid and pins appId/productName/output/targets", () => {
+    expect(typeof config).toBe("object");
+    expect(config.appId).toBe("com.nengine.editor");
+    expect(config.productName).toBe("nEngine Editor");
+    expect(config.directories).toMatchObject({ output: "dist-app" });
+
+    const linux = config.linux as { target?: string[]; category?: string };
+    expect(asStringList(linux?.target)).toContain("AppImage");
+    expect(linux?.category).toBe("Development");
+
+    const win = config.win as { target?: string[] };
+    expect(asStringList(win?.target)).toContain("nsis");
+
+    const files = asStringList(config.files);
+    expect(files).toContain("dist/**");
+    expect(files).toContain("package.json");
+  });
+
+  it("main/scripts point at the packaged layout (dist-app, electron-builder --linux)", () => {
+    expect(PACKAGE_JSON.main).toBe("dist/main/main.js");
+    expect(PACKAGE_JSON.scripts?.dist).toBe("electron-builder --linux");
+    expect(PACKAGE_JSON.scripts?.["dist:win"]).toBe("electron-builder --win");
+  });
+
+  it("files globs cover the compiled entrypoints of a fresh build output", () => {
+    expect(existsSync(join(APP_DIR, "dist/main/main.js"))).toBe(true);
+    expect(existsSync(join(APP_DIR, "dist/preload/preload.js"))).toBe(true);
+    expect(existsSync(join(APP_DIR, "dist/renderer/index.html"))).toBe(true);
+
+    const files = asStringList(config.files);
+    const listing = fileList();
+    expect(listing).toContain("dist/main/main.js");
+    expect(listing).toContain("dist/main/mcp-stdio.js");
+    expect(listing).toContain("dist/preload/preload.js");
+    expect(listing).toContain("dist/renderer/index.html");
+
+    const covered = new Set(listing.filter((rel) => files.some((pattern) => globMatches(pattern, rel))));
+    expect(covered.has("dist/main/main.js")).toBe(true);
+    expect(covered.has("dist/main/mcp-stdio.js")).toBe(true);
+    expect(covered.has("dist/preload/preload.js")).toBe(true);
+    expect(covered.has("dist/renderer/index.html")).toBe(true);
+    expect(covered.size).toBeGreaterThan(0);
+  });
+
+  it("main-process runtime imports resolve inside the production dependency closure", () => {
+    const closure = productionDeps();
+    const unresolvable = mainProcessImports();
+    for (const [top, missing] of unresolvable) {
+      // electron is provided by the Electron runtime binary, not packaged
+      if (top === "electron") continue;
+      expect(missing, `cannot resolve ${top} from the compiled main process`).toEqual([]);
+      expect(closure.has(top), `@nengine/editor must declare ${top} (or a dependency chain to it) as a runtime dependency`).toBe(true);
+    }
+    expect(closure.has("vite")).toBe(true);
+    expect(closure.has("@modelcontextprotocol/sdk")).toBe(true);
+  });
+});

+ 62 - 0
apps/editor/test/form-ref-assets.test.ts

@@ -0,0 +1,62 @@
+import { mapSchema, spriteSchema, soundSchema } from "@nengine/core-schema";
+import { describe, expect, it } from "vitest";
+import type { EditorContentListing } from "../src/ipc.js";
+import { deriveFormSchema } from "../src/renderer/form-schema.js";
+import { refIdList } from "../src/renderer/form-panel.js";
+
+function listing(overrides?: Partial<EditorContentListing>): EditorContentListing {
+  return {
+    kinds: [
+      { kind: "sprite", count: 2, files: [{ id: "player", name: "player.yaml", path: "content/sprites/player.yaml" }] },
+      { kind: "sound", count: 1, files: [{ id: "click", name: "click.yaml", path: "content/sounds/click.yaml" }] },
+    ],
+    mods: [],
+    assets: ["grass-1", "water-1"],
+    ...overrides,
+  };
+}
+
+function field(schema: Parameters<typeof deriveFormSchema>[0], name: string) {
+  const found = deriveFormSchema(schema).fields.find((f) => f.name === name);
+  if (found === undefined) throw new Error(`no field "${name}"`);
+  return found;
+}
+
+describe("form-ref asset fields (F2)", () => {
+  it("sprite image layer src and sound src already resolve to the asset kind", () => {
+    const frames = field(spriteSchema, "frames");
+    expect(frames.type).toBe("array");
+    const layers = frames.element?.fields?.find((f) => f.name === "layers");
+    expect(layers?.type).toBe("array");
+    const layer = layers?.element;
+    expect(layer?.type).toBe("union");
+    const imageBranch = layer?.branches?.find((b) => b.id === "image");
+    const src = imageBranch?.fields?.find((f) => f.name === "src");
+    expect(src?.type).toBe("ref");
+    expect(src?.refKind).toBe("asset");
+    const soundSrc = field(soundSchema, "src");
+    expect(soundSrc.type).toBe("ref");
+    expect(soundSrc.refKind).toBe("asset");
+  });
+
+  it("map tiles record values resolve to the asset kind (tile id → asset id)", () => {
+    const tiles = field(mapSchema, "tiles");
+    expect(tiles.type).toBe("record");
+    expect(tiles.optional).toBe(true);
+    expect(tiles.recordValue?.type).toBe("ref");
+    expect(tiles.recordValue?.refKind).toBe("asset");
+  });
+
+  it("refIdList feeds asset ids for asset refs and kind ids for content refs", () => {
+    const l = listing();
+    expect(refIdList(l, "asset")).toEqual(["grass-1", "water-1"]);
+    expect(refIdList(l, "sprite")).toEqual(["player"]);
+    expect(refIdList(l, "sound")).toEqual(["click"]);
+  });
+
+  it("an empty asset list yields [] — the free-text fallback path", () => {
+    const l = listing({ assets: [] });
+    expect(refIdList(l, "asset")).toEqual([]);
+    expect(refIdList(l, "unknown-kind")).toEqual([]);
+  });
+});

+ 130 - 0
apps/editor/test/mods-consumers.test.ts

@@ -0,0 +1,130 @@
+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 { compileBundle } from "@nengine/pack";
+import { createEditEngine } from "../src/edit-engine.js";
+import { runEditorPack } from "../src/pack.js";
+import { writeProjectYaml } from "../src/project-yaml.js";
+
+const MOD_A = [
+  "export default {",
+  '  manifest: { id: "a-mod", version: "1.0.0" },',
+  "  hooks: { onDayStart() {} },",
+  "};",
+  "",
+].join("\n");
+const MOD_B = [
+  "export default {",
+  '  manifest: { id: "b-mod", version: "1.0.0" },',
+  "  hooks: { onDayStart() {} },",
+  "};",
+  "",
+].join("\n");
+
+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: 8",
+    "height: 8",
+    "tilesetRef: farm-tiles",
+    "spawn: { x: 1, y: 1 }",
+    "layers:",
+    "  - name: ground",
+    "    data:",
+    ...mapRows(),
+    "",
+  ].join("\n"),
+  "mods/a-mod/index.mjs": MOD_A,
+  "mods/b-mod/index.mjs": MOD_B,
+};
+
+function mapRows(): string[] {
+  const rows: string[] = [];
+  for (let y = 0; y < 8; y++) {
+    const row: string[] = [];
+    for (let x = 0; x < 8; x++) row.push("grass");
+    rows.push(`      - [${row.join(", ")}]`);
+  }
+  return rows;
+}
+
+async function fixtureDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), "editor-mods-consumers-"));
+  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 packOptions(): Parameters<typeof runEditorPack>[1] {
+  return { name: "Mods Farm", icon: "", desktop: false, installer: false, mods: true, splitAssets: false, outDir: "" };
+}
+
+describe("F1 consumer wiring", () => {
+  it("createEditEngine applies the enabledMods filter (undefined = all)", async () => {
+    const dir = await fixtureDir();
+    try {
+      const filtered = createEditEngine(dir, ["a-mod"]);
+      await filtered.start();
+      try {
+        expect(filtered.mods.map((m) => m.id)).toEqual(["a-mod"]);
+      } finally {
+        await filtered.stop();
+      }
+      const all = createEditEngine(dir);
+      await all.start();
+      try {
+        expect(all.mods.map((m) => m.id).sort()).toEqual(["a-mod", "b-mod"]);
+      } finally {
+        await all.stop();
+      }
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("compileBundle with an enabledMods list packages only the enabled mods", async () => {
+    const dir = await fixtureDir();
+    try {
+      const filtered = await compileBundle(join(dir, "content"), join(dir, "mods"), false, ["b-mod"]);
+      expect(Object.keys(filtered.mods ?? {})).toEqual(["b-mod"]);
+      const all = await compileBundle(join(dir, "content"), join(dir, "mods"));
+      expect(Object.keys(all.mods ?? {}).sort()).toEqual(["a-mod", "b-mod"]);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("runEditorPack honors project.yaml mods.enabled (main parity)", async () => {
+    const dir = await fixtureDir();
+    try {
+      await writeProjectYaml(dir, { mods: { enabled: ["a-mod"] } });
+      const result = await runEditorPack(dir, packOptions(), existsSync(join(dir, "mods")));
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+      expect(result.stats.modCount).toBe(1);
+      const bundle = JSON.parse(
+        await (await import("node:fs/promises")).readFile(join(result.outDir, "bundle.json"), "utf8"),
+      );
+      expect(Object.keys(bundle.mods ?? {})).toEqual(["a-mod"]);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+});

+ 50 - 0
apps/editor/test/mods-model.test.ts

@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import type { EditorModsList } from "../src/ipc.js";
+import { enabledModsCount, isModEnabled, toggleEnabled } from "../src/renderer/mods-model.js";
+
+function list(enabled: string[] | null, ids: string[]): EditorModsList {
+  return { enabled, mods: ids.map((id) => ({ id, version: "1.0.0", hooks: ["onDayStart"], loaded: true })) };
+}
+
+describe("mods-model toggle logic (F1)", () => {
+  it("a null enabled list means every known mod is enabled", () => {
+    const l = list(null, ["a-mod", "b-mod"]);
+    expect(isModEnabled(l, "a-mod")).toBe(true);
+    expect(isModEnabled(l, "b-mod")).toBe(true);
+    expect(enabledModsCount(l)).toBe(2);
+  });
+
+  it("disabling a mod with no explicit list makes the rest explicit", () => {
+    const l = list(null, ["a-mod", "b-mod", "c-mod"]);
+    const next = toggleEnabled(l, "b-mod", false);
+    expect(next).toEqual(["a-mod", "c-mod"]);
+    const l2 = list(next, ["a-mod", "b-mod", "c-mod"]);
+    expect(isModEnabled(l2, "b-mod")).toBe(false);
+    expect(enabledModsCount(l2)).toBe(2);
+  });
+
+  it("re-enabling appends the id back", () => {
+    const l = list(["a-mod", "c-mod"], ["a-mod", "b-mod", "c-mod"]);
+    const next = toggleEnabled(l, "b-mod", true);
+    expect(next).toEqual(["a-mod", "c-mod", "b-mod"]);
+  });
+
+  it("disabling with an explicit list removes only that id", () => {
+    const l = list(["a-mod", "b-mod", "c-mod"], ["a-mod", "b-mod", "c-mod"]);
+    expect(toggleEnabled(l, "a-mod", false)).toEqual(["b-mod", "c-mod"]);
+  });
+
+  it("disabling the last mod yields an empty list (all disabled), never a deletion", () => {
+    const l = list(null, ["a-mod"]);
+    expect(toggleEnabled(l, "a-mod", false)).toEqual([]);
+    const l2 = list([], ["a-mod"]);
+    expect(toggleEnabled(l2, "a-mod", true)).toEqual(["a-mod"]);
+    expect(enabledModsCount(list([], ["a-mod"]))).toBe(0);
+  });
+
+  it("an unknown id in the enabled list is counted but never toggles others", () => {
+    const l = list(["ghost"], ["a-mod"]);
+    expect(enabledModsCount(l)).toBe(0);
+    expect(isModEnabled(l, "a-mod")).toBe(false);
+  });
+});

+ 61 - 1
apps/editor/test/project-yaml.test.ts

@@ -2,7 +2,13 @@ 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";
+import {
+  PROJECT_YAML_NAME,
+  projectYamlSchema,
+  readProjectEnabledMods,
+  readProjectYaml,
+  writeProjectYaml,
+} from "../src/project-yaml.js";
 
 async function tmpDir(): Promise<string> {
   return mkdtemp(join(tmpdir(), "editor-yaml-"));
@@ -18,12 +24,23 @@ describe("projectYamlSchema", () => {
     ).toBe(true);
   });
 
+  it("accepts the F1 mods field (absent / empty / enabled list)", () => {
+    expect(projectYamlSchema.safeParse({ mods: {} }).success).toBe(true);
+    expect(projectYamlSchema.safeParse({ mods: { enabled: [] } }).success).toBe(true);
+    expect(projectYamlSchema.safeParse({ mods: { enabled: ["a-mod", "b-mod"] } }).success).toBe(true);
+    expect(projectYamlSchema.safeParse({ name: "G", mods: { enabled: ["a-mod"] } }).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);
+    expect(projectYamlSchema.safeParse({ mods: { enabled: [1] } }).success).toBe(false);
+    expect(projectYamlSchema.safeParse({ mods: { enabled: "" } }).success).toBe(false);
+    expect(projectYamlSchema.safeParse({ mods: { unknown: true } }).success).toBe(false);
+    expect(projectYamlSchema.safeParse({ mods: "all" }).success).toBe(false);
   });
 });
 
@@ -51,6 +68,49 @@ describe("readProjectYaml / writeProjectYaml", () => {
     }
   });
 
+  it("roundtrips the mods field losslessly with other fields preserved", async () => {
+    const dir = await tmpDir();
+    try {
+      const data = {
+        name: "Pinefield",
+        pack: { mods: true },
+        mods: { enabled: ["fishing-mod", "weather-mod"] },
+      };
+      await writeProjectYaml(dir, data);
+      expect(await readProjectYaml(dir)).toEqual(data);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("roundtrips an empty enabled list (all disabled) as distinct from absent", async () => {
+    const dir = await tmpDir();
+    try {
+      await writeProjectYaml(dir, { mods: { enabled: [] } });
+      const read = await readProjectYaml(dir);
+      expect(read).toEqual({ mods: { enabled: [] } });
+      expect(read?.mods?.enabled).toEqual([]);
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
+  it("readProjectEnabledMods returns undefined when absent or malformed", async () => {
+    const dir = await tmpDir();
+    try {
+      expect(await readProjectEnabledMods(dir)).toBeUndefined();
+      await writeProjectYaml(dir, { mods: { enabled: ["a-mod"] } });
+      expect(await readProjectEnabledMods(dir)).toEqual(["a-mod"]);
+      await writeProjectYaml(dir, {});
+      expect(await readProjectEnabledMods(dir)).toBeUndefined();
+      const { writeFile } = await import("node:fs/promises");
+      await writeFile(join(dir, PROJECT_YAML_NAME), "name: [broken\n", "utf8");
+      expect(await readProjectEnabledMods(dir)).toBeUndefined();
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+
   it("throws on malformed yaml and invalid data", async () => {
     const dir = await tmpDir();
     try {

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

@@ -8,6 +8,7 @@
   },
   "include": [
     "src/main.ts",
+    "src/assets.ts",
     "src/mcp-server.ts",
     "src/mcp-stdio.ts",
     "src/edit-engine.ts",

+ 1 - 3
apps/editor/tsconfig.preload.json

@@ -1,9 +1,7 @@
 {
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
-    "noEmit": false,
-    "outDir": "dist/preload",
-    "rootDir": "src",
+    "noEmit": true,
     "module": "CommonJS",
     "moduleResolution": "Node",
     "verbatimModuleSyntax": false,

+ 22 - 0
apps/editor/vite.preload.config.ts

@@ -0,0 +1,22 @@
+import { fileURLToPath, URL } from "node:url";
+import { defineConfig } from "vite";
+
+// The sandboxed preload must be a single self-contained CJS file — Electron's
+// sandbox require polyfill cannot resolve relative modules ("a bundler is
+// required if you need to split preload code into multiple files"). tsc.preload
+// type-checks (noEmit); this config bundles src/preload.ts + the shared ipc.ts
+// channel constants into one dist/preload/preload.js with electron external.
+export default defineConfig({
+  build: {
+    outDir: "dist/preload",
+    emptyOutDir: true,
+    lib: {
+      entry: fileURLToPath(new URL("./src/preload.ts", import.meta.url)),
+      formats: ["cjs"],
+      fileName: () => "preload.js",
+    },
+    rollupOptions: {
+      external: ["electron"],
+    },
+  },
+});

+ 20 - 8
apps/pack/src/index.ts

@@ -59,6 +59,7 @@ export interface PackArgs {
   splitAssets: boolean;
   modsDir?: string;
   name?: string;
+  enabledMods?: string[];
 }
 
 export interface PackResult {
@@ -113,13 +114,20 @@ export function parsePackArgs(input: { argv?: string[]; cwd?: string } = {}): Pa
  * per-kind/per-id messages) and shape it into a ContentBundle. The bundle is
  * INLINE (assets carry base64 `data`) plus a metadata `assetsIndex`; pass
  * `splitAssets: true` to strip the bytes and return the D37 split form
- * (metadata-only, `file` paths — `packGame` writes the sibling files). */
-export async function compileBundle(contentDir: string, modsDir?: string, splitAssets = false): Promise<ContentBundle> {
-  const bundle = await compileInlineBundle(contentDir, modsDir);
+ * (metadata-only, `file` paths — `packGame` writes the sibling files).
+ * F1: an optional `enabledMods` list restricts which Mods land in
+ * `bundle.mods` (absent = all, matching the engine's `enabledMods` filter). */
+export async function compileBundle(
+  contentDir: string,
+  modsDir?: string,
+  splitAssets = false,
+  enabledMods?: string[],
+): Promise<ContentBundle> {
+  const bundle = await compileInlineBundle(contentDir, modsDir, enabledMods);
   return splitAssets ? splitBundleAssets(bundle) : bundle;
 }
 
-async function compileInlineBundle(contentDir: string, modsDir?: string): Promise<ContentBundle> {
+async function compileInlineBundle(contentDir: string, modsDir?: string, enabledMods?: string[]): Promise<ContentBundle> {
   const registry = new ContentRegistry();
   const loader = new ContentLoader(registry);
   const report = await loader.load(contentDir);
@@ -151,7 +159,7 @@ async function compileInlineBundle(contentDir: string, modsDir?: string): Promis
     bundle.assetsIndex = assetsIndex;
   }
   if (modsDir !== undefined) {
-    const mods = await compileMods(modsDir);
+    const mods = await compileMods(modsDir, enabledMods);
     if (Object.keys(mods).length > 0) bundle.mods = mods;
   }
   return bundle;
@@ -169,8 +177,11 @@ export function splitBundleAssets(bundle: ContentBundle): ContentBundle {
 /** Scan a Mods directory using the ModLoader conventions (D36): each subdir
  * with an index.mjs/index.js entry, manifest from the module's default export,
  * entry source read as TEXT. A Mod with no entry or no valid manifest fails
- * the pack loudly (mirrors content compile failures). */
-export async function compileMods(modsDir: string): Promise<Record<string, BundledMod>> {
+ * the pack loudly (mirrors content compile failures). F1: when `enabledMods`
+ * is given, Mods whose manifest id is NOT in the list are skipped (absent =
+ * all enabled, matching the engine's `enabledMods` filter; unknown ids are
+ * ignored). */
+export async function compileMods(modsDir: string, enabledMods?: string[]): Promise<Record<string, BundledMod>> {
   if (!existsSync(modsDir)) {
     throw new Error(`nengine-pack: mods dir not found: ${modsDir}`);
   }
@@ -198,6 +209,7 @@ export async function compileMods(modsDir: string): Promise<Record<string, Bundl
       failures.push(`- ${fallbackId}: manifest { id, version } required`);
       continue;
     }
+    if (enabledMods !== undefined && !enabledMods.includes(manifestObj.id)) continue;
     const code = await readModEntry(dir);
     if (code === undefined) {
       failures.push(`- ${fallbackId}: no index.mjs or index.js entry`);
@@ -219,7 +231,7 @@ export async function compileMods(modsDir: string): Promise<Record<string, Bundl
  * `out/nengine-assets/<id>.<ext>` files and bundle.json carries only the metadata
  * index. */
 export async function packGame(options: PackArgs): Promise<PackResult> {
-  const inline = await compileInlineBundle(options.contentDir, options.modsDir);
+  const inline = await compileInlineBundle(options.contentDir, options.modsDir, options.enabledMods);
   let bundle = inline;
   await mkdir(options.outDir, { recursive: true });
   await buildViteSite(options.outDir);

+ 29 - 0
apps/pack/test/pack.test.ts

@@ -272,6 +272,35 @@ describe("compileBundle", () => {
     expect(bundle.mods).toBeUndefined();
   });
 
+  it("an enabledMods list packages only the listed mods (F1; absent = all)", async () => {
+    const all = await compileBundle(sampleContentDir, sampleModsDir);
+    expect(Object.keys(all.mods ?? {})).toEqual(["sample-game-mod"]);
+    const filtered = await compileBundle(sampleContentDir, sampleModsDir, false, ["sample-game-mod"]);
+    expect(Object.keys(filtered.mods ?? {})).toEqual(["sample-game-mod"]);
+    const none = await compileBundle(sampleContentDir, sampleModsDir, false, ["ghost-mod"]);
+    expect(none.mods).toBeUndefined();
+    const unknown = await compileBundle(sampleContentDir, sampleModsDir, false, ["ghost-mod", "sample-game-mod"]);
+    expect(Object.keys(unknown.mods ?? {})).toEqual(["sample-game-mod"]);
+  });
+
+  it("packGame honors PackArgs.enabledMods (F1)", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-pack-mods-filter-"));
+    try {
+      const result = await packGame({
+        contentDir: sampleContentDir,
+        outDir: out,
+        modsDir: sampleModsDir,
+        enabledMods: ["ghost-mod"],
+      });
+      expect(result.modCount).toBe(0);
+      const raw = await readFile(join(out, "bundle.json"), "utf8");
+      const bundle = JSON.parse(raw) as ContentBundle;
+      expect(bundle.mods).toBeUndefined();
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+
   it("fails loudly when a mods dir entry is missing or its manifest is invalid (D36)", async () => {
     const tmp = await mkdtemp(join(tmpdir(), "nengine-modpack-"));
     try {

Diff do ficheiro suprimidas por serem muito extensas
+ 2 - 2
docs/api.md


Diff do ficheiro suprimidas por serem muito extensas
+ 15 - 0
docs/decisions.md


Diff do ficheiro suprimidas por serem muito extensas
+ 16 - 14
docs/editor.md


+ 10 - 3
packages/core/src/engine.ts

@@ -41,11 +41,13 @@ export type EngineOptions = (EngineContentSourceOptions | EngineBundleSourceOpti
   tickIntervalMs?: number;
   tickDeltaTicks?: number;
   seed?: number;
+  enabledMods?: string[];
 };
 
 export interface Engine {
   readonly contentDir: string | undefined;
   readonly modsDir: string | undefined;
+  readonly enabledMods: string[] | undefined;
   readonly mods: ModStatus[];
   readonly world: World;
   readonly registry: ContentRegistry;
@@ -81,6 +83,7 @@ export function createEngine(opts: EngineOptions): Engine {
   const engine: Engine = {
     contentDir: "bundle" in opts ? undefined : opts.contentDir,
     modsDir: opts.modsDir,
+    enabledMods: opts.enabledMods,
     get mods(): ModStatus[] {
       return modStatuses;
     },
@@ -106,7 +109,7 @@ export function createEngine(opts: EngineOptions): Engine {
       await initSystems();
       seedScenes();
       if (opts.modsDir) {
-        modStatuses = await modLoader.load(opts.modsDir);
+        modStatuses = await modLoader.load(opts.modsDir, opts.enabledMods);
       } else if ("bundle" in opts && opts.bundle.mods) {
         modStatuses = await loadBundleMods(opts.bundle.mods);
       }
@@ -150,14 +153,17 @@ export function createEngine(opts: EngineOptions): Engine {
 
     async reloadMods(id?: string): Promise<ModStatus[]> {
       if (opts.modsDir) {
-        modStatuses = await modLoader.reload(opts.modsDir, id);
+        modStatuses = await modLoader.reload(opts.modsDir, id, opts.enabledMods);
         return modStatuses;
       }
       if ("bundle" in opts && opts.bundle.mods) {
         if (id !== undefined) {
           throw new Error("engine.reloadMods: bundled mods reload all at once; pass no id");
         }
-        modStatuses = await modLoader.reloadFromSources(bundleModSources(opts.bundle.mods));
+        const sources = bundleModSources(opts.bundle.mods).filter(
+          (src) => opts.enabledMods === undefined || opts.enabledMods.includes(src.id),
+        );
+        modStatuses = await modLoader.reloadFromSources(sources);
         return modStatuses;
       }
       throw new Error("engine.reloadMods: no modsDir configured");
@@ -248,6 +254,7 @@ export function createEngine(opts: EngineOptions): Engine {
   async function loadBundleMods(mods: Record<string, BundledMod>): Promise<ModStatus[]> {
     const statuses: ModStatus[] = [];
     for (const entry of Object.values(mods)) {
+      if (opts.enabledMods !== undefined && !opts.enabledMods.includes(entry.manifest.id)) continue;
       statuses.push(await modLoader.loadFromSource(entry.code, entry.manifest.id, entry.manifest.version));
     }
     return statuses;

+ 1 - 1
packages/core/src/index.ts

@@ -57,7 +57,7 @@ export type {
 export { findPath, isWalkableTile, findMapEntity } from "./systems/index.js";
 export type { TilePoint, MapEntity, WalkableMap } from "./systems/index.js";
 
-export { HookRegistry, ModLoader, HOOK_NAMES, isHookName, entryFileOf, readModEntry } from "./mods/index.js";
+export { HookRegistry, ModLoader, HOOK_NAMES, isHookName, entryFileOf, readModEntry, inspectModEntry } from "./mods/index.js";
 export type { HookContext, HookFn, HookName, ModManifest, ModSource, ModStatus } from "./mods/index.js";
 
 export {

+ 1 - 1
packages/core/src/mods/index.ts

@@ -1,4 +1,4 @@
 export { HOOK_NAMES, HookRegistry, isHookName } from "./hooks.js";
 export type { HookContext, HookFn, HookName } from "./hooks.js";
-export { ModLoader, entryFileOf, readModEntry } from "./loader.js";
+export { ModLoader, entryFileOf, readModEntry, inspectModEntry } from "./loader.js";
 export type { ModManifest, ModSource, ModStatus } from "./loader.js";

+ 55 - 11
packages/core/src/mods/loader.ts

@@ -50,10 +50,14 @@ export class ModLoader {
 
   constructor(private readonly hookRegistry: HookRegistry) {}
 
-  async load(modsDir: string): Promise<ModStatus[]> {
+  /** Load every Mod under `modsDir` (F1: when `enabledMods` is given, Mods
+   * whose manifest id is NOT in the list are skipped entirely — never bound,
+   * never returned. `undefined` = all enabled, backward compatible). */
+  async load(modsDir: string, enabledMods?: string[]): Promise<ModStatus[]> {
     const statuses: ModStatus[] = [];
     for (const name of await modDirsOf(modsDir)) {
-      statuses.push(await this.loadMod(resolve(modsDir, name), false));
+      const status = await this.loadMod(resolve(modsDir, name), false, enabledMods);
+      if (status !== undefined) statuses.push(status);
     }
     return statuses;
   }
@@ -83,7 +87,7 @@ export class ModLoader {
     return statuses;
   }
 
-  async reload(modsDir: string, id?: string): Promise<ModStatus[]> {
+  async reload(modsDir: string, id?: string, enabledMods?: string[]): Promise<ModStatus[]> {
     if (id !== undefined) {
       const record = this.records.get(id);
       if (!record) {
@@ -92,17 +96,19 @@ export class ModLoader {
       if (record.dir === undefined) {
         return [{ id, version: UNLOADED_VERSION, loaded: false, hooks: [], errors: ["bundled (source) mod; reload via reloadFromSources"] }];
       }
-      return [await this.loadMod(record.dir, true)];
+      const status = await this.loadMod(record.dir, true, enabledMods);
+      return status === undefined ? [] : [status];
     }
     this.unbindAll();
     const statuses: ModStatus[] = [];
     for (const name of await modDirsOf(modsDir)) {
-      statuses.push(await this.loadMod(resolve(modsDir, name), true));
+      const status = await this.loadMod(resolve(modsDir, name), true, enabledMods);
+      if (status !== undefined) statuses.push(status);
     }
     return statuses;
   }
 
-  private async loadMod(dir: string, bust: boolean): Promise<ModStatus> {
+  private async loadMod(dir: string, bust: boolean, enabledMods?: string[]): Promise<ModStatus | undefined> {
     this.unbindForDir(dir);
     const fallbackId = basename(dir);
     const entry = entryFileOf(dir);
@@ -120,6 +126,10 @@ export class ModLoader {
 
     const bound = this.bindModule(mod.default, fallbackId);
     if (bound.status.loaded) {
+      if (enabledMods !== undefined && !enabledMods.includes(bound.status.id)) {
+        for (const unsub of bound.unsubscribers) unsub();
+        return undefined;
+      }
       this.records.set(bound.status.id, { dir, unsubscribers: bound.unsubscribers });
     }
     return bound.status;
@@ -151,13 +161,11 @@ export class ModLoader {
     if (!def || typeof def !== "object") {
       return { status: { id: fallbackId, version: UNLOADED_VERSION, loaded: false, hooks: [], errors: ["module default export must be an object"] }, unsubscribers: [] };
     }
-    const manifest = (def as ModModule).manifest;
-    const manifestObj = manifest as { id?: unknown; version?: unknown } | undefined;
-    if (!manifestObj || typeof manifestObj.id !== "string" || typeof manifestObj.version !== "string") {
+    const manifest = modManifestOf(def);
+    if (!manifest) {
       return { status: { id: fallbackId, version: UNLOADED_VERSION, loaded: false, hooks: [], errors: ["manifest { id, version } required"] }, unsubscribers: [] };
     }
-    const id = manifestObj.id;
-    const version = manifestObj.version;
+    const { id, version } = manifest;
 
     const hooksObj = (def as ModModule).hooks;
     const hooks: string[] = [];
@@ -217,6 +225,42 @@ export class ModLoader {
   }
 }
 
+function modManifestOf(def: unknown): { id: string; version: string } | undefined {
+  const manifest = (def as ModModule).manifest as { id?: unknown; version?: unknown } | undefined;
+  if (!manifest || typeof manifest.id !== "string" || typeof manifest.version !== "string") return undefined;
+  return { id: manifest.id, version: manifest.version };
+}
+
+function hookKeysOf(def: unknown): string[] {
+  const hooksObj = (def as ModModule).hooks;
+  const keys: string[] = [];
+  if (hooksObj && typeof hooksObj === "object") {
+    for (const key of Object.keys(hooksObj)) {
+      if (isHookName(key)) keys.push(key);
+    }
+  }
+  return keys;
+}
+
+/** Read a Mod's manifest + hooked hook names WITHOUT binding (F1 panel
+ * introspection — shows disabled mods that `load` with an `enabledMods` filter
+ * never returns). `undefined` = no entry / import failed / no valid manifest. */
+export async function inspectModEntry(dir: string): Promise<ModStatus | undefined> {
+  const entry = entryFileOf(dir);
+  if (!entry) return undefined;
+  let mod: { default?: unknown };
+  try {
+    mod = (await import(pathToFileURL(entry).href)) as { default?: unknown };
+  } catch {
+    return undefined;
+  }
+  const def = mod.default;
+  if (!def || typeof def !== "object") return undefined;
+  const manifest = modManifestOf(def);
+  if (!manifest) return undefined;
+  return { id: manifest.id, version: manifest.version, loaded: false, hooks: hookKeysOf(def) };
+}
+
 async function modDirsOf(modsDir: string): Promise<string[]> {
   let entries;
   try {

+ 170 - 0
packages/core/test/enabled-mods.test.ts

@@ -0,0 +1,170 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+  ContentLoader,
+  ContentRegistry,
+  HookRegistry,
+  ModLoader,
+  TimeSystem,
+  World,
+  contentSchemas,
+  createEngine,
+  type ContentBundle,
+  type ContentKind,
+  type HookContext,
+} from "../src/index.js";
+
+const repoRoot = resolve(process.cwd(), "../..");
+const contentDir = resolve(repoRoot, "examples", "sample-game", "content");
+
+function modSource(id: string, tag: string): string {
+  return [
+    "export default {",
+    `  manifest: { id: "${id}", version: "1.0.0" },`,
+    "  hooks: {",
+    "    onDayStart(ctx) {",
+    "      const world = ctx.world;",
+    `      const stats = world.get("${tag}") ?? world.create({ id: "${tag}", type: "mod-state", fired: 0 });`,
+    "      stats.fired += 1;",
+    "    },",
+    "  },",
+    "};",
+    "",
+  ].join("\n");
+}
+
+async function modsFixture(mods: Record<string, string>): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), "core-enabled-mods-"));
+  for (const [id, source] of Object.entries(mods)) {
+    const target = join(dir, id);
+    await mkdir(target, { recursive: true });
+    await writeFile(join(target, "index.mjs"), source, "utf8");
+  }
+  return dir;
+}
+
+async function rmRecursive(dir: string): Promise<void> {
+  await rm(dir, { recursive: true, force: true });
+}
+
+function makeCtx(): HookContext {
+  return {
+    world: new World(),
+    registry: new ContentRegistry(),
+    time: new TimeSystem(),
+    emit: () => {},
+  };
+}
+
+describe("ModLoader enabledMods filter (F1)", () => {
+  it("loads only the listed mods and binds only their hooks", async () => {
+    const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
+    try {
+      const hooks = new HookRegistry();
+      const loader = new ModLoader(hooks);
+      const statuses = await loader.load(dir, ["a-mod"]);
+      expect(statuses.map((s) => s.id)).toEqual(["a-mod"]);
+      expect(statuses[0]?.loaded).toBe(true);
+      hooks.fire("onDayStart", makeCtx());
+      expect(hooks.bound("onDayStart")).toBe(true);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("undefined enabledMods loads everything (backward compatible)", async () => {
+    const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
+    try {
+      const hooks = new HookRegistry();
+      const loader = new ModLoader(hooks);
+      const statuses = await loader.load(dir);
+      expect(statuses.map((s) => s.id).sort()).toEqual(["a-mod", "b-mod"]);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("unknown ids in the list are ignored, not errors", async () => {
+    const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats") });
+    try {
+      const hooks = new HookRegistry();
+      const loader = new ModLoader(hooks);
+      const statuses = await loader.load(dir, ["ghost", "a-mod"]);
+      expect(statuses.map((s) => s.id)).toEqual(["a-mod"]);
+      const none = await loader.load(dir, ["ghost"]);
+      expect(none).toEqual([]);
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+});
+
+describe("createEngine enabledMods (F1)", () => {
+  it("excludes disabled mods from engine.mods and their hooks never fire", async () => {
+    const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
+    try {
+      const engine = createEngine({ contentDir, modsDir: dir, enabledMods: ["a-mod"] });
+      await engine.start();
+      try {
+        expect(engine.enabledMods).toEqual(["a-mod"]);
+        expect(engine.mods.map((m) => m.id)).toEqual(["a-mod"]);
+        engine.tick(480);
+        expect(engine.world.get("mod:a-stats")?.fired).toBe(1);
+        expect(engine.world.get("mod:b-stats")).toBeUndefined();
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("reload respects the same filter and a disabled id reload reports not loaded", async () => {
+    const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
+    try {
+      const engine = createEngine({ contentDir, modsDir: dir, enabledMods: ["a-mod"] });
+      await engine.start();
+      try {
+        const statuses = await engine.reloadMods();
+        expect(statuses.map((s) => s.id)).toEqual(["a-mod"]);
+        const single = await engine.reloadMods("b-mod");
+        expect(single[0]?.loaded).toBe(false);
+        expect(single[0]?.errors?.[0]).toContain("mod not loaded");
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rmRecursive(dir);
+    }
+  });
+
+  it("bundle mode filters bundled mods too", async () => {
+    const { readFile } = await import("node:fs/promises");
+    const source = await readFile(resolve(repoRoot, "examples", "sample-game", "mods", "sample-game-mod", "index.mjs"), "utf8");
+    const registry = new ContentRegistry();
+    const loader = new ContentLoader(registry);
+    const report = await loader.load(contentDir);
+    if (!report.ok) throw new Error("sample content load failed");
+    const content: Record<string, unknown[]> = {};
+    for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
+      const defs = registry.list(kind);
+      if (defs.length > 0) content[kind] = defs;
+    }
+    const bundle: ContentBundle = { format: "nengine-bundle", version: 1, content };
+    bundle.mods = {
+      "sample-game-mod": { manifest: { id: "sample-game-mod", version: "0.1.0" }, code: source },
+      "other-mod": { manifest: { id: "other-mod", version: "0.1.0" }, code: `export default { manifest: { id: "other-mod", version: "0.1.0" }, hooks: {} };` },
+    };
+    const engine = createEngine({ bundle, enabledMods: ["sample-game-mod"] });
+    await engine.start();
+    try {
+      expect(engine.mods.map((m) => m.id)).toEqual(["sample-game-mod"]);
+      engine.tick(480);
+      expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1);
+    } finally {
+      await engine.stop();
+    }
+  });
+});

Diff do ficheiro suprimidas por serem muito extensas
+ 610 - 17
pnpm-lock.yaml


Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff