Kaynağa Gözat

nEngine: top-down 2D farming game engine with MCP-first interfaces, engine-level Mod support, and enforced data-driven content

- 727 tests green; 21 MCP namespaces / 116 tools / 34 resources / 15 prompts
- 17 content kinds, 16 hooks, 12 auto-boot systems, D1-D39 in docs/decisions.md
- packaging: content bundle + static site, Electron desktop shell, AppImage installer, split-assets, Mod packaging
- sample game: examples/sample-game (Pinefield Valley)
nonvioce 2 ay önce
işleme
3c43aabf79
100 değiştirilmiş dosya ile 8961 ekleme ve 0 silme
  1. 8 0
      .gitignore
  2. 44 0
      AGENTS.md
  3. 30 0
      README.md
  4. 186 0
      apps/dev/index.html
  5. 21 0
      apps/dev/package.json
  6. 2 0
      apps/dev/src/bundle-default.d.ts
  7. 17 0
      apps/dev/src/keys.test.ts
  8. 7 0
      apps/dev/src/keys.ts
  9. 363 0
      apps/dev/src/main.ts
  10. 41 0
      apps/dev/src/params.test.ts
  11. 28 0
      apps/dev/src/params.ts
  12. 32 0
      apps/dev/src/shim/fs-promises.ts
  13. 18 0
      apps/dev/src/shim/fs.ts
  14. 27 0
      apps/dev/src/shim/path.test.ts
  15. 47 0
      apps/dev/src/shim/path.ts
  16. 3 0
      apps/dev/src/shim/url.ts
  17. 122 0
      apps/dev/src/ui.test.ts
  18. 145 0
      apps/dev/src/ui.ts
  19. 8 0
      apps/dev/tsconfig.json
  20. 101 0
      apps/dev/vite.config.ts
  21. 24 0
      apps/mcp-server/package.json
  22. 110 0
      apps/mcp-server/src/index.ts
  23. 57 0
      apps/mcp-server/src/server.ts
  24. 259 0
      apps/mcp-server/src/ws-broadcast.ts
  25. 1435 0
      apps/mcp-server/test/client-surface.test.ts
  26. 124 0
      apps/mcp-server/test/roundtrip.test.ts
  27. 470 0
      apps/mcp-server/test/sample-game.test.ts
  28. 498 0
      apps/mcp-server/test/ws-broadcast.test.ts
  29. 9 0
      apps/mcp-server/tsconfig.json
  30. 33 0
      apps/pack/package.json
  31. 274 0
      apps/pack/src/desktop.ts
  32. 314 0
      apps/pack/src/index.ts
  33. 429 0
      apps/pack/test/pack.test.ts
  34. 9 0
      apps/pack/tsconfig.json
  35. 15 0
      apps/pack/vitepacktest.mjs
  36. 13 0
      content/animals/chicken.yaml
  37. 21 0
      content/behaviors/season-welcome.yaml
  38. 14 0
      content/behaviors/spring-gift.yaml
  39. 9 0
      content/buildings/fence.yaml
  40. 10 0
      content/buildings/shed.yaml
  41. 13 0
      content/crops/parsnip.yaml
  42. 31 0
      content/dialogues/welcome.yaml
  43. 9 0
      content/items/egg.yaml
  44. 8 0
      content/items/hoe.yaml
  45. 7 0
      content/items/parsnip-seed.yaml
  46. 6 0
      content/items/parsnip.yaml
  47. 23 0
      content/maps/farm.yaml
  48. 11 0
      content/shops/general-store.yaml
  49. 31 0
      content/statemachines/chicken-egg.yaml
  50. 31 0
      content/statemachines/daily-routine.yaml
  51. 32 0
      content/statemachines/spring-quest.yaml
  52. 9 0
      content/time.yaml
  53. 34 0
      content/weather.yaml
  54. 61 0
      docs/api.md
  55. 239 0
      docs/architecture.md
  56. 82 0
      docs/boundaries.md
  57. 590 0
      docs/decisions.md
  58. 1102 0
      docs/engine-guide-zh.md
  59. 657 0
      docs/mcp.md
  60. 353 0
      examples/sample-game/README.md
  61. 10 0
      examples/sample-game/content/animals/chicken.yaml
  62. 11 0
      examples/sample-game/content/animations/player.yaml
  63. BIN
      examples/sample-game/content/assets/back-1.ogg
  64. BIN
      examples/sample-game/content/assets/bong-1.ogg
  65. BIN
      examples/sample-game/content/assets/carrot-grown.png
  66. BIN
      examples/sample-game/content/assets/carrot-mature.png
  67. BIN
      examples/sample-game/content/assets/carrot-sprout.png
  68. BIN
      examples/sample-game/content/assets/click-1.ogg
  69. BIN
      examples/sample-game/content/assets/confirm-1.ogg
  70. BIN
      examples/sample-game/content/assets/coop-1.png
  71. BIN
      examples/sample-game/content/assets/coop-2.png
  72. BIN
      examples/sample-game/content/assets/drop-1.ogg
  73. BIN
      examples/sample-game/content/assets/error-1.ogg
  74. BIN
      examples/sample-game/content/assets/fence-1.png
  75. BIN
      examples/sample-game/content/assets/grass-1.png
  76. BIN
      examples/sample-game/content/assets/grass-2.png
  77. BIN
      examples/sample-game/content/assets/plant-grown.png
  78. BIN
      examples/sample-game/content/assets/plant-mature.png
  79. BIN
      examples/sample-game/content/assets/plant-sprout.png
  80. BIN
      examples/sample-game/content/assets/pluck-1.ogg
  81. BIN
      examples/sample-game/content/assets/switch-1.ogg
  82. BIN
      examples/sample-game/content/assets/tilled-1.png
  83. BIN
      examples/sample-game/content/assets/water-1.png
  84. 25 0
      examples/sample-game/content/behaviors/starter-gift.yaml
  85. 8 0
      examples/sample-game/content/buildings/coop.yaml
  86. 8 0
      examples/sample-game/content/buildings/fence.yaml
  87. 12 0
      examples/sample-game/content/buildings/shop.yaml
  88. 12 0
      examples/sample-game/content/crops/carrot.yaml
  89. 12 0
      examples/sample-game/content/crops/parsnip.yaml
  90. 53 0
      examples/sample-game/content/dialogues/mayor.yaml
  91. 10 0
      examples/sample-game/content/interact.yaml
  92. 7 0
      examples/sample-game/content/items/carrot-seed.yaml
  93. 6 0
      examples/sample-game/content/items/carrot.yaml
  94. 8 0
      examples/sample-game/content/items/egg.yaml
  95. 9 0
      examples/sample-game/content/items/fish.yaml
  96. 7 0
      examples/sample-game/content/items/hoe.yaml
  97. 7 0
      examples/sample-game/content/items/parsnip-seed.yaml
  98. 6 0
      examples/sample-game/content/items/parsnip.yaml
  99. 8 0
      examples/sample-game/content/items/watering-can.yaml
  100. 36 0
      examples/sample-game/content/maps/farm.yaml

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+node_modules/
+dist/
+coverage/
+*.tsbuildinfo
+.DS_Store
+.vite/
+*.local
+dist-pack/

Dosya farkı çok büyük olduğundan ihmal edildi
+ 44 - 0
AGENTS.md


+ 30 - 0
README.md

@@ -0,0 +1,30 @@
+# nEngine
+
+**nEngine** 是一个**俯视角 2D 种田游戏的引擎**(top-down 2D farming game engine),而不是一个可玩的游戏。TypeScript + PixiJS 的 monorepo(pnpm workspaces),围绕三大不可妥协支柱:**MCP-first 接口**、**引擎级 Mod 支持**、**强制数据驱动内容**。
+
+核心价值:**可控性**(外部工具 / AI Agent 通过 MCP 完整操控)、**可扩展性**(Mod 通过类型化钩子注入玩法)、**内容即数据**(游戏 = YAML 数据文件,引擎逻辑不硬编码内容)。
+
+## 三种运行方式
+
+1. **浏览器游玩(开发模式)**:`pnpm dev` → 打开 `http://localhost:5173/apps/dev/?contentDir=examples/sample-game/content&modsDir=examples/sample-game/mods`
+2. **MCP 调试(headless 核心 + stdio)**:`pnpm -r build` → `node apps/mcp-server/dist/index.js --content-dir examples/sample-game/content --mods-dir examples/sample-game/mods`,用任意 MCP 客户端连接
+3. **打包发布**:`nengine-pack` 把内容编译成自包含静态网站 / Electron 桌面应用(安装器):`NODE_OPTIONS=--use-system-ca pnpm pack:example:full`
+
+(进阶:headless 核心 + `--ws-port` WebSocket 视口同步,浏览器只渲染快照。)
+
+## 安装与打包桌面软件
+
+下载/安装本引擎、以及如何把游戏打包为桌面软件(AppImage 等)的完整步骤,见 **[`docs/engine-guide-zh.md` §4](docs/engine-guide-zh.md)**(快速开始 / 打包为桌面软件)。
+
+## 文档地图
+
+| 路径 | 用途 |
+|---|---|
+| `docs/decisions.md` | 全部架构决策(D1–D39),**WHY 的事实来源** |
+| `docs/architecture.md` | 顶层架构、包边界、目录布局 |
+| `docs/mcp.md` | **MCP-first 接口规格**(21 命名空间 / 116 工具 / 34 资源 / 15 prompt) |
+| `docs/api.md` | 引擎 API 参考(活的 API 文档) |
+| `docs/boundaries.md` | 引擎/游戏边界(三判据) |
+| `docs/engine-guide-zh.md` | 中文引擎指南:安装、游玩、制作游戏、打包发布 |
+| `AGENTS.md` | 项目规则 + 当前状态摘要 |
+| `examples/sample-game/README.md` | 示例游戏 Pinefield Valley 的完整游玩/驱动/打包说明 |

+ 186 - 0
apps/dev/index.html

@@ -0,0 +1,186 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>@nengine/dev — walking skeleton</title>
+    <style>
+      html,
+      body {
+        margin: 0;
+        height: 100%;
+        background: #111;
+        font-family: system-ui, sans-serif;
+      }
+      #app {
+        display: flex;
+        flex-direction: column;
+        height: 100%;
+      }
+      #hud {
+        display: flex;
+        align-items: center;
+        gap: 16px;
+        padding: 10px 16px;
+        background: #202020;
+        color: #eee;
+        font-size: 14px;
+        flex-wrap: wrap;
+      }
+      #hud button {
+        font: inherit;
+        padding: 6px 14px;
+        border: 1px solid #555;
+        border-radius: 4px;
+        background: #333;
+        color: #eee;
+        cursor: pointer;
+      }
+      #hud button:hover {
+        background: #444;
+      }
+      #hud button.selected {
+        background: #2e7d32;
+        border-color: #66bb6a;
+      }
+      #stats-label {
+        color: #b0bec5;
+      }
+      #feedback-label {
+        min-width: 180px;
+      }
+      #seed-picker {
+        display: flex;
+        gap: 6px;
+        align-items: center;
+      }
+      #seed-picker button {
+        padding: 4px 10px;
+        font-size: 12px;
+      }
+      #game {
+        flex: 1;
+        min-height: 0;
+        position: relative;
+      }
+      #game canvas {
+        display: block;
+      }
+      .panel {
+        position: absolute;
+        background: #263238;
+        border: 1px solid #455a64;
+        border-radius: 6px;
+        color: #eceff1;
+        padding: 14px 16px;
+        font-size: 14px;
+        max-width: 420px;
+        z-index: 10;
+      }
+      #dialogue-panel {
+        bottom: 90px;
+        left: 50%;
+        transform: translateX(-50%);
+      }
+      #dialogue-panel .dialogue-text {
+        margin-bottom: 10px;
+        line-height: 1.5;
+      }
+      #dialogue-panel button {
+        display: block;
+        margin: 6px 0;
+      }
+      #shop-panel {
+        top: 80px;
+        right: 16px;
+      }
+      #shop-panel h3 {
+        margin: 0 0 8px;
+        font-size: 14px;
+        color: #ffd54f;
+      }
+      #shop-panel h4 {
+        margin: 8px 0 4px;
+        font-size: 12px;
+        color: #90a4ae;
+      }
+      #shop-panel .row {
+        display: flex;
+        gap: 8px;
+        align-items: center;
+        margin: 4px 0;
+      }
+      #shop-panel .price {
+        color: #ffd54f;
+      }
+      #shop-panel .owned {
+        color: #90a4ae;
+      }
+      #shop-panel button {
+        font: inherit;
+        padding: 3px 10px;
+        border: 1px solid #555;
+        border-radius: 4px;
+        background: #37474f;
+        color: #eceff1;
+        cursor: pointer;
+      }
+      #inventory-bar {
+        display: flex;
+        gap: 16px;
+        align-items: center;
+        padding: 6px 16px;
+        background: #1a1a1a;
+        color: #cfd8dc;
+        font-size: 12px;
+        flex-wrap: wrap;
+      }
+      #inventory-gold {
+        color: #ffd54f;
+      }
+      #inventory-stamina {
+        color: #a5d6a7;
+      }
+      #inventory-seed {
+        color: #80cbc4;
+      }
+      .hidden {
+        display: none;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="app">
+      <div id="hud">
+        <span id="time-label">booting…</span>
+        <span id="stats-label"></span>
+        <span id="feedback-label"></span>
+        <div id="seed-picker" title="Selected seed for sowing (player.intent select-seed)"></div>
+        <button id="tool-interact" type="button">Interact</button>
+        <button id="tool-move" type="button">Move</button>
+        <button id="advance-day" type="button">Advance day</button>
+      </div>
+      <div id="game">
+        <div id="dialogue-panel" class="panel hidden">
+          <div id="dialogue-text" class="dialogue-text"></div>
+          <div id="dialogue-choices"></div>
+        </div>
+        <div id="shop-panel" class="panel hidden">
+          <h3 id="shop-title"></h3>
+          <h4>Shop sells (buy)</h4>
+          <div id="shop-sells"></div>
+          <h4>Shop buys (sell yours)</h4>
+          <div id="shop-buys"></div>
+          <button id="shop-close" type="button">Close</button>
+        </div>
+      </div>
+      <div id="inventory-bar">
+        <span id="inventory-items"></span>
+        <span id="inventory-gold"></span>
+        <span id="inventory-stamina"></span>
+        <span id="inventory-seed"></span>
+      </div>
+    </div>
+    <script type="module" src="/apps/dev/src/main.ts"></script>
+  </body>
+</html>

+ 21 - 0
apps/dev/package.json

@@ -0,0 +1,21 @@
+{
+  "name": "@nengine/dev",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Vite dev app: boots core + render together (walking skeleton).",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -p tsconfig.json && vite build",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "@nengine/core": "workspace:*",
+    "@nengine/render": "workspace:*"
+  },
+  "devDependencies": {
+    "typescript": "^5.6.0",
+    "vite": "^7.0.0",
+    "vitest": "^3.0.0"
+  }
+}

+ 2 - 0
apps/dev/src/bundle-default.d.ts

@@ -0,0 +1,2 @@
+declare const __NENGINE_BUNDLE_DEFAULT__: string | undefined;
+declare const __NENGINE_ASSETS_BASE__: string | undefined;

+ 17 - 0
apps/dev/src/keys.test.ts

@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { keydownInput } from "./keys.js";
+
+describe("apps/dev keydownInput (keyboard → raw input)", () => {
+  it("maps arrow keys to raw key: input strings", () => {
+    expect(keydownInput("ArrowUp")).toBe("key:ArrowUp");
+    expect(keydownInput("ArrowDown")).toBe("key:ArrowDown");
+    expect(keydownInput("ArrowLeft")).toBe("key:ArrowLeft");
+    expect(keydownInput("ArrowRight")).toBe("key:ArrowRight");
+  });
+
+  it("returns undefined for non-arrow keys", () => {
+    expect(keydownInput("Space")).toBeUndefined();
+    expect(keydownInput("Enter")).toBeUndefined();
+    expect(keydownInput("")).toBeUndefined();
+  });
+});

+ 7 - 0
apps/dev/src/keys.ts

@@ -0,0 +1,7 @@
+import { arrowDirection } from "@nengine/render";
+
+/** Map a keydown key to a raw `"key:Arrow*"` input string, or undefined for non-arrow keys. */
+export function keydownInput(key: string): string | undefined {
+  const input = `key:${key}`;
+  return arrowDirection(input) ? input : undefined;
+}

+ 363 - 0
apps/dev/src/main.ts

@@ -0,0 +1,363 @@
+import { createEngine } from "@nengine/core";
+import type { ContentBundle, DialogueState, Engine, ItemDef, MapDef, ShopDef, TimeDef } from "@nengine/core";
+import { connectLocal, connectWs, createSoundPlayer, createViewport, type ViewportHandle } from "@nengine/render";
+import type { SoundPlayer } from "@nengine/render";
+import type { WsEventFrame, WsSnapshotFrame } from "@nengine/render";
+import { parseDevParams } from "./params.js";
+import { keydownInput } from "./keys.js";
+import {
+  buildingShopAt,
+  dialogueActiveInWorld,
+  dialogueChoicesFromNode,
+  playerStatsFromSnapshot,
+  seedListFromSnapshot,
+  shopRowsFromDef,
+  type PlayerStats,
+} from "./ui.js";
+
+const timeLabel = document.getElementById("time-label") as HTMLSpanElement;
+const statsLabel = document.getElementById("stats-label") as HTMLSpanElement;
+const feedbackLabel = document.getElementById("feedback-label") as HTMLSpanElement;
+const game = document.getElementById("game") as HTMLElement;
+const advanceButton = document.getElementById("advance-day") as HTMLButtonElement;
+const interactButton = document.getElementById("tool-interact") as HTMLButtonElement;
+const moveButton = document.getElementById("tool-move") as HTMLButtonElement;
+const seedPicker = document.getElementById("seed-picker") as HTMLElement;
+const inventoryItems = document.getElementById("inventory-items") as HTMLSpanElement;
+const inventoryGold = document.getElementById("inventory-gold") as HTMLSpanElement;
+const inventoryStamina = document.getElementById("inventory-stamina") as HTMLSpanElement;
+const inventorySeed = document.getElementById("inventory-seed") as HTMLSpanElement;
+const dialoguePanel = document.getElementById("dialogue-panel") as HTMLElement;
+const dialogueText = document.getElementById("dialogue-text") as HTMLDivElement;
+const dialogueChoices = document.getElementById("dialogue-choices") as HTMLElement;
+const shopPanel = document.getElementById("shop-panel") as HTMLElement;
+const shopTitle = document.getElementById("shop-title") as HTMLHeadingElement;
+const shopSells = document.getElementById("shop-sells") as HTMLElement;
+const shopBuys = document.getElementById("shop-buys") as HTMLElement;
+const shopClose = document.getElementById("shop-close") as HTMLButtonElement;
+
+type Tool = "interact" | "move";
+
+interface SyncLike {
+  intent(action: string, args: Record<string, unknown>): void;
+}
+
+interface SnapshotView {
+  world: unknown[];
+  shops: ShopDef[];
+  items?: ItemDef[];
+}
+
+let tool: Tool = "interact";
+let feedbackTimer: number | undefined;
+let sync: SyncLike = { intent: () => {} };
+let view: SnapshotView = { world: [], shops: [] };
+let activeShop: { shopId: string } | undefined;
+let dialogueState: DialogueState | undefined;
+
+function toRepoPath(rel: string): string {
+  return new URL(rel.startsWith("/") ? rel : `../../../${rel}`, import.meta.url).pathname;
+}
+
+function setTool(next: Tool): void {
+  tool = next;
+  interactButton.classList.toggle("selected", tool === "interact");
+  moveButton.classList.toggle("selected", tool === "move");
+}
+
+function showFeedback(message: string, ok: boolean): void {
+  feedbackLabel.textContent = message;
+  feedbackLabel.style.color = ok ? "#8bc34a" : "#ef5350";
+  if (feedbackTimer !== undefined) window.clearTimeout(feedbackTimer);
+  feedbackTimer = window.setTimeout(() => {
+    feedbackLabel.textContent = "";
+  }, 3000);
+}
+
+function feedbackFor(payload: unknown): { message: string; ok: boolean } {
+  if (!payload || typeof payload !== "object") return { message: "intent", ok: false };
+  const p = payload as { ok?: unknown; detail?: unknown; error?: unknown; action?: unknown };
+  const action = typeof p.action === "string" ? p.action : "intent";
+  if (p.ok === true) {
+    const detail = typeof p.detail === "string" ? p.detail : `${action} ok`;
+    return { message: detail, ok: true };
+  }
+  return { message: typeof p.error === "string" ? p.error : `${action} failed`, ok: false };
+}
+
+function isDialogueState(value: unknown): value is DialogueState {
+  if (!value || typeof value !== "object") return false;
+  const state = value as { dialogueId?: unknown; nodeId?: unknown; text?: unknown; choices?: unknown };
+  return (
+    typeof state.dialogueId === "string" &&
+    typeof state.nodeId === "string" &&
+    typeof state.text === "string" &&
+    Array.isArray(state.choices)
+  );
+}
+
+function onIntentResult(payload: unknown): void {
+  const feedback = feedbackFor(payload);
+  showFeedback(feedback.message, feedback.ok);
+  if (!payload || typeof payload !== "object") return;
+  const p = payload as { action?: unknown; ok?: unknown; detail?: unknown };
+  if (typeof p.action === "string" && (p.action === "talk" || p.action === "choose") && p.ok === true) {
+    dialogueState = isDialogueState(p.detail) ? p.detail : undefined;
+  }
+}
+
+function renderUi(): void {
+  const stats = playerStatsFromSnapshot(view.world);
+  statsLabel.textContent = `gold ${stats.gold} · stamina ${stats.stamina}`;
+  renderSeedPicker(stats);
+  renderInventoryBar(stats);
+  renderDialoguePanel();
+  renderShopPanel();
+}
+
+function renderSeedPicker(stats: PlayerStats): void {
+  seedPicker.replaceChildren();
+  const options = seedListFromSnapshot(view.world, view.items);
+  for (const option of options) {
+    const button = document.createElement("button");
+    button.type = "button";
+    button.textContent = `${option.name} ${option.qty}`;
+    button.title = `Select ${option.name} as the sow seed`;
+    button.classList.toggle("selected", stats.selectedSeed === option.id);
+    button.addEventListener("click", () => {
+      sync.intent("select-seed", { seedItemId: option.id });
+    });
+    seedPicker.appendChild(button);
+  }
+}
+
+function renderInventoryBar(stats: PlayerStats): void {
+  const items = Object.entries(stats.inventory)
+    .map(([id, qty]) => `${id} ×${qty}`)
+    .join(" · ");
+  inventoryItems.textContent = items.length > 0 ? `items: ${items}` : "items: —";
+  inventoryGold.textContent = `gold ${stats.gold}`;
+  inventoryStamina.textContent = `stamina ${stats.stamina}`;
+  inventorySeed.textContent = stats.selectedSeed ? `seed: ${stats.selectedSeed}` : "seed: first";
+}
+
+function renderDialoguePanel(): void {
+  const active = dialogueActiveInWorld(view.world);
+  dialoguePanel.classList.toggle("hidden", !active);
+  if (!active) return;
+  const node = dialogueChoicesFromNode(dialogueState);
+  dialogueText.textContent = node.text.length > 0 ? node.text : "…";
+  dialogueChoices.replaceChildren();
+  node.choices.forEach((text, index) => {
+    const button = document.createElement("button");
+    button.type = "button";
+    button.textContent = text;
+    button.addEventListener("click", () => {
+      sync.intent("choose", { index });
+    });
+    dialogueChoices.appendChild(button);
+  });
+}
+
+function renderShopPanel(): void {
+  if (!activeShop) return;
+  const shop = view.shops.find((candidate) => candidate.id === activeShop?.shopId);
+  if (!shop) {
+    shopTitle.textContent = `Shop: ${activeShop.shopId}`;
+    shopSells.replaceChildren();
+    shopBuys.replaceChildren();
+    return;
+  }
+  const rows = shopRowsFromDef(shop, playerStatsFromSnapshot(view.world).inventory, view.items);
+  shopTitle.textContent = `Shop: ${shop.id}`;
+  shopSells.replaceChildren();
+  for (const row of rows.sellRows) {
+    const line = document.createElement("div");
+    line.className = "row";
+    const label = document.createElement("span");
+    label.textContent = `${row.name} (own ${row.owned})`;
+    const price = document.createElement("span");
+    price.className = "price";
+    price.textContent = `${row.price}g`;
+    const button = document.createElement("button");
+    button.type = "button";
+    button.textContent = "Buy";
+    button.addEventListener("click", () => {
+      sync.intent("buy", { shopId: shop.id, itemId: row.itemId, qty: 1 });
+    });
+    line.append(label, price, button);
+    shopSells.appendChild(line);
+  }
+  shopBuys.replaceChildren();
+  for (const row of rows.buyRows) {
+    const line = document.createElement("div");
+    line.className = "row";
+    const label = document.createElement("span");
+    label.textContent = `${row.name} (own ${row.owned})`;
+    const price = document.createElement("span");
+    price.className = "price";
+    price.textContent = `${row.price}g`;
+    const button = document.createElement("button");
+    button.type = "button";
+    button.textContent = "Sell";
+    button.addEventListener("click", () => {
+      sync.intent("sell", { shopId: shop.id, itemId: row.itemId, qty: 1 });
+    });
+    line.append(label, price, button);
+    shopBuys.appendChild(line);
+  }
+}
+
+function setActiveShop(shop: { shopId: string } | undefined): void {
+  activeShop = shop;
+  shopPanel.classList.toggle("hidden", shop === undefined);
+  if (shop) renderShopPanel();
+}
+
+function bindClicks(viewport: ViewportHandle, intent: (action: string, args: Record<string, unknown>) => void): void {
+  game.addEventListener("click", (event) => {
+    const tile = viewport.tileAt(event.clientX, event.clientY);
+    if (!tile) return;
+    intent(tool, { tile });
+    const shop = buildingShopAt(view.world, tile);
+    setActiveShop(shop ? { shopId: shop.shopId } : undefined);
+  });
+}
+
+function bindKeyboard(syncHandle: { input(input: string): void }, sound: { prime(): void }): void {
+  window.addEventListener("keydown", (event) => {
+    sound.prime();
+    const input = keydownInput(event.key);
+    if (input) syncHandle.input(input);
+  });
+}
+
+async function boot(): Promise<void> {
+  interactButton.addEventListener("click", () => setTool("interact"));
+  moveButton.addEventListener("click", () => setTool("move"));
+  shopClose.addEventListener("click", () => setActiveShop(undefined));
+  setTool("interact");
+
+  try {
+    const params = parseDevParams(location.search);
+    if (params.sync === "ws") {
+      if (!params.url) throw new Error("sync=ws requires ?url=<ws url>");
+      const sound = createSoundPlayer();
+      window.addEventListener("pointerdown", () => sound.prime());
+      const viewport = await createViewport(game, {});
+      const wsSync = connectWs({
+        url: params.url,
+        viewport,
+        sound,
+        onEvent: (frame: WsEventFrame) => {
+          if (frame.event === "player-intent") {
+            onIntentResult(frame.payload);
+          }
+        },
+        onSnapshot: (frame: WsSnapshotFrame) => {
+          view = {
+            world: frame.world as unknown[],
+            shops: frame.shops ?? [],
+          };
+          renderUi();
+        },
+      });
+      sync = wsSync;
+      wsSync.setTimeLabel((label) => {
+        timeLabel.textContent = label;
+      });
+      advanceButton.disabled = true;
+      advanceButton.title = "time is driven by the headless core";
+      bindClicks(viewport, (action, args) => wsSync.intent(action, args));
+      bindKeyboard(wsSync, sound);
+      return;
+    }
+
+    if (params.bundle !== undefined) {
+      const bundleUrl = toRepoPath(params.bundle);
+      await bootFromBundle(bundleUrl, assetsBaseOf(bundleUrl));
+      return;
+    }
+    const defaultBundle = typeof __NENGINE_BUNDLE_DEFAULT__ !== "undefined" ? __NENGINE_BUNDLE_DEFAULT__ : undefined;
+    if (defaultBundle !== undefined) {
+      const bundleUrl = new URL(defaultBundle, import.meta.url).href;
+      await bootFromBundle(bundleUrl, assetsBaseOf(bundleUrl));
+      return;
+    }
+
+    const contentDir = toRepoPath(params.contentDir);
+    const modsDir = toRepoPath(params.modsDir);
+    const engine = createEngine({ contentDir, modsDir });
+    await engine.start();
+    const sound = createSoundPlayer();
+    window.addEventListener("pointerdown", () => sound.prime());
+    await bootLocal(engine, sound);
+  } catch (err) {
+    timeLabel.textContent = err instanceof Error ? err.message : String(err);
+  }
+}
+
+/** The split-assets fetch base for a bundle (D37): the baked
+ * `__NENGINE_ASSETS_BASE__` define when present (packaged static site — the
+ * sibling `nengine-assets/` dir), else the fetched bundle's own directory (dev
+ * `?bundle=` mode — `nengine-assets/` lives next to bundle.json). */
+function assetsBaseOf(bundleUrl: string): string {
+  const baked = typeof __NENGINE_ASSETS_BASE__ !== "undefined" && __NENGINE_ASSETS_BASE__ !== undefined ? __NENGINE_ASSETS_BASE__ : undefined;
+  if (baked !== undefined) {
+    return new URL(baked.endsWith("/") ? baked : `${baked}/`, document.baseURI).href;
+  }
+  const abs = new URL(bundleUrl, document.baseURI).href;
+  return new URL(".", abs).href;
+}
+
+async function bootFromBundle(bundleUrl: string, assetsBase: string): Promise<void> {
+  const res = await fetch(bundleUrl);
+  if (!res.ok) throw new Error(`failed to fetch bundle: ${res.status} ${res.statusText}`);
+  const bundle = (await res.json()) as ContentBundle;
+  const engine = createEngine({ bundle });
+  await engine.start();
+  const sound = createSoundPlayer(undefined, assetsBase);
+  window.addEventListener("pointerdown", () => sound.prime());
+  await bootLocal(engine, sound, assetsBase);
+}
+
+async function bootLocal(engine: Engine, sound: SoundPlayer, assetsBase?: string): Promise<void> {
+  const map = engine.registry.get<MapDef>("map", "farm") ?? engine.registry.list<MapDef>("map")[0];
+  if (!map) throw new Error("no map content loaded");
+
+  const viewport: ViewportHandle = await createViewport(game, { map, ...(assetsBase !== undefined ? { assetsBase } : {}) });
+  const localSync = connectLocal(engine, viewport, { sound });
+  sync = localSync;
+  localSync.setTimeLabel((label) => {
+    timeLabel.textContent = label;
+  });
+
+  const refreshViewLocal = (): void => {
+    view = {
+      world: engine.world.snapshot() as unknown[],
+      shops: engine.registry.list<ShopDef>("shop"),
+      items: engine.registry.list<ItemDef>("item"),
+    };
+    renderUi();
+  };
+
+  engine.events.on("player-intent", (payload) => {
+    onIntentResult(payload);
+    refreshViewLocal();
+  });
+  refreshViewLocal();
+
+  const timeDef = engine.registry.get<TimeDef>("time", "default");
+  const dayLengthTicks = timeDef?.dayLengthTicks ?? 480;
+  advanceButton.addEventListener("click", () => {
+    // engine.tick is the simulation driver (D23); time.advance is clock-only.
+    engine.tick(dayLengthTicks);
+    localSync.refresh();
+    refreshViewLocal();
+  });
+
+  bindClicks(viewport, (action, args) => localSync.intent(action, args));
+  bindKeyboard(localSync, sound);
+}
+
+void boot();

+ 41 - 0
apps/dev/src/params.test.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_CONTENT_DIR, DEFAULT_MODS_DIR, parseDevParams } from "./params.js";
+
+describe("apps/dev parseDevParams (URL parameterization)", () => {
+  it("defaults to the repo content + LocalSync when no params are present", () => {
+    expect(parseDevParams("")).toEqual({
+      contentDir: DEFAULT_CONTENT_DIR,
+      modsDir: DEFAULT_MODS_DIR,
+      sync: "local",
+      url: undefined,
+    });
+  });
+
+  it("parses the sample-game ws demo: contentDir + sync=ws + url", () => {
+    expect(
+      parseDevParams("?contentDir=examples/sample-game/content&sync=ws&url=ws://localhost:8787"),
+    ).toEqual({
+      contentDir: "examples/sample-game/content",
+      modsDir: DEFAULT_MODS_DIR,
+      sync: "ws",
+      url: "ws://localhost:8787",
+    });
+  });
+
+  it("treats an unknown sync value as local mode", () => {
+    expect(parseDevParams("?sync=http&url=http://localhost:8787").sync).toBe("local");
+    expect(parseDevParams("?sync=WS").sync).toBe("local");
+  });
+
+  it("keeps the url absent when sync is local", () => {
+    expect(parseDevParams("?contentDir=examples/sample-game/content").url).toBeUndefined();
+    expect(parseDevParams("?contentDir=examples/sample-game/content").sync).toBe("local");
+  });
+
+  it("parses ?bundle= and keeps it absent otherwise (D30)", () => {
+    expect(
+      parseDevParams("?bundle=examples/sample-game/dist-pack/bundle.json").bundle,
+    ).toBe("examples/sample-game/dist-pack/bundle.json");
+    expect(parseDevParams("").bundle).toBeUndefined();
+  });
+});

+ 28 - 0
apps/dev/src/params.ts

@@ -0,0 +1,28 @@
+export interface DevParams {
+  contentDir: string;
+  modsDir: string;
+  sync: "local" | "ws";
+  url?: string;
+  bundle?: string;
+}
+
+export const DEFAULT_CONTENT_DIR = "../../../content";
+export const DEFAULT_MODS_DIR = "../../../mods";
+
+export function parseDevParams(search: string): DevParams {
+  const params = new URLSearchParams(search);
+  const dirs: DevParams = {
+    contentDir: params.get("contentDir") ?? DEFAULT_CONTENT_DIR,
+    modsDir: params.get("modsDir") ?? DEFAULT_MODS_DIR,
+    sync: params.get("sync") === "ws" ? "ws" : "local",
+  };
+  const url = params.get("url");
+  if (url !== null) {
+    dirs.url = url;
+  }
+  const bundle = params.get("bundle");
+  if (bundle !== null) {
+    dirs.bundle = bundle;
+  }
+  return dirs;
+}

+ 32 - 0
apps/dev/src/shim/fs-promises.ts

@@ -0,0 +1,32 @@
+export interface Dirent {
+  name: string;
+  isDirectory(): boolean;
+}
+
+async function getJson(path: string, endpoint: string, extra?: string): Promise<unknown> {
+  const params = extra ? `${extra}&p=${encodeURIComponent(path)}` : `p=${encodeURIComponent(path)}`;
+  const res = await fetch(`/__nengine_fs/${endpoint}?${params}`);
+  if (!res.ok) throw new Error(`fs: ${res.status}: ${path}`);
+  return res.json() as Promise<unknown>;
+}
+
+export async function readFile(path: string, _encoding?: string): Promise<string> {
+  const res = await fetch(path);
+  if (!res.ok) throw new Error(`ENOENT: ${path}`);
+  return res.text();
+}
+
+export async function readdir(path: string): Promise<string[]>;
+export async function readdir(path: string, opts: { withFileTypes: true }): Promise<Dirent[]>;
+export async function readdir(path: string, opts?: { withFileTypes?: boolean }): Promise<string[] | Dirent[]> {
+  if (opts?.withFileTypes) {
+    const entries = (await getJson(path, "readdir", "detailed=1")) as { name: string; isDirectory: boolean }[];
+    return entries.map((e) => ({ name: e.name, isDirectory: () => e.isDirectory }));
+  }
+  return (await getJson(path, "readdir")) as string[];
+}
+
+export async function stat(path: string): Promise<{ isDirectory(): boolean }> {
+  const info = (await getJson(path, "stat")) as { isDirectory: boolean };
+  return { isDirectory: () => info.isDirectory };
+}

+ 18 - 0
apps/dev/src/shim/fs.ts

@@ -0,0 +1,18 @@
+export interface FSWatcher {
+  close(): void;
+}
+
+export function existsSync(path: string): boolean {
+  try {
+    const xhr = new XMLHttpRequest();
+    xhr.open("GET", `/__nengine_fs/exists?p=${encodeURIComponent(path)}`, false);
+    xhr.send();
+    return xhr.status === 200 && xhr.responseText === "1";
+  } catch {
+    return false;
+  }
+}
+
+export function watch(): FSWatcher {
+  return { close() {} };
+}

+ 27 - 0
apps/dev/src/shim/path.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { basename, extname, resolve } from "./path.js";
+
+describe("path shim", () => {
+  it("resolves absolute paths with relative segments", () => {
+    expect(resolve("/content", "maps")).toBe("/content/maps");
+    expect(resolve("/content", "maps", "farm.yaml")).toBe("/content/maps/farm.yaml");
+    expect(resolve("/mods", "example-mod")).toBe("/mods/example-mod");
+  });
+
+  it("resolves .. segments", () => {
+    expect(resolve("/content/maps", "..")).toBe("/content");
+    expect(resolve("/a", "..", "b")).toBe("/b");
+  });
+
+  it("takes the last absolute segment as the base", () => {
+    expect(resolve("x", "/content", "time.yaml")).toBe("/content/time.yaml");
+  });
+
+  it("computes basename and extname", () => {
+    expect(basename("/content/maps/farm.yaml")).toBe("farm.yaml");
+    expect(basename("/content/maps/farm.yaml", ".yaml")).toBe("farm");
+    expect(basename("/mods/example-mod/index.mjs", ".mjs")).toBe("index");
+    expect(extname("/content/maps/farm.yaml")).toBe(".yaml");
+    expect(extname("/mods")).toBe("");
+  });
+});

+ 47 - 0
apps/dev/src/shim/path.ts

@@ -0,0 +1,47 @@
+export function isAbsolute(p: string): boolean {
+  return p.startsWith("/");
+}
+
+export function resolve(...segments: string[]): string {
+  const parts: string[] = [];
+  for (const segment of segments) {
+    if (segment.length === 0) continue;
+    if (segment.startsWith("/")) {
+      parts.length = 0;
+    }
+    parts.push(...segment.split("/"));
+  }
+  const out: string[] = [];
+  for (const part of parts) {
+    if (part === "" || part === ".") continue;
+    if (part === "..") {
+      out.pop();
+    } else {
+      out.push(part);
+    }
+  }
+  return "/" + out.join("/");
+}
+
+export function join(...segments: string[]): string {
+  return resolve(...segments);
+}
+
+export function basename(p: string, suffix?: string): string {
+  const name = p.split("/").filter((s) => s.length > 0).pop() ?? "";
+  if (suffix && name.endsWith(suffix)) {
+    return name.slice(0, -suffix.length);
+  }
+  return name;
+}
+
+export function extname(p: string): string {
+  const name = basename(p);
+  const idx = name.lastIndexOf(".");
+  return idx > 0 ? name.slice(idx) : "";
+}
+
+export function dirname(p: string): string {
+  const idx = p.lastIndexOf("/");
+  return idx > 0 ? p.slice(0, idx) : "/";
+}

+ 3 - 0
apps/dev/src/shim/url.ts

@@ -0,0 +1,3 @@
+export function pathToFileURL(path: string): { href: string } {
+  return { href: `file://${path}` };
+}

+ 122 - 0
apps/dev/src/ui.test.ts

@@ -0,0 +1,122 @@
+import { describe, expect, it } from "vitest";
+import type { ItemDef, ShopDef } from "@nengine/core";
+import {
+  buildingShopAt,
+  dialogueActiveInWorld,
+  dialogueChoicesFromNode,
+  playerStatsFromSnapshot,
+  seedListFromSnapshot,
+  shopRowsFromDef,
+} from "./ui.js";
+
+const SHOP: ShopDef = {
+  id: "general-store",
+  buys: [{ itemId: "parsnip", price: 30 }],
+  sells: [
+    { itemId: "hoe", price: 500 },
+    { itemId: "parsnip-seed", price: 20, stock: 99 },
+  ],
+};
+
+const ITEMS: ItemDef[] = [
+  { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true },
+  { id: "carrot-seed", name: "Carrot Seed", kind: "seed", stackable: true },
+  { id: "hoe", name: "Hoe", kind: "tool", stackable: false },
+  { id: "parsnip", name: "Parsnip", kind: "material", stackable: true },
+];
+
+describe("apps/dev UI pure helpers", () => {
+  it("playerStatsFromSnapshot extracts inventory/gold/stamina/selectedSeed", () => {
+    const stats = playerStatsFromSnapshot([
+      {
+        id: "player",
+        type: "player",
+        x: 0,
+        y: 0,
+        inventory: { "parsnip-seed": 3, hoe: 1, fish: 0 },
+        gold: 50,
+        stamina: 80,
+        selectedSeed: "parsnip-seed",
+      },
+    ]);
+    expect(stats).toEqual({
+      inventory: { "parsnip-seed": 3, hoe: 1 },
+      gold: 50,
+      stamina: 80,
+      selectedSeed: "parsnip-seed",
+    });
+  });
+
+  it("playerStatsFromSnapshot defaults when the player is missing", () => {
+    expect(playerStatsFromSnapshot([])).toEqual({ inventory: {}, gold: 0, stamina: 0, selectedSeed: null });
+  });
+
+  it("seedListFromSnapshot filters seed-kind items when an item catalog is present (local mode)", () => {
+    const world = [{ id: "player", type: "player", inventory: { "parsnip-seed": 3, hoe: 1 } }];
+    expect(seedListFromSnapshot(world, ITEMS)).toEqual([{ id: "parsnip-seed", name: "Parsnip Seed", qty: 3 }]);
+  });
+
+  it("seedListFromSnapshot offers every owned item without a catalog (ws mode)", () => {
+    const world = [{ id: "player", type: "player", inventory: { "parsnip-seed": 3, hoe: 1 } }];
+    expect(seedListFromSnapshot(world)).toEqual([
+      { id: "parsnip-seed", name: "parsnip-seed", qty: 3 },
+      { id: "hoe", name: "hoe", qty: 1 },
+    ]);
+  });
+
+  it("shopRowsFromDef lists buy rows (owned) and sell rows (price + stock)", () => {
+    const rows = shopRowsFromDef(SHOP, { parsnip: 2, "parsnip-seed": 1 }, ITEMS);
+    expect(rows.shopId).toBe("general-store");
+    expect(rows.buyRows).toEqual([{ itemId: "parsnip", name: "Parsnip", price: 30, stock: null, owned: 2 }]);
+    expect(rows.sellRows).toEqual([
+      { itemId: "hoe", name: "Hoe", price: 500, stock: null, owned: 0 },
+      { itemId: "parsnip-seed", name: "Parsnip Seed", price: 20, stock: 99, owned: 1 },
+    ]);
+  });
+
+  it("shopRowsFromDef falls back to item ids for names without a catalog", () => {
+    const rows = shopRowsFromDef(SHOP, {});
+    expect(rows.sellRows[0]?.name).toBe("hoe");
+    expect(rows.sellRows[0]?.owned).toBe(0);
+  });
+
+  it("dialogueChoicesFromNode maps a DialogueState to text + choice labels", () => {
+    expect(dialogueChoicesFromNode(undefined)).toEqual({ text: "", choices: [] });
+    expect(
+      dialogueChoicesFromNode({
+        dialogueId: "mayor",
+        nodeId: "greeting",
+        text: "Hello!",
+        choices: [{ text: "Bye", to: "farewell" }],
+      }),
+    ).toEqual({ text: "Hello!", choices: ["Bye"] });
+  });
+
+  it("buildingShopAt finds a shop-carrying building footprint on a tile", () => {
+    const world = [
+      {
+        id: "building:shop:5:5",
+        type: "building",
+        buildingId: "shop",
+        x: 5,
+        y: 5,
+        w: 2,
+        h: 2,
+        blocksMovement: false,
+        shop: "general-store",
+      },
+      { id: "building:fence:0:0", type: "building", buildingId: "fence", x: 0, y: 0, w: 1, h: 1, blocksMovement: true },
+    ];
+    expect(buildingShopAt(world, { x: 6, y: 6 })).toEqual({
+      buildingId: "building:shop:5:5",
+      shopId: "general-store",
+    });
+    expect(buildingShopAt(world, { x: 0, y: 0 })).toBeUndefined();
+    expect(buildingShopAt(world, { x: 9, y: 9 })).toBeUndefined();
+  });
+
+  it("dialogueActiveInWorld detects the dialogue:active entity", () => {
+    expect(dialogueActiveInWorld([{ id: "dialogue:active", type: "dialogue" }])).toBe(true);
+    expect(dialogueActiveInWorld([{ id: "player", type: "player" }])).toBe(false);
+  });
+});

+ 145 - 0
apps/dev/src/ui.ts

@@ -0,0 +1,145 @@
+import type { DialogueState, ItemDef, ShopDef } from "@nengine/core";
+
+export interface PlayerStats {
+  inventory: Record<string, number>;
+  gold: number;
+  stamina: number;
+  selectedSeed: string | null;
+}
+
+export function playerStatsFromSnapshot(world: unknown[]): PlayerStats {
+  const player = world.find(
+    (entity): entity is Record<string, unknown> => isRecord(entity) && entity["id"] === "player",
+  );
+  const inventory: Record<string, number> = {};
+  const rawInventory = player?.["inventory"];
+  if (isRecord(rawInventory)) {
+    for (const [id, qty] of Object.entries(rawInventory)) {
+      if (typeof qty === "number" && qty > 0) inventory[id] = qty;
+    }
+  }
+  return {
+    inventory,
+    gold: numberField(player, "gold"),
+    stamina: numberField(player, "stamina"),
+    selectedSeed: stringField(player, "selectedSeed"),
+  };
+}
+
+export interface SeedOption {
+  id: string;
+  name: string;
+  qty: number;
+}
+
+/** Seed picker options from a snapshot world. With an item catalog (local
+ * mode) only `seed`-kind items qualify; without one (ws mode — the snapshot
+ * carries no item defs) every inventory item is offered and the engine
+ * validates on select-seed. */
+export function seedListFromSnapshot(world: unknown[], items?: ItemDef[]): SeedOption[] {
+  const stats = playerStatsFromSnapshot(world);
+  const out: SeedOption[] = [];
+  for (const [id, qty] of Object.entries(stats.inventory)) {
+    const def = items?.find((item) => item.id === id);
+    if (items !== undefined) {
+      if (def?.kind !== "seed") continue;
+      out.push({ id, name: def.name, qty });
+    } else {
+      out.push({ id, name: id, qty });
+    }
+  }
+  return out;
+}
+
+export interface ShopRow {
+  itemId: string;
+  name: string;
+  price: number;
+  stock: number | null;
+  owned: number;
+}
+
+export interface ShopRows {
+  shopId: string;
+  buyRows: ShopRow[];
+  sellRows: ShopRow[];
+}
+
+export function shopRowsFromDef(
+  shop: ShopDef,
+  inventory: Record<string, number> | undefined,
+  items?: ItemDef[],
+): ShopRows {
+  const inv: Record<string, number> = inventory ?? {};
+  const nameOf = (itemId: string): string => {
+    const def = items?.find((item) => item.id === itemId);
+    return def?.name ?? itemId;
+  };
+  return {
+    shopId: shop.id,
+    buyRows: shop.buys.map((line) => ({
+      itemId: line.itemId,
+      name: nameOf(line.itemId),
+      price: line.price,
+      stock: null,
+      owned: inv[line.itemId] ?? 0,
+    })),
+    sellRows: shop.sells.map((line) => ({
+      itemId: line.itemId,
+      name: nameOf(line.itemId),
+      price: line.price,
+      stock: line.stock ?? null,
+      owned: inv[line.itemId] ?? 0,
+    })),
+  };
+}
+
+export interface DialogueView {
+  text: string;
+  choices: string[];
+}
+
+export function dialogueChoicesFromNode(state: DialogueState | undefined): DialogueView {
+  if (!state) return { text: "", choices: [] };
+  return { text: state.text, choices: state.choices.map((choice) => choice.text) };
+}
+
+export interface BuildingShop {
+  buildingId: string;
+  shopId: string;
+}
+
+export function buildingShopAt(world: unknown[], tile: { x: number; y: number }): BuildingShop | undefined {
+  for (const entity of world) {
+    if (!isRecord(entity) || entity["type"] !== "building") continue;
+    const shop = entity["shop"];
+    if (typeof shop !== "string" || shop.length === 0) continue;
+    const x = entity["x"];
+    const y = entity["y"];
+    const w = entity["w"];
+    const h = entity["h"];
+    if (typeof x !== "number" || typeof y !== "number" || typeof w !== "number" || typeof h !== "number") continue;
+    if (tile.x >= x && tile.x < x + w && tile.y >= y && tile.y < y + h) {
+      return { buildingId: String(entity["id"]), shopId: shop };
+    }
+  }
+  return undefined;
+}
+
+export function dialogueActiveInWorld(world: unknown[]): boolean {
+  return world.some((entity) => isRecord(entity) && entity["id"] === "dialogue:active");
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function numberField(value: Record<string, unknown> | undefined, key: string): number {
+  const field = value?.[key];
+  return typeof field === "number" ? field : 0;
+}
+
+function stringField(value: Record<string, unknown> | undefined, key: string): string | null {
+  const field = value?.[key];
+  return typeof field === "string" && field.length > 0 ? field : null;
+}

+ 8 - 0
apps/dev/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "noEmit": true
+  },
+  "include": ["src"]
+}

+ 101 - 0
apps/dev/vite.config.ts

@@ -0,0 +1,101 @@
+import { existsSync, readdirSync, statSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath, URL } from "node:url";
+import type { Plugin } from "vite";
+import { defineConfig } from "vitest/config";
+
+const repoRoot = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/+$/, "");
+
+function repoPath(p: string): string {
+  const abs = resolve(repoRoot, p.replace(/^\/+/, ""));
+  if (abs !== repoRoot && !abs.startsWith(`${repoRoot}/`)) {
+    throw new Error(`path outside repo root: ${p}`);
+  }
+  return abs;
+}
+
+function nengineFsPlugin(): Plugin {
+  return {
+    name: "nengine-fs",
+    configureServer(server) {
+      server.middlewares.use((req, res, next) => {
+        if (req.url === "/" || req.url?.startsWith("/?")) {
+          req.url = "/apps/dev/index.html";
+          next();
+          return;
+        }
+        const url = new URL(req.url ?? "/", "http://localhost");
+        const target = url.searchParams.get("p");
+        if (!target) {
+          next();
+          return;
+        }
+        let abs: string;
+        try {
+          abs = repoPath(target);
+        } catch {
+          res.statusCode = 404;
+          res.end("bad path");
+          return;
+        }
+        if (url.pathname === "/__nengine_fs/readdir") {
+          if (!existsSync(abs)) {
+            res.statusCode = 404;
+            res.end("ENOENT");
+            return;
+          }
+          const entries = readdirSync(abs, { withFileTypes: true });
+          const body =
+            url.searchParams.get("detailed") === "1"
+              ? JSON.stringify(entries.map((e) => ({ name: e.name, isDirectory: e.isDirectory() })))
+              : JSON.stringify(entries.map((e) => e.name));
+          res.setHeader("content-type", "application/json; charset=utf-8");
+          res.end(body);
+          return;
+        }
+        if (url.pathname === "/__nengine_fs/stat") {
+          if (!existsSync(abs)) {
+            res.statusCode = 404;
+            res.end("ENOENT");
+            return;
+          }
+          res.setHeader("content-type", "application/json; charset=utf-8");
+          res.end(JSON.stringify({ isDirectory: statSync(abs).isDirectory() }));
+          return;
+        }
+        if (url.pathname === "/__nengine_fs/exists") {
+          res.setHeader("content-type", "text/plain; charset=utf-8");
+          res.end(existsSync(abs) ? "1" : "0");
+          return;
+        }
+        next();
+      });
+    },
+  };
+}
+
+export default defineConfig({
+  root: repoRoot,
+  plugins: [nengineFsPlugin()],
+  resolve: {
+    alias: {
+      "@nengine/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
+      "@nengine/render": fileURLToPath(new URL("../../packages/render/src/index.ts", import.meta.url)),
+      "node:fs/promises": fileURLToPath(new URL("./src/shim/fs-promises.ts", import.meta.url)),
+      "node:fs": fileURLToPath(new URL("./src/shim/fs.ts", import.meta.url)),
+      "node:path": fileURLToPath(new URL("./src/shim/path.ts", import.meta.url)),
+      "node:url": fileURLToPath(new URL("./src/shim/url.ts", import.meta.url)),
+    },
+  },
+  build: {
+    outDir: "apps/dev/dist",
+    emptyOutDir: true,
+    rollupOptions: {
+      input: fileURLToPath(new URL("./index.html", import.meta.url)),
+    },
+  },
+  test: {
+    include: ["apps/dev/src/**/*.test.ts"],
+    environment: "node",
+  },
+});

+ 24 - 0
apps/mcp-server/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "@nengine/mcp-server",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "description": "Headless stdio entry: headless core + MCP gateway over the MCP SDK stdio transport.",
+  "scripts": {
+    "start": "node dist/index.js",
+    "build": "tsc -p tsconfig.json",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "@nengine/core": "workspace:*",
+    "@nengine/mcp": "workspace:*",
+    "@modelcontextprotocol/sdk": "^1.30.0",
+    "ws": "^8.18.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "@types/ws": "^8.5.13",
+    "typescript": "^5.6.0",
+    "vitest": "^3.0.0"
+  }
+}

+ 110 - 0
apps/mcp-server/src/index.ts

@@ -0,0 +1,110 @@
+import { resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { parseArgs } from "node:util";
+import { createEngine } from "@nengine/core";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { createNengineMcpServer } from "./server.js";
+import { startWsBroadcast, type WsBroadcastHandle } from "./ws-broadcast.js";
+
+export interface ServerDirs {
+  contentDir: string;
+  modsDir: string;
+  wsPort?: number;
+}
+
+export const DEFAULT_DIRS: ServerDirs = {
+  contentDir: fileURLToPath(new URL("../../../content", import.meta.url)),
+  modsDir: fileURLToPath(new URL("../../../mods", import.meta.url)),
+};
+
+export const CONTENT_DIR_ENV = "NENGINE_CONTENT_DIR";
+export const MODS_DIR_ENV = "NENGINE_MODS_DIR";
+export const WS_PORT_ENV = "NENGINE_WS_PORT";
+
+export interface ResolveDirInput {
+  argv?: string[];
+  env?: NodeJS.ProcessEnv;
+  cwd?: string;
+}
+
+/**
+ * Resolve content/mods roots and the optional WS broadcast port: CLI
+ * (`--content-dir`/`--mods-dir`/`--ws-port`) > env
+ * (`NENGINE_CONTENT_DIR`/`NENGINE_MODS_DIR`/`NENGINE_WS_PORT`) > monorepo defaults.
+ * Relative CLI/env paths resolve against `cwd` (default `process.cwd()`).
+ */
+export function resolveDirs(input: ResolveDirInput = {}): ServerDirs {
+  const argv = input.argv ?? process.argv.slice(2);
+  const env = input.env ?? process.env;
+  const cwd = input.cwd ?? process.cwd();
+  const parsed = parseArgs({
+    args: argv,
+    options: {
+      "content-dir": { type: "string" },
+      "mods-dir": { type: "string" },
+      "ws-port": { type: "string" },
+    },
+    strict: false,
+  });
+  const dirs: ServerDirs = {
+    contentDir: resolveDir(cliString(parsed.values, "content-dir"), env[CONTENT_DIR_ENV], DEFAULT_DIRS.contentDir, cwd),
+    modsDir: resolveDir(cliString(parsed.values, "mods-dir"), env[MODS_DIR_ENV], DEFAULT_DIRS.modsDir, cwd),
+  };
+  const wsPort = resolveWsPort(cliString(parsed.values, "ws-port"), env[WS_PORT_ENV]);
+  if (wsPort !== undefined) {
+    dirs.wsPort = wsPort;
+  }
+  return dirs;
+}
+
+function cliString(values: Record<string, string | boolean | undefined>, key: string): string | undefined {
+  const value = values[key];
+  return typeof value === "string" ? value : undefined;
+}
+
+function resolveDir(cliValue: string | undefined, envValue: string | undefined, fallback: string, cwd: string): string {
+  const raw = cliValue ?? envValue;
+  if (raw === undefined || raw === "") return fallback;
+  return resolve(cwd, raw);
+}
+
+function resolveWsPort(cliValue: string | undefined, envValue: string | undefined): number | undefined {
+  const raw = cliValue ?? envValue;
+  if (raw === undefined || raw === "") return undefined;
+  const port = Number.parseInt(raw, 10);
+  if (!Number.isInteger(port) || port <= 0 || port > 65535 || String(port) !== raw) {
+    throw new Error(`invalid ws port: ${raw}`);
+  }
+  return port;
+}
+
+export async function main(dirs: ServerDirs): Promise<void> {
+  const engine = createEngine(dirs);
+  await engine.start();
+
+  let ws: WsBroadcastHandle | undefined;
+  if (dirs.wsPort !== undefined) {
+    ws = await startWsBroadcast(engine, dirs.wsPort);
+  }
+
+  const server = createNengineMcpServer(engine);
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
+
+  const shutdown = async (): Promise<void> => {
+    await server.close();
+    if (ws) await ws.close();
+    await engine.stop();
+    process.exit(0);
+  };
+  process.on("SIGINT", () => void shutdown());
+  process.on("SIGTERM", () => void shutdown());
+}
+
+const entry = process.argv[1];
+if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
+  main(resolveDirs()).catch((err: unknown) => {
+    console.error("nengine mcp-server failed to start:", err);
+    process.exit(1);
+  });
+}

+ 57 - 0
apps/mcp-server/src/server.ts

@@ -0,0 +1,57 @@
+import type { Engine } from "@nengine/core";
+import { createEngineNamespaces, McpGateway } from "@nengine/mcp";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import {
+  CallToolRequestSchema,
+  ErrorCode,
+  GetPromptRequestSchema,
+  ListPromptsRequestSchema,
+  ListResourcesRequestSchema,
+  ListToolsRequestSchema,
+  McpError,
+  ReadResourceRequestSchema,
+} from "@modelcontextprotocol/sdk/types.js";
+
+export function createGateway(engine: Engine): McpGateway {
+  const gateway = new McpGateway();
+  for (const namespace of createEngineNamespaces(engine, { gateway })) {
+    gateway.register(namespace);
+  }
+  return gateway;
+}
+
+export function createNengineMcpServer(engine: Engine, gateway: McpGateway = createGateway(engine)): McpServer {
+  const server = new McpServer(
+    { name: "nengine-mcp-server", version: "0.1.0" },
+    { capabilities: { tools: {}, resources: {}, prompts: {} } },
+  );
+
+  server.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: gateway.listTools() }));
+  server.server.setRequestHandler(CallToolRequestSchema, async (request) =>
+    gateway.callTool(request.params.name, request.params.arguments ?? {}),
+  );
+  server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: gateway.listResources() }));
+  server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
+    try {
+      return await gateway.readResource(request.params.uri);
+    } catch (err) {
+      throw toMcpError(err);
+    }
+  });
+  server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: gateway.listPrompts() }));
+  server.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
+    try {
+      return await gateway.getPrompt(request.params.name, request.params.arguments ?? {});
+    } catch (err) {
+      throw toMcpError(err);
+    }
+  });
+
+  return server;
+}
+
+function toMcpError(err: unknown): McpError {
+  if (err instanceof McpError) return err;
+  const message = err instanceof Error ? err.message : String(err);
+  return new McpError(ErrorCode.InvalidRequest, message);
+}

+ 259 - 0
apps/mcp-server/src/ws-broadcast.ts

@@ -0,0 +1,259 @@
+import { WebSocketServer, WebSocket } from "ws";
+import type {
+  AnimationDef,
+  AssetEntry,
+  AssetIndexEntry,
+  Engine,
+  Entity,
+  IntentSystem,
+  MapDef,
+  ParticleDef,
+  ShaderDef,
+  ShopDef,
+  SoundDef,
+  SpriteDef,
+  TimeState,
+  TimeSystem,
+} from "@nengine/core";
+import { assetByteSize } from "@nengine/core";
+
+export interface WsSnapshotFrame {
+  type: "snapshot";
+  map: MapDef;
+  world: Entity[];
+  time: TimeState;
+  sprites: SpriteDef[];
+  sounds: SoundDef[];
+  particles: ParticleDef[];
+  shaders: ShaderDef[];
+  shops: ShopDef[];
+  /** Animation state machine defs (D38): render viewports resolve an entity's
+   * sprite by its animation def (matched by spriteRef) + `anim`/motion state. */
+  animations: AnimationDef[];
+  /** Metadata-only asset index (D37): asset id → {kind, mime, size}. Bytes are
+   * NOT shipped per snapshot — clients request them via `assets-request` and
+   * the server replies with a `{type:"assets"}` frame. */
+  assetsIndex: Record<string, AssetIndexEntry>;
+}
+
+export interface WsAssetsFrame {
+  type: "assets";
+  assets: Record<string, AssetEntry>;
+}
+
+export interface WsEventFrame {
+  type: "event";
+  event: string;
+  payload: unknown;
+}
+
+export interface WsBroadcastHandle {
+  port: number;
+  close(): Promise<void>;
+}
+
+/** Per-request asset id cap (D37): a client may ask for at most this many
+ * assets in one `assets-request`; larger requests are truncated server-side. */
+const MAX_ASSET_REQUEST_IDS = 64;
+
+export async function startWsBroadcast(engine: Engine, port: number): Promise<WsBroadcastHandle> {
+  const wss = new WebSocketServer({ port });
+  const clients = new Set<WebSocket>();
+
+  await new Promise<void>((resolveListen, rejectListen) => {
+    wss.once("error", (err) => rejectListen(err));
+    if (wss.address() !== null) {
+      resolveListen();
+    } else {
+      wss.once("listening", () => resolveListen());
+    }
+  });
+
+  wss.on("connection", (client) => {
+    clients.add(client);
+    send(client, snapshotFrame(engine));
+    client.on("message", (data) => {
+      let msg: unknown;
+      try {
+        msg = JSON.parse(String(data));
+      } catch {
+        return;
+      }
+      if (!isRecord(msg)) return;
+      if (msg.type === "sync") {
+        send(client, snapshotFrame(engine));
+        return;
+      }
+      if (msg.type === "assets-request") {
+        handleAssetsRequest(client, msg);
+        return;
+      }
+      if (msg.type === "intent") {
+        handleIntentMessage(engine, msg);
+        return;
+      }
+      if (msg.type === "input") {
+        handleInputMessage(engine, msg);
+      }
+    });
+    client.on("close", () => clients.delete(client));
+    client.on("error", () => clients.delete(client));
+  });
+
+  const unsubscribe = engine.events.onAny((event, payload) => {
+    broadcast({ type: "event", event, payload });
+  });
+
+  function broadcast(frame: WsSnapshotFrame | WsEventFrame): void {
+    const text = serialize(frame);
+    if (text === undefined) return;
+    for (const client of clients) {
+      if (client.readyState === WebSocket.OPEN) {
+        client.send(text);
+      }
+    }
+  }
+
+  function handleIntentMessage(engine: Engine, msg: { action?: unknown; args?: unknown }): void {
+    const intent = engine.systems.get<IntentSystem>("intent");
+    const action = msg.action;
+    if (!intent) {
+      broadcast({ type: "event", event: "player-intent", payload: errorPayload(action, "intent system not available") });
+      return;
+    }
+    if (action !== "move" && action !== "interact" && action !== "buy" && action !== "sell" && action !== "talk" && action !== "choose" && action !== "select-seed") {
+      broadcast({
+        type: "event",
+        event: "player-intent",
+        payload: errorPayload(action, 'intent action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"'),
+      });
+      return;
+    }
+    try {
+      intent.handle(action, isRecord(msg.args) ? msg.args : {});
+    } catch (err) {
+      broadcast({
+        type: "event",
+        event: "player-intent",
+        payload: errorPayload(action, err instanceof Error ? err.message : String(err)),
+      });
+    }
+  }
+
+  function handleAssetsRequest(client: WebSocket, msg: { ids?: unknown }): void {
+    const raw = Array.isArray(msg.ids) ? msg.ids : [];
+    const ids = raw.filter((id): id is string => typeof id === "string" && id.length > 0).slice(0, MAX_ASSET_REQUEST_IDS);
+    const assets: Record<string, AssetEntry> = {};
+    for (const id of ids) {
+      const entry = engine.assets.get(id);
+      if (entry) assets[id] = entry;
+    }
+    send(client, { type: "assets", assets });
+  }
+
+  function handleInputMessage(engine: Engine, msg: { input?: unknown }): void {
+    const input = msg.input;
+    if (typeof input !== "string" || input.length === 0) {
+      broadcast({
+        type: "event",
+        event: "input",
+        payload: { input: typeof input === "string" ? input : String(input), ok: false, error: "input must be a non-empty string" },
+      });
+      return;
+    }
+    engine.events.emit("input", { input });
+    const direction = arrowDirection(input);
+    if (!direction) return;
+    const intent = engine.systems.get<IntentSystem>("intent");
+    const player = engine.world.get("player");
+    if (!intent || !player) return;
+    const x = (typeof player.x === "number" ? player.x : 0) + direction.dx;
+    const y = (typeof player.y === "number" ? player.y : 0) + direction.dy;
+    if (x < 0 || y < 0) return;
+    try {
+      intent.handle("move", { tile: { x, y } });
+    } catch {
+      // never crash the server on input processing
+    }
+  }
+
+  const address = wss.address();
+  const boundPort = typeof address === "object" && address !== null ? address.port : port;
+
+  return {
+    port: boundPort,
+    close(): Promise<void> {
+      unsubscribe();
+      for (const client of clients) client.close();
+      clients.clear();
+      return new Promise((resolveClose) => {
+        wss.close(() => resolveClose());
+      });
+    },
+  };
+}
+
+function send(client: WebSocket, frame: WsSnapshotFrame | WsAssetsFrame): void {
+  if (client.readyState !== WebSocket.OPEN) return;
+  const text = serialize(frame);
+  if (text !== undefined) {
+    client.send(text);
+  }
+}
+
+function snapshotFrame(engine: Engine): WsSnapshotFrame {
+  const map = engine.registry.list<MapDef>("map")[0];
+  if (!map) throw new Error("ws-broadcast: no map content loaded");
+  const time = engine.systems.get<TimeSystem>("time")?.get();
+  const assetsIndex: Record<string, AssetIndexEntry> = {};
+  for (const [id, entry] of engine.assets) {
+    assetsIndex[id] = { kind: entry.kind, mime: entry.mime, size: assetByteSize(entry) };
+  }
+  return {
+    type: "snapshot",
+    map,
+    world: engine.world.snapshot(),
+    time: time ?? { day: 1, season: "", tickInDay: 0 },
+    sprites: engine.registry.list<SpriteDef>("sprite"),
+    sounds: engine.registry.list<SoundDef>("sound"),
+    particles: engine.registry.list<ParticleDef>("particle"),
+    shaders: engine.registry.list<ShaderDef>("shader"),
+    shops: engine.registry.list<ShopDef>("shop"),
+    animations: engine.registry.list<AnimationDef>("animation"),
+    assetsIndex,
+  };
+}
+
+function arrowDirection(input: string): { dx: number; dy: number } | undefined {
+  if (!input.startsWith("key:") || input.length <= 4) return undefined;
+  const key = input.slice(4);
+  switch (key) {
+    case "ArrowUp":
+      return { dx: 0, dy: -1 };
+    case "ArrowDown":
+      return { dx: 0, dy: 1 };
+    case "ArrowLeft":
+      return { dx: -1, dy: 0 };
+    case "ArrowRight":
+      return { dx: 1, dy: 0 };
+    default:
+      return undefined;
+  }
+}
+
+function errorPayload(action: unknown, error: string): { action: unknown; tile: { x: number; y: number }; ok: false; error: string } {
+  return { action, tile: { x: 0, y: 0 }, ok: false, error };
+}
+
+function serialize(frame: unknown): string | undefined {
+  try {
+    return JSON.stringify(frame);
+  } catch (err) {
+    console.error("ws-broadcast: skipping non-serializable frame:", err);
+    return undefined;
+  }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}

+ 1435 - 0
apps/mcp-server/test/client-surface.test.ts

@@ -0,0 +1,1435 @@
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine, type InventorySystem, type PlantingSystem, type TimeSystem } from "@nengine/core";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { createNengineMcpServer, createGateway } from "../src/server.js";
+
+const repoRoot = resolve(process.cwd(), "../..");
+const contentDir = resolve(repoRoot, "content");
+const modsDir = resolve(repoRoot, "mods");
+
+interface Harness {
+  engine: ReturnType<typeof createEngine>;
+  client: Client;
+  close: () => Promise<void>;
+}
+
+async function setup(): Promise<Harness> {
+  const engine = createEngine({ contentDir, modsDir });
+  await engine.start();
+  const gateway = createGateway(engine);
+  const server = createNengineMcpServer(engine, gateway);
+  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+  await server.connect(serverTransport);
+  const client = new Client({ name: "client-surface-test", version: "0.0.1" });
+  await client.connect(clientTransport);
+  return {
+    engine,
+    client,
+    close: async () => {
+      await client.close();
+      await server.close();
+      await engine.stop();
+    },
+  };
+}
+
+function textOf(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.find((block) => block.type === "text")?.text ?? "";
+}
+
+function contentsOf(result: { contents: { text?: string }[] }): string {
+  return result.contents[0]?.text ?? "";
+}
+
+function jsonOf(text: string): Record<string, any> {
+  return JSON.parse(text) as Record<string, any>;
+}
+
+describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
+  it("handshake: initialize + server version", async () => {
+    const { client, close } = await setup();
+    try {
+      expect(client.getServerVersion()).toMatchObject({ name: "nengine-mcp-server", version: "0.1.0" });
+    } finally {
+      await close();
+    }
+  });
+
+  it("tools surface: exactly 116 tools, all with object inputSchema", async () => {
+    const { client, close } = await setup();
+    try {
+      const tools = await client.listTools();
+      const names = tools.tools.map((tool) => tool.name).sort();
+      expect(names).toEqual(
+        [
+          "animal.create",
+          "animal.entities",
+          "animal.feed",
+          "animal.get",
+          "animal.list",
+          "animal.spawn",
+          "animal.stage",
+          "animal.update",
+          "animal.validate",
+          "animation.create",
+          "animation.get",
+          "animation.list",
+          "animation.update",
+          "animation.validate",
+          "behavior.condition.validate",
+          "behavior.fire",
+          "behavior.statemachine.create",
+          "behavior.statemachine.get",
+          "behavior.statemachine.list",
+          "behavior.statemachine.status",
+          "behavior.statemachine.transition",
+          "behavior.statemachine.update",
+          "behavior.statemachine.validate",
+          "behavior.trigger.create",
+          "behavior.trigger.get",
+          "behavior.trigger.list",
+          "behavior.trigger.update",
+          "behavior.trigger.validate",
+          "crop.create",
+          "crop.get",
+          "crop.harvest",
+          "crop.list",
+          "crop.sow",
+          "crop.stage",
+          "crop.till",
+          "crop.update",
+          "crop.validate",
+          "crop.water",
+          "dialogue.choose",
+          "dialogue.create",
+          "dialogue.get",
+          "dialogue.list",
+          "dialogue.start",
+          "dialogue.update",
+          "dialogue.validate",
+          "item.create",
+          "item.get",
+          "item.give",
+          "item.list",
+          "item.query_inventory",
+          "item.update",
+          "item.validate",
+          "map.get",
+          "map.list",
+          "mod.list",
+          "mod.reload",
+          "mod.status",
+          "npc.get",
+          "npc.list",
+          "npc.move",
+          "npc.path",
+          "npc.spawn",
+          "npc.teleport",
+          "particle.create",
+          "particle.get",
+          "particle.list",
+          "particle.update",
+          "particle.validate",
+          "placement.building.create",
+          "placement.building.get",
+          "placement.building.list",
+          "placement.building.update",
+          "placement.building.validate",
+          "placement.list",
+          "placement.occupies",
+          "placement.place",
+          "placement.remove",
+          "player.intent",
+          "rng.get",
+          "rng.set",
+          "save.migrate",
+          "save.restore",
+          "save.snapshot",
+          "save.validate",
+          "server.disable_subsystem",
+          "server.enable_subsystem",
+          "server.info",
+          "server.status",
+          "shader.create",
+          "shader.get",
+          "shader.list",
+          "shader.update",
+          "shader.validate",
+          "shop.buy",
+          "shop.create",
+          "shop.get",
+          "shop.list",
+          "shop.sell",
+          "shop.update",
+          "shop.validate",
+          "sound.create",
+          "sound.get",
+          "sound.list",
+          "sound.update",
+          "sound.validate",
+          "sprite.create",
+          "sprite.get",
+          "sprite.list",
+          "sprite.update",
+          "sprite.validate",
+          "time.advance",
+          "time.get",
+          "time.set",
+          "time.tick",
+          "weather.get",
+          "weather.set",
+        ].sort(),
+      );
+      expect(tools.tools).toHaveLength(116);
+      for (const tool of tools.tools) {
+        expect(tool.inputSchema).toMatchObject({ type: "object" });
+      }
+    } finally {
+      await close();
+    }
+  });
+
+  it("server.info: identity, content counts, subsystems enabled, time state", async () => {
+    const { client, close } = await setup();
+    try {
+      const info = jsonOf(textOf(await client.callTool({ name: "server.info", arguments: {} })));
+      expect(info.engine).toBe("nengine");
+      expect(info.version).toBe("0.1.0");
+      expect(info.content).toEqual({
+        animal: ["chicken"],
+        behavior: ["season-welcome", "spring-gift"],
+        building: ["fence", "shed"],
+        crop: ["parsnip"],
+        dialogue: ["welcome"],
+        map: ["farm"],
+        time: ["default"],
+        item: ["egg", "hoe", "parsnip-seed", "parsnip"],
+        shop: ["general-store"],
+        statemachine: ["chicken-egg", "daily-routine", "spring-quest"],
+        weather: ["default"],
+      });
+      expect(info.subsystems.map((s: { name: string }) => s.name).sort()).toEqual([
+        "animal",
+        "animation",
+        "behavior",
+        "crop",
+        "dialogue",
+        "item",
+        "map",
+        "mod",
+        "npc",
+        "particle",
+        "placement",
+        "player",
+        "rng",
+        "save",
+        "server",
+        "shader",
+        "shop",
+        "sound",
+        "sprite",
+        "time",
+        "weather",
+      ]);
+      expect(info.subsystems.every((s: { enabled: boolean }) => s.enabled === true)).toBe(true);
+      expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("server.status: {subsystems:[...]} with all namespaces", async () => {
+    const { client, close } = await setup();
+    try {
+      const status = jsonOf(textOf(await client.callTool({ name: "server.status", arguments: {} })));
+      expect(status.subsystems.map((s: { name: string }) => s.name).sort()).toEqual([
+        "animal",
+        "animation",
+        "behavior",
+        "crop",
+        "dialogue",
+        "item",
+        "map",
+        "mod",
+        "npc",
+        "particle",
+        "placement",
+        "player",
+        "rng",
+        "save",
+        "server",
+        "shader",
+        "shop",
+        "sound",
+        "sprite",
+        "time",
+        "weather",
+      ]);
+    } finally {
+      await close();
+    }
+  });
+
+  it("server.enable_subsystem/disable_subsystem drive namespace availability through the real server", async () => {
+    const { client, close } = await setup();
+    try {
+      const before = await client.listTools();
+      expect(before.tools.some((t) => t.name === "time.get")).toBe(true);
+      expect(before.tools.some((t) => t.name === "server.enable_subsystem")).toBe(true);
+
+      const disabled = jsonOf(
+        textOf(await client.callTool({ name: "server.disable_subsystem", arguments: { name: "time" } })),
+      );
+      expect(disabled).toEqual({ name: "time", enabled: false });
+
+      const after = await client.listTools();
+      expect(after.tools.some((t) => t.name === "time.get")).toBe(false);
+      expect(after.tools.some((t) => t.name === "server.info")).toBe(true);
+
+      const resources = await client.listResources();
+      expect(resources.resources.some((r) => r.uri === "time://state")).toBe(false);
+
+      const info = jsonOf(textOf(await client.callTool({ name: "server.info", arguments: {} })));
+      expect(info.subsystems.find((s: { name: string }) => s.name === "time")).toEqual({
+        name: "time",
+        enabled: false,
+      });
+
+      const rejected = await client.callTool({ name: "time.get", arguments: {} });
+      expect(rejected.isError).toBe(true);
+      expect(textOf(rejected)).toMatch(/namespace "time" is disabled/);
+
+      const guard = await client.callTool({ name: "server.disable_subsystem", arguments: { name: "server" } });
+      expect(guard.isError).toBe(true);
+      expect(textOf(guard)).toMatch(/server\.disable_subsystem: cannot disable the server subsystem/);
+
+      const unknown = await client.callTool({ name: "server.enable_subsystem", arguments: { name: "nope" } });
+      expect(unknown.isError).toBe(true);
+      expect(textOf(unknown)).toMatch(/server\.enable_subsystem: unknown subsystem: nope/);
+
+      const blank = await client.callTool({ name: "server.disable_subsystem", arguments: { name: "" } });
+      expect(blank.isError).toBe(true);
+      expect(textOf(blank)).toMatch(/server\.disable_subsystem: name must be a non-empty string/);
+
+      const enabled = jsonOf(
+        textOf(await client.callTool({ name: "server.enable_subsystem", arguments: { name: "time" } })),
+      );
+      expect(enabled).toEqual({ name: "time", enabled: true });
+
+      const restored = await client.listTools();
+      expect(restored.tools.some((t) => t.name === "time.get")).toBe(true);
+      const get = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} })));
+      expect(get).toEqual({ day: 1, season: "spring", tickInDay: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("item: seeded catalog, list/get, inventory give + query", async () => {
+    const { client, close } = await setup();
+    try {
+      const catalog = jsonOf(contentsOf(await client.readResource({ uri: "item://catalog" })));
+      expect(catalog.items).toHaveLength(4);
+      const seed = catalog.items.find((i: { id: string }) => i.id === "parsnip-seed");
+      expect(seed).toMatchObject({ id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
+
+      const list = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} })));
+      expect(list.items).toEqual([
+        { id: "egg", name: "Egg", kind: "material" },
+        { id: "hoe", name: "Hoe", kind: "tool" },
+        { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed" },
+        { id: "parsnip", name: "Parsnip", kind: "material" },
+      ]);
+
+      const got = jsonOf(textOf(await client.callTool({ name: "item.get", arguments: { itemId: "hoe" } })));
+      expect(got).toMatchObject({ id: "hoe", kind: "tool" });
+
+      const gave = jsonOf(textOf(await client.callTool({ name: "item.give", arguments: { itemId: "hoe", qty: 2 } })));
+      expect(gave).toEqual({ itemId: "hoe", qty: 2, total: 2 });
+
+      const inventory = jsonOf(textOf(await client.callTool({ name: "item.query_inventory", arguments: {} })));
+      expect(inventory).toEqual({ inventory: { hoe: 2 } });
+
+      const missing = await client.callTool({ name: "item.get", arguments: { itemId: "nope" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/item not found: nope/);
+
+      const unknownGive = await client.callTool({ name: "item.give", arguments: { itemId: "nope", qty: 1 } });
+      expect(unknownGive.isError).toBe(true);
+      expect(textOf(unknownGive)).toMatch(/unknown item: nope/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("crop: seeded catalog, list/get, plot water/stage debug tools", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const catalog = jsonOf(contentsOf(await client.readResource({ uri: "crop://catalog" })));
+      expect(catalog.crops).toHaveLength(1);
+      const parsnip = catalog.crops.find((c: { id: string }) => c.id === "parsnip");
+      expect(parsnip).toMatchObject({
+        id: "parsnip",
+        seedItem: "parsnip-seed",
+        stages: [
+          { name: "sprout", days: 2, waterRequired: true },
+          { name: "grown", days: 2, waterRequired: true },
+          { name: "mature", days: 1, waterRequired: false },
+        ],
+        seasons: ["spring"],
+        drops: [{ itemId: "parsnip", qty: 1 }],
+      });
+
+      const list = jsonOf(textOf(await client.callTool({ name: "crop.list", arguments: {} })));
+      expect(list.crops).toEqual([{ id: "parsnip", seedItem: "parsnip-seed", stageCount: 3 }]);
+
+      const got = jsonOf(textOf(await client.callTool({ name: "crop.get", arguments: { cropId: "parsnip" } })));
+      expect(got).toMatchObject({ id: "parsnip", seedItem: "parsnip-seed" });
+
+      const missing = await client.callTool({ name: "crop.get", arguments: { cropId: "nope" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/crop not found: nope/);
+
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      inv!.give("parsnip-seed", 5);
+      const planting = engine.systems.get<PlantingSystem>("planting");
+      planting!.till(6, 6);
+      planting!.sow(6, 6, "parsnip-seed");
+
+      const watered = jsonOf(textOf(await client.callTool({ name: "crop.water", arguments: { x: 6, y: 6 } })));
+      expect(watered).toEqual({ plotId: "plot:6:6", x: 6, y: 6, watered: true });
+
+      const staged = jsonOf(textOf(await client.callTool({ name: "crop.stage", arguments: { x: 6, y: 6, stageIndex: 2 } })));
+      expect(staged).toEqual({
+        plotId: "plot:6:6",
+        x: 6,
+        y: 6,
+        cropItemId: "parsnip",
+        stageIndex: 2,
+        stage: "mature",
+      });
+
+      const singleCrop = jsonOf(contentsOf(await client.readResource({ uri: "crop://parsnip" })));
+      expect(singleCrop.id).toBe("parsnip");
+    } finally {
+      await close();
+    }
+  });
+
+  it("crop: till → sow → harvest debug tools drive the full farming loop through MCP", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      inv!.give("parsnip-seed", 5);
+
+      const tilled = jsonOf(textOf(await client.callTool({ name: "crop.till", arguments: { x: 7, y: 7 } })));
+      expect(tilled).toEqual({ plotId: "plot:7:7", x: 7, y: 7, tilled: true });
+
+      const byPlotId = jsonOf(textOf(await client.callTool({ name: "crop.till", arguments: { plotId: "plot:8:8" } })));
+      expect(byPlotId).toEqual({ plotId: "plot:8:8", x: 8, y: 8, tilled: true });
+
+      const sown = jsonOf(
+        textOf(await client.callTool({ name: "crop.sow", arguments: { x: 7, y: 7, seedItemId: "parsnip-seed" } })),
+      );
+      expect(sown).toEqual({ plotId: "plot:7:7", x: 7, y: 7, cropItemId: "parsnip", seedItemId: "parsnip-seed" });
+      expect(inv!.count("parsnip-seed")).toBe(4);
+
+      engine.world.create({ id: "plot:6:6", type: "plot", x: 6, y: 6, tilled: false, watered: false });
+      const untilled = await client.callTool({ name: "crop.sow", arguments: { x: 6, y: 6, seedItemId: "parsnip-seed" } });
+      expect(untilled.isError).toBe(true);
+      expect(textOf(untilled)).toMatch(/not tilled/);
+
+      const staged = jsonOf(textOf(await client.callTool({ name: "crop.stage", arguments: { x: 7, y: 7, stageIndex: 2 } })));
+      expect(staged).toMatchObject({ stageIndex: 2, stage: "mature" });
+
+      const harvested = jsonOf(textOf(await client.callTool({ name: "crop.harvest", arguments: { x: 7, y: 7 } })));
+      expect(harvested).toEqual({
+        plotId: "plot:7:7",
+        x: 7,
+        y: 7,
+        cropItemId: "parsnip",
+        drops: [{ itemId: "parsnip", qty: 1 }],
+      });
+      expect(inv!.count("parsnip")).toBe(1);
+
+      const empty = await client.callTool({ name: "crop.harvest", arguments: { x: 8, y: 8 } });
+      expect(empty.isError).toBe(true);
+      expect(textOf(empty)).toMatch(/has no crop/);
+
+      const missingSeed = await client.callTool({ name: "crop.sow", arguments: { x: 7, y: 7 } });
+      expect(missingSeed.isError).toBe(true);
+      expect(textOf(missingSeed)).toMatch(/missing required arguments: seedItemId/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("shop: seeded def, list/get, buy/sell debug transactions", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const catalog = jsonOf(contentsOf(await client.readResource({ uri: "shop://catalog" })));
+      expect(catalog.shops).toHaveLength(1);
+      const store = catalog.shops.find((s: { id: string }) => s.id === "general-store");
+      expect(store).toMatchObject({
+        id: "general-store",
+        buys: [{ itemId: "parsnip", price: 30 }],
+        sells: [
+          { itemId: "hoe", price: 500 },
+          { itemId: "parsnip-seed", price: 20, stock: 99 },
+        ],
+      });
+
+      const list = jsonOf(textOf(await client.callTool({ name: "shop.list", arguments: {} })));
+      expect(list.shops).toEqual([{ id: "general-store", buyCount: 1, sellCount: 2 }]);
+
+      const got = jsonOf(textOf(await client.callTool({ name: "shop.get", arguments: { shopId: "general-store" } })));
+      expect(got).toMatchObject({ id: "general-store" });
+
+      const missing = await client.callTool({ name: "shop.get", arguments: { shopId: "nope" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/shop not found: nope/);
+
+      const player = engine.world.get("player");
+      if (!player) throw new Error("player entity not seeded");
+      player.gold = 100;
+
+      const bought = jsonOf(
+        textOf(await client.callTool({ name: "shop.buy", arguments: { shopId: "general-store", itemId: "parsnip-seed", qty: 2 } })),
+      );
+      expect(bought).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 });
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      expect(inv!.count("parsnip-seed")).toBe(2);
+
+      const broke = await client.callTool({ name: "shop.buy", arguments: { shopId: "general-store", itemId: "hoe", qty: 1 } });
+      expect(broke.isError).toBe(true);
+      expect(textOf(broke)).toMatch(/insufficient gold/);
+
+      inv!.give("parsnip", 3);
+      const sold = jsonOf(
+        textOf(await client.callTool({ name: "shop.sell", arguments: { shopId: "general-store", itemId: "parsnip", qty: 2 } })),
+      );
+      expect(sold).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 2, total: 60, gold: 120 });
+      expect(inv!.count("parsnip")).toBe(1);
+
+      const short = await client.callTool({ name: "shop.sell", arguments: { shopId: "general-store", itemId: "parsnip", qty: 9 } });
+      expect(short.isError).toBe(true);
+      expect(textOf(short)).toMatch(/insufficient parsnip/);
+
+      const singleShop = jsonOf(contentsOf(await client.readResource({ uri: "shop://general-store" })));
+      expect(singleShop.id).toBe("general-store");
+    } finally {
+      await close();
+    }
+  });
+
+  it("behavior: seeded triggers, list/get, condition validate, fire debug, resource and prompt", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const resource = jsonOf(contentsOf(await client.readResource({ uri: "behavior://triggers" })));
+      expect(resource.triggers).toHaveLength(2);
+      const springGift = resource.triggers.find((t: { id: string }) => t.id === "spring-gift");
+      expect(springGift).toMatchObject({
+        id: "spring-gift",
+        when: { op: "gte", path: "time.day", value: 5 },
+        fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }],
+      });
+
+      const list = jsonOf(textOf(await client.callTool({ name: "behavior.trigger.list", arguments: {} })));
+      expect(list.triggers).toEqual([
+        { id: "season-welcome", actionCount: 2 },
+        { id: "spring-gift", actionCount: 1 },
+      ]);
+
+      const got = jsonOf(textOf(await client.callTool({ name: "behavior.trigger.get", arguments: { triggerId: "spring-gift" } })));
+      expect(got).toMatchObject({ id: "spring-gift", when: { op: "gte", path: "time.day", value: 5 } });
+
+      const missing = await client.callTool({ name: "behavior.trigger.get", arguments: { triggerId: "nope" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/trigger not found: nope/);
+
+      const validCondition = jsonOf(
+        textOf(await client.callTool({
+          name: "behavior.condition.validate",
+          arguments: { condition: { and: [{ op: "gte", path: "time.day", value: 5 }, { op: "exists", path: "payload.itemId" }] } },
+        })),
+      );
+      expect(validCondition).toEqual({ ok: true });
+
+      const invalidCondition = jsonOf(
+        textOf(await client.callTool({ name: "behavior.condition.validate", arguments: { condition: { op: "gte", path: "time.day" } } })),
+      );
+      expect(invalidCondition.ok).toBe(false);
+      expect(Array.isArray(invalidCondition.errors)).toBe(true);
+
+      const time = engine.systems.get<TimeSystem>("time");
+      time!.set(5, "spring");
+      const fired = jsonOf(textOf(await client.callTool({ name: "behavior.fire", arguments: { triggerId: "spring-gift" } })));
+      expect(fired).toEqual({ triggerId: "spring-gift", fired: true });
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      expect(inv!.count("parsnip-seed")).toBe(1);
+
+      const notFired = jsonOf(textOf(await client.callTool({ name: "behavior.fire", arguments: { triggerId: "season-welcome" } })));
+      expect(notFired).toEqual({ triggerId: "season-welcome", fired: false });
+
+      const unknown = await client.callTool({ name: "behavior.fire", arguments: { triggerId: "nope" } });
+      expect(unknown.isError).toBe(true);
+      expect(textOf(unknown)).toMatch(/unknown trigger: nope/);
+
+      const prompt = await client.getPrompt({ name: "behavior.trigger-prompt", arguments: {} });
+      const promptText = String(prompt.messages[0]?.content.text);
+      expect(promptText).toContain("give-item");
+      expect(promptText).toContain("behavior.trigger.validate");
+      expect(promptText).toContain("content/behaviors/<id>.yaml");
+    } finally {
+      await close();
+    }
+  });
+
+  it("statemachine: seeded definitions, list/get/status/transition, resource and prompt", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const resource = jsonOf(contentsOf(await client.readResource({ uri: "behavior://statemachines" })));
+      expect(resource.statemachines).toHaveLength(3);
+      const routine = resource.statemachines.find((m: { id: string }) => m.id === "daily-routine");
+      expect(routine).toMatchObject({
+        id: "daily-routine",
+        initial: "sleeping",
+        states: [{ id: "sleeping" }, { id: "working" }, { id: "resting" }],
+      });
+
+      const list = jsonOf(textOf(await client.callTool({ name: "behavior.statemachine.list", arguments: {} })));
+      expect(list.statemachines).toEqual([
+        { id: "chicken-egg", stateCount: 2, transitionCount: 2 },
+        { id: "daily-routine", stateCount: 3, transitionCount: 1 },
+        { id: "spring-quest", stateCount: 3, transitionCount: 2 },
+      ]);
+
+      const got = jsonOf(textOf(await client.callTool({ name: "behavior.statemachine.get", arguments: { machineId: "spring-quest" } })));
+      expect(got).toMatchObject({ id: "spring-quest", initial: "inactive" });
+
+      const missing = await client.callTool({ name: "behavior.statemachine.get", arguments: { machineId: "nope" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/machine not found: nope/);
+
+      const before = jsonOf(
+        textOf(await client.callTool({ name: "behavior.statemachine.status", arguments: { machineId: "spring-quest" } })),
+      );
+      expect(before).toEqual({ machineId: "spring-quest", state: "inactive", started: false });
+
+      const time = engine.systems.get<TimeSystem>("time");
+      time!.set(3, "spring");
+      time!.advance(480);
+      const after = jsonOf(
+        textOf(await client.callTool({ name: "behavior.statemachine.status", arguments: { machineId: "spring-quest" } })),
+      );
+      expect(after).toEqual({ machineId: "spring-quest", state: "active", started: true });
+
+      const forced = jsonOf(
+        textOf(await client.callTool({ name: "behavior.statemachine.transition", arguments: { machineId: "spring-quest", to: "complete" } })),
+      );
+      expect(forced).toEqual({ machineId: "spring-quest", from: "active", to: "complete", transitioned: true });
+      const player = engine.world.get("player");
+      expect(player?.questProgress).toMatchObject({ "spring-quest": "complete" });
+
+      const prompt = await client.getPrompt({ name: "behavior.statemachine-prompt", arguments: {} });
+      const promptText = String(prompt.messages[0]?.content.text);
+      expect(promptText).toContain("onSchedule");
+      expect(promptText).toContain("behavior.statemachine.validate");
+      expect(promptText).toContain("content/statemachines/<id>.yaml");
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.list: {maps:[...]} summaries", async () => {
+    const { client, close } = await setup();
+    try {
+      const list = jsonOf(textOf(await client.callTool({ name: "map.list", arguments: {} })));
+      expect(list.maps).toEqual([{ id: "farm", width: 12, height: 12, tilesetRef: "farm-tiles" }]);
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.get: full definition, layer layout, spawn, player entity", async () => {
+    const { client, close } = await setup();
+    try {
+      const map = jsonOf(textOf(await client.callTool({ name: "map.get", arguments: { mapId: "farm" } })));
+      expect(map.id).toBe("farm");
+      expect(map.width).toBe(12);
+      expect(map.height).toBe(12);
+      expect(map.tilesetRef).toBe("farm-tiles");
+      const ground = map.layers[0];
+      expect(ground.name).toBe("ground");
+      expect(ground.data).toHaveLength(12);
+      expect(ground.data[0]).toHaveLength(12);
+      expect(ground.data[0][0]).toBe("water");
+      expect(ground.data[4][4]).toBe("tilled");
+      expect(ground.data[0][10]).toBe("grass");
+      expect(map.spawn).toEqual({ x: 2, y: 2 });
+      expect(map.player).toMatchObject({ id: "player", type: "player", x: 2, y: 2 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("map.get: defaults to first map; missing mapId is an error result", async () => {
+    const { client, close } = await setup();
+    try {
+      const first = jsonOf(textOf(await client.callTool({ name: "map.get", arguments: {} })));
+      expect(first.id).toBe("farm");
+
+      const missing = await client.callTool({ name: "map.get", arguments: { mapId: "nope" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/map not found: nope/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("time: get initial, advance across day, set, advance across season", async () => {
+    const { client, close } = await setup();
+    try {
+      const initial = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} })));
+      expect(initial).toEqual({ day: 1, season: "spring", tickInDay: 0 });
+
+      const day2 = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } })));
+      expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+
+      const day28 = jsonOf(textOf(await client.callTool({ name: "time.set", arguments: { day: 28, season: "spring" } })));
+      expect(day28).toEqual({ day: 28, season: "spring", tickInDay: 0 });
+
+      const summer = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } })));
+      expect(summer).toEqual({ day: 1, season: "summer", tickInDay: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("time.tick runs a full simulation step (drives growth + emits time-tick; defaults to 1 tick)", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const stepped = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: { ticks: 120 } })));
+      expect(stepped).toEqual({ day: 1, season: "spring", tickInDay: 120 });
+
+      const defaulted = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: {} })));
+      expect(defaulted).toEqual({ day: 1, season: "spring", tickInDay: 121 });
+
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      inv!.give("parsnip-seed", 5);
+      const planting = engine.systems.get<PlantingSystem>("planting");
+      planting!.till(6, 6);
+      planting!.sow(6, 6, "parsnip-seed");
+      planting!.water(6, 6);
+      const day2 = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: { ticks: 359 } })));
+      expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+      const plot = engine.world.get("plot:6:6") as { daysInStage?: number };
+      expect(plot.daysInStage).toBe(1);
+
+      const invalid = await client.callTool({ name: "time.tick", arguments: { ticks: 0 } });
+      expect(invalid.isError).toBe(true);
+      expect(textOf(invalid)).toMatch(/ticks must be a positive integer/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("error paths: unknown tool, missing args, negative ticks", async () => {
+    const { client, close } = await setup();
+    try {
+      const unknown = await client.callTool({ name: "nope.nothing", arguments: {} });
+      expect(unknown.isError).toBe(true);
+      expect(textOf(unknown)).toMatch(/tool not found: nope.nothing/);
+
+      const missing = await client.callTool({ name: "time.advance", arguments: {} });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/missing required arguments: ticks/);
+
+      const negative = await client.callTool({ name: "time.advance", arguments: { ticks: -5 } });
+      expect(negative.isError).toBe(true);
+      expect(textOf(negative)).toMatch(/ticks must be a positive integer/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("resources: 34 template URIs, all readable, live state reflected", async () => {
+    const { client, close } = await setup();
+    try {
+      await client.callTool({ name: "time.advance", arguments: { ticks: 480 } });
+      await client.callTool({ name: "time.set", arguments: { day: 28, season: "spring" } });
+      await client.callTool({ name: "time.advance", arguments: { ticks: 480 } });
+
+      const resources = await client.listResources();
+      expect(resources.resources.map((r) => r.uri).sort()).toEqual(
+        ["animal://{id}", "animal://catalog", "animation://{id}", "animation://catalog", "behavior://statemachines", "behavior://triggers", "crop://{id}", "crop://catalog", "dialogue://{id}", "dialogue://catalog", "item://{id}", "item://catalog", "map://{mapId}", "mod://{id}", "npc://{npcId}", "particle://{id}", "particle://catalog", "placement://{id}", "placement://buildings", "placement://placed", "player://state", "rng://state", "save://state", "server://status", "shader://{id}", "shader://catalog", "shop://{shopId}", "shop://catalog", "sound://{id}", "sound://catalog", "sprite://{id}", "sprite://catalog", "time://state", "weather://state"].sort(),
+      );
+
+      const status = jsonOf(contentsOf(await client.readResource({ uri: "server://status" })));
+      expect(status.engine).toBe("nengine");
+
+      const map = jsonOf(contentsOf(await client.readResource({ uri: "map://farm" })));
+      expect(map.id).toBe("farm");
+
+      const time = jsonOf(contentsOf(await client.readResource({ uri: "time://state" })));
+      expect(time).toEqual({ day: 1, season: "summer", tickInDay: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("resources: missing template param rejects with a server error", async () => {
+    const { client, close } = await setup();
+    try {
+      await expect(client.readResource({ uri: "map://missing" })).rejects.toThrow(/map not found: missing/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("prompts: 15 authoring prompts, get returns guidance; missing prompt rejects", async () => {
+    const { client, close } = await setup();
+    try {
+      const prompts = await client.listPrompts();
+      expect(prompts.prompts.map((p) => p.name).sort()).toEqual([
+        "animal.create-prompt",
+        "animation.create-prompt",
+        "behavior.statemachine-prompt",
+        "behavior.trigger-prompt",
+        "crop.create-prompt",
+        "dialogue.create-prompt",
+        "item.create-prompt",
+        "particle.create-prompt",
+        "placement.create-prompt",
+        "save.authoring-prompt",
+        "server.orient",
+        "shader.create-prompt",
+        "shop.create-prompt",
+        "sound.create-prompt",
+        "sprite.create-prompt",
+      ]);
+
+      const orient = await client.getPrompt({ name: "server.orient", arguments: {} });
+      const text = orient.messages[0]?.content;
+      expect(text).toMatchObject({ type: "text" });
+      expect(String(text?.text)).toContain("nEngine MCP server");
+      expect(String(text?.text)).toContain("time.advance");
+      expect(String(text?.text)).toContain("content/maps/<id>.yaml");
+
+      const itemPrompt = await client.getPrompt({ name: "item.create-prompt", arguments: {} });
+      expect(String(itemPrompt.messages[0]?.content.text)).toContain("item.validate");
+      expect(String(itemPrompt.messages[0]?.content.text)).toContain("content/items/<id>.yaml");
+
+      const cropPrompt = await client.getPrompt({ name: "crop.create-prompt", arguments: {} });
+      expect(String(cropPrompt.messages[0]?.content.text)).toContain("crop.validate");
+      expect(String(cropPrompt.messages[0]?.content.text)).toContain("content/crops/<id>.yaml");
+      expect(String(cropPrompt.messages[0]?.content.text)).toContain("waterRequired");
+
+      const shopPrompt = await client.getPrompt({ name: "shop.create-prompt", arguments: {} });
+      expect(String(shopPrompt.messages[0]?.content.text)).toContain("shop.validate");
+      expect(String(shopPrompt.messages[0]?.content.text)).toContain("content/shops/<id>.yaml");
+      expect(String(shopPrompt.messages[0]?.content.text)).toContain("stock");
+
+      await expect(client.getPrompt({ name: "nope", arguments: {} })).rejects.toThrow();
+    } finally {
+      await close();
+    }
+  });
+
+  it("save: snapshot/validate/restore/migrate, save://state resource, and authoring prompt", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const snapshot = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} })));
+      expect(snapshot.format).toBe("nengine-save");
+      expect(snapshot.version).toBe(1);
+      expect(typeof snapshot.savedAt).toBe("string");
+      const entities = snapshot.world as { id: string; type: string; x: number; y: number; width: number; height: number }[];
+      expect(entities.find((e) => e.id === "farm")).toMatchObject({ type: "map", width: 12, height: 12 });
+      expect(entities.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 2, y: 2 });
+
+      const valid = jsonOf(textOf(await client.callTool({ name: "save.validate", arguments: { snapshot } })));
+      expect(valid).toEqual({ ok: true });
+
+      const invalid = jsonOf(
+        textOf(await client.callTool({ name: "save.validate", arguments: { snapshot: { format: "nengine-save" } } })),
+      );
+      expect(invalid.ok).toBe(false);
+      expect(Array.isArray(invalid.errors)).toBe(true);
+
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      inv!.give("parsnip-seed", 4);
+      const planting = engine.systems.get<PlantingSystem>("planting");
+      planting!.till(9, 9);
+      const mutated = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} })));
+      expect((mutated.world as { id: string }[]).some((e) => e.id === "plot:9:9")).toBe(true);
+
+      const restored = jsonOf(
+        textOf(await client.callTool({ name: "save.restore", arguments: { snapshot: mutated } })),
+      );
+      expect(restored).toEqual({ restored: 4 });
+
+      const after = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} })));
+      const afterEntities = after.world as { id: string; inventory?: Record<string, number> }[];
+      expect(afterEntities.some((e) => e.id === "plot:9:9")).toBe(true);
+      expect(afterEntities.find((e) => e.id === "player")?.inventory).toEqual({ "parsnip-seed": 4 });
+
+      const badRestore = await client.callTool({ name: "save.restore", arguments: { snapshot: { format: "nengine-save" } } });
+      expect(badRestore.isError).toBe(true);
+      expect(textOf(badRestore)).toMatch(/invalid snapshot/);
+
+      const migrated = jsonOf(
+        textOf(await client.callTool({ name: "save.migrate", arguments: { snapshot, fromVersion: 1, toVersion: 2 } })),
+      );
+      expect(migrated.world).toEqual(snapshot.world);
+
+      const state = jsonOf(contentsOf(await client.readResource({ uri: "save://state" })));
+      expect(state.format).toBe("nengine-save");
+      expect(state.version).toBe(1);
+
+      const prompt = await client.getPrompt({ name: "save.authoring-prompt", arguments: {} });
+      const promptText = String(prompt.messages[0]?.content.text);
+      expect(promptText).toContain("nengine-save");
+      expect(promptText).toContain("migration");
+      expect(promptText).toContain("save.snapshot");
+    } finally {
+      await close();
+    }
+  });
+
+  it("mod: list/status/reload over the real mods dir (example-mod), mod://{id} resource, no prompts", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const list = jsonOf(textOf(await client.callTool({ name: "mod.list", arguments: {} })));
+      expect(list.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]);
+
+      const status = jsonOf(textOf(await client.callTool({ name: "mod.status", arguments: { id: "example-mod" } })));
+      expect(status).toEqual({ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] });
+
+      const missing = await client.callTool({ name: "mod.status", arguments: { id: "nope" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/mod not found: nope/);
+
+      const singleMod = jsonOf(contentsOf(await client.readResource({ uri: "mod://example-mod" })));
+      expect(singleMod).toMatchObject({ id: "example-mod", version: "0.1.0", loaded: true });
+
+      const reloaded = jsonOf(textOf(await client.callTool({ name: "mod.reload", arguments: { id: "example-mod" } })));
+      expect(reloaded.reloaded).toBe(true);
+      expect(reloaded.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]);
+      expect(engine.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]);
+
+      const reloadAll = jsonOf(textOf(await client.callTool({ name: "mod.reload", arguments: {} })));
+      expect(reloadAll.mods).toHaveLength(1);
+      expect(reloadAll.mods[0].id).toBe("example-mod");
+
+      const prompts = await client.listPrompts();
+      expect(prompts.prompts.some((p) => p.name.startsWith("mod."))).toBe(false);
+    } finally {
+      await close();
+    }
+  });
+
+  it("placement: seeded building defs, place/list/occupies/remove, resources and prompt", async () => {
+    const { client, close } = await setup();
+    try {
+      const catalog = jsonOf(contentsOf(await client.readResource({ uri: "placement://buildings" })));
+      expect(catalog.buildings.map((b: { id: string }) => b.id)).toEqual(["fence", "shed"]);
+
+      const list = jsonOf(textOf(await client.callTool({ name: "placement.building.list", arguments: {} })));
+      expect(list.buildings).toEqual([
+        { id: "fence", name: "Fence", w: 1, h: 1, blocksMovement: true },
+        { id: "shed", name: "Shed", w: 2, h: 2, blocksMovement: true },
+      ]);
+
+      const placed = jsonOf(
+        textOf(await client.callTool({ name: "placement.place", arguments: { buildingId: "shed", x: 3, y: 3 } })),
+      );
+      expect(placed).toEqual({
+        id: "building:shed:3:3",
+        type: "building",
+        buildingId: "shed",
+        x: 3,
+        y: 3,
+        w: 2,
+        h: 2,
+        blocksMovement: true,
+      });
+
+      await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 0, y: 10 } });
+      const all = jsonOf(textOf(await client.callTool({ name: "placement.list", arguments: {} })));
+      expect(all.buildings).toHaveLength(2);
+
+      const inside = jsonOf(textOf(await client.callTool({ name: "placement.occupies", arguments: { x: 4, y: 3 } })));
+      expect(inside.id).toBe("building:shed:3:3");
+      const outside = jsonOf(textOf(await client.callTool({ name: "placement.occupies", arguments: { x: 11, y: 11 } })));
+      expect(outside).toBeNull();
+
+      const onWater = await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 0, y: 0 } });
+      expect(onWater.isError).toBe(true);
+      expect(textOf(onWater)).toMatch(/tile \(0,0\) is not buildable for fence/);
+
+      const overlap = await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 3, y: 3 } });
+      expect(overlap.isError).toBe(true);
+      expect(textOf(overlap)).toMatch(/overlaps existing building/);
+
+      const removed = jsonOf(textOf(await client.callTool({ name: "placement.remove", arguments: { x: 3, y: 3 } })));
+      expect(removed.id).toBe("building:shed:3:3");
+      const missing = await client.callTool({ name: "placement.remove", arguments: { x: 3, y: 3 } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/no building at \(3,3\)/);
+
+      const placedResource = jsonOf(contentsOf(await client.readResource({ uri: "placement://placed" })));
+      expect(placedResource.buildings).toHaveLength(1);
+      const shedDef = jsonOf(contentsOf(await client.readResource({ uri: "placement://shed" })));
+      expect(shedDef).toMatchObject({ id: "shed", footprint: { w: 2, h: 2 } });
+
+      const prompt = await client.getPrompt({ name: "placement.create-prompt", arguments: {} });
+      const promptText = String(prompt.messages[0]?.content.text);
+      expect(promptText).toContain("buildable");
+      expect(promptText).toContain("content/buildings/<id>.yaml");
+    } finally {
+      await close();
+    }
+  });
+
+  it("npc: spawn/list/get/move/teleport, npc-move event, blocking by building, resource, no prompts", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const spawned = jsonOf(textOf(await client.callTool({ name: "npc.spawn", arguments: { id: "leah", x: 2, y: 2 } })));
+      expect(spawned).toEqual({ id: "leah", type: "npc", x: 2, y: 2 });
+
+      const listed = jsonOf(textOf(await client.callTool({ name: "npc.list", arguments: {} })));
+      expect(listed.npcs).toEqual([{ id: "leah", x: 2, y: 2 }]);
+
+      const got = jsonOf(textOf(await client.callTool({ name: "npc.get", arguments: { npcId: "leah" } })));
+      expect(got).toMatchObject({ id: "leah", type: "npc", x: 2, y: 2 });
+
+      const moved = jsonOf(textOf(await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 4, y: 4 } })));
+      expect(moved).toEqual({ npcId: "leah", from: { x: 2, y: 2 }, to: { x: 4, y: 4 } });
+
+      const unknown = await client.callTool({ name: "npc.move", arguments: { npcId: "ghost", x: 0, y: 0 } });
+      expect(unknown.isError).toBe(true);
+      expect(textOf(unknown)).toMatch(/unknown npc: ghost/);
+
+      const onWater = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 0, y: 0 } });
+      expect(onWater.isError).toBe(true);
+      expect(textOf(onWater)).toMatch(/tile \(0,0\) is not walkable/);
+
+      const bounds = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 12, y: 0 } });
+      expect(bounds.isError).toBe(true);
+      expect(textOf(bounds)).toMatch(/target out of bounds: \(12,0\)/);
+
+      await client.callTool({ name: "placement.place", arguments: { buildingId: "shed", x: 8, y: 8 } });
+      const blocked = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 8, y: 8 } });
+      expect(blocked.isError).toBe(true);
+      expect(textOf(blocked)).toMatch(/blocked by building building:shed:8:8 at \(8,8\)/);
+
+      const teleported = jsonOf(
+        textOf(await client.callTool({ name: "npc.teleport", arguments: { npcId: "leah", x: 0, y: 0 } })),
+      );
+      expect(teleported).toEqual({ npcId: "leah", from: { x: 4, y: 4 }, to: { x: 0, y: 0 } });
+
+      const leah = jsonOf(contentsOf(await client.readResource({ uri: "npc://leah" })));
+      expect(leah).toMatchObject({ id: "leah", type: "npc", x: 0, y: 0 });
+      await expect(client.readResource({ uri: "npc://ghost" })).rejects.toThrow(/npc not found: ghost/);
+
+      const prompts = await client.listPrompts();
+      expect(prompts.prompts.some((p) => p.name.startsWith("npc."))).toBe(false);
+      expect(engine.world.get("leah")).toMatchObject({ x: 0, y: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("npc.path: A* path result, null reasons (blocked/out of bounds), and validation errors", async () => {
+    const { client, close } = await setup();
+    try {
+      await client.callTool({ name: "npc.spawn", arguments: { id: "leah", x: 4, y: 4 } });
+
+      const found = jsonOf(
+        textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 6, y: 6 } } })),
+      );
+      expect(found).toEqual({
+        npcId: "leah",
+        from: { x: 4, y: 4 },
+        to: { x: 6, y: 6 },
+        path: [
+          { x: 4, y: 4 },
+          { x: 5, y: 4 },
+          { x: 6, y: 4 },
+          { x: 6, y: 5 },
+          { x: 6, y: 6 },
+        ],
+      });
+
+      const onWater = jsonOf(
+        textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 0, y: 0 } } })),
+      );
+      expect(onWater).toEqual({ npcId: "leah", path: null, reason: "blocked" });
+
+      const bounds = jsonOf(
+        textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 12, y: 0 } } })),
+      );
+      expect(bounds).toEqual({ npcId: "leah", path: null, reason: "out of bounds" });
+
+      const unknown = await client.callTool({ name: "npc.path", arguments: { npcId: "ghost", to: { x: 5, y: 5 } } });
+      expect(unknown.isError).toBe(true);
+      expect(textOf(unknown)).toMatch(/movement: unknown npc: ghost/);
+
+      const blank = await client.callTool({ name: "npc.path", arguments: { npcId: "", to: { x: 5, y: 5 } } });
+      expect(blank.isError).toBe(true);
+      expect(textOf(blank)).toMatch(/npc\.path: npcId must be a non-empty string/);
+
+      const badTo = await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 1.5, y: 0 } } });
+      expect(badTo.isError).toBe(true);
+      expect(textOf(badTo)).toMatch(/npc\.path: to must have integer x and y/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("animal: seeded def, list/get, spawn/feed/stage debug tools, resources and prompt", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const catalog = jsonOf(contentsOf(await client.readResource({ uri: "animal://catalog" })));
+      expect(catalog.animals).toHaveLength(1);
+      const chicken = catalog.animals.find((a: { id: string }) => a.id === "chicken");
+      expect(chicken).toMatchObject({
+        id: "chicken",
+        stages: [
+          { name: "chick", days: 2, feedRequired: false },
+          { name: "grown", days: 2, feedRequired: true },
+          { name: "laying", days: 1, feedRequired: true },
+        ],
+        produces: [{ itemId: "egg", qty: 1 }],
+      });
+
+      const list = jsonOf(textOf(await client.callTool({ name: "animal.list", arguments: {} })));
+      expect(list.animals).toEqual([{ id: "chicken", stageCount: 3 }]);
+
+      const got = jsonOf(textOf(await client.callTool({ name: "animal.get", arguments: { animalId: "chicken" } })));
+      expect(got).toMatchObject({ id: "chicken", produces: [{ itemId: "egg", qty: 1 }] });
+
+      const missing = await client.callTool({ name: "animal.get", arguments: { animalId: "cow" } });
+      expect(missing.isError).toBe(true);
+      expect(textOf(missing)).toMatch(/animal not found: cow/);
+
+      const spawned = jsonOf(textOf(await client.callTool({ name: "animal.spawn", arguments: { animalId: "chicken", x: 3, y: 3 } })));
+      expect(spawned).toMatchObject({
+        id: "animal:chicken:1",
+        type: "animal",
+        animalId: "chicken",
+        x: 3,
+        y: 3,
+        stageIndex: 0,
+        fed: false,
+      });
+
+      const fed = jsonOf(textOf(await client.callTool({ name: "animal.feed", arguments: { id: "animal:chicken:1" } })));
+      expect(fed).toEqual({ id: "animal:chicken:1", animalId: "chicken", fed: true });
+
+      const staged = jsonOf(
+        textOf(await client.callTool({ name: "animal.stage", arguments: { id: "animal:chicken:1", stageIndex: 2 } })),
+      );
+      expect(staged).toEqual({
+        id: "animal:chicken:1",
+        animalId: "chicken",
+        animalDefId: "chicken",
+        stageIndex: 2,
+        stage: "laying",
+      });
+
+      const entities = jsonOf(textOf(await client.callTool({ name: "animal.entities", arguments: {} })));
+      expect(entities.animals).toHaveLength(1);
+      expect(entities.animals[0].id).toBe("animal:chicken:1");
+
+      const unknownSpawn = await client.callTool({ name: "animal.spawn", arguments: { animalId: "cow", x: 0, y: 0 } });
+      expect(unknownSpawn.isError).toBe(true);
+      expect(textOf(unknownSpawn)).toMatch(/unknown animal def: cow/);
+
+      const singleAnimal = jsonOf(contentsOf(await client.readResource({ uri: "animal://chicken" })));
+      expect(singleAnimal.id).toBe("chicken");
+      await expect(client.readResource({ uri: "animal://ghost" })).rejects.toThrow(/animal not found: ghost/);
+
+      const prompt = await client.getPrompt({ name: "animal.create-prompt", arguments: {} });
+      const promptText = String(prompt.messages[0]?.content.text);
+      expect(promptText).toContain("feedRequired");
+      expect(promptText).toContain("content/animals/<id>.yaml");
+    } finally {
+      await close();
+    }
+  });
+
+  it("player: intent drives the player entity, player://state reads it", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const state = jsonOf(contentsOf(await client.readResource({ uri: "player://state" })));
+      expect(state).toMatchObject({ id: "player", type: "player", x: 2, y: 2 });
+
+      const moved = jsonOf(
+        textOf(await client.callTool({ name: "player.intent", arguments: { action: "move", tile: { x: 3, y: 2 } } })),
+      );
+      expect(moved).toMatchObject({ action: "move", tile: { x: 3, y: 2 }, ok: true, detail: "moved to (3,2)" });
+      expect(engine.world.get("player")).toMatchObject({ x: 3, y: 2 });
+
+      const tilled = jsonOf(
+        textOf(await client.callTool({ name: "player.intent", arguments: { action: "interact", tile: { x: 5, y: 5 } } })),
+      );
+      expect(tilled).toMatchObject({ action: "interact", ok: true, detail: "tilled plot" });
+      expect(engine.world.get("plot:5:5")).toMatchObject({ type: "plot", x: 5, y: 5, tilled: true });
+
+      const onWater = await client.callTool({ name: "player.intent", arguments: { action: "move", tile: { x: 0, y: 0 } } });
+      expect(onWater.isError).toBe(true);
+      expect(textOf(onWater)).toMatch(/player\.intent: intent\.move: tile \(0,0\) is not walkable/);
+
+      const badAction = await client.callTool({
+        name: "player.intent",
+        arguments: { action: "dance", tile: { x: 1, y: 1 } },
+      });
+      expect(badAction.isError).toBe(true);
+      expect(textOf(badAction)).toMatch(
+        /player\.intent: action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"/,
+      );
+
+      const badTile = await client.callTool({
+        name: "player.intent",
+        arguments: { action: "move", tile: { x: -1, y: 0 } },
+      });
+      expect(badTile.isError).toBe(true);
+      expect(textOf(badTile)).toMatch(/player\.intent: tile x and y must be non-negative integers/);
+
+      const movedState = jsonOf(contentsOf(await client.readResource({ uri: "player://state" })));
+      expect(movedState).toMatchObject({ x: 3, y: 2 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("player.intent buy/sell/talk/choose/select-seed drive the extended actions through the real server", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const player = engine.world.get("player");
+      if (!player) throw new Error("player entity not seeded");
+      player.gold = 100;
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      if (!inv) throw new Error("inventory system not booted");
+
+      const noActive = await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 0 } });
+      expect(noActive.isError).toBe(true);
+      expect(textOf(noActive)).toMatch(/no active dialogue/);
+
+      const bought = jsonOf(
+        textOf(
+          await client.callTool({
+            name: "player.intent",
+            arguments: { action: "buy", shopId: "general-store", itemId: "parsnip-seed", qty: 2 },
+          }),
+        ),
+      );
+      expect(bought.detail).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 });
+      expect(inv.count("parsnip-seed")).toBe(2);
+
+      const missingItem = await client.callTool({ name: "player.intent", arguments: { action: "buy", shopId: "general-store" } });
+      expect(missingItem.isError).toBe(true);
+      expect(textOf(missingItem)).toMatch(/buy requires itemId/);
+
+      const missingShop = await client.callTool({ name: "player.intent", arguments: { action: "buy", itemId: "parsnip-seed" } });
+      expect(missingShop.isError).toBe(true);
+      expect(textOf(missingShop)).toMatch(/buy requires shopId/);
+
+      const badQty = await client.callTool({
+        name: "player.intent",
+        arguments: { action: "buy", shopId: "general-store", itemId: "parsnip-seed", qty: 0 },
+      });
+      expect(badQty.isError).toBe(true);
+      expect(textOf(badQty)).toMatch(/qty must be a positive integer/);
+
+      inv.give("parsnip", 3);
+      const sold = jsonOf(
+        textOf(
+          await client.callTool({
+            name: "player.intent",
+            arguments: { action: "sell", shopId: "general-store", itemId: "parsnip", qty: 1 },
+          }),
+        ),
+      );
+      expect(sold.detail).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 1, total: 30, gold: 90 });
+      expect(inv.count("parsnip")).toBe(2);
+
+      const broke = await client.callTool({
+        name: "player.intent",
+        arguments: { action: "buy", shopId: "general-store", itemId: "hoe", qty: 1 },
+      });
+      expect(broke.isError).toBe(true);
+      expect(textOf(broke)).toMatch(/insufficient gold/);
+
+      engine.world.create({ id: "npc:clerk", type: "npc", x: 3, y: 3, dialogue: "welcome" });
+      const talked = jsonOf(
+        textOf(await client.callTool({ name: "player.intent", arguments: { action: "talk", npcId: "npc:clerk" } })),
+      );
+      expect(talked.detail).toMatchObject({ dialogueId: "welcome", nodeId: "hello" });
+
+      const chosen = jsonOf(
+        textOf(await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 0 } })),
+      );
+      expect(chosen.detail).toMatchObject({ dialogueId: "welcome", nodeId: "seed-tip" });
+
+      const outOfRange = await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 9 } });
+      expect(outOfRange.isError).toBe(true);
+      expect(textOf(outOfRange)).toMatch(/choice index out of range/);
+
+      const selected = jsonOf(
+        textOf(
+          await client.callTool({ name: "player.intent", arguments: { action: "select-seed", seedItemId: "parsnip-seed" } }),
+        ),
+      );
+      expect(selected.detail).toEqual({ selectedSeed: "parsnip-seed" });
+      expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" });
+
+      const notASeed = await client.callTool({ name: "player.intent", arguments: { action: "select-seed", seedItemId: "hoe" } });
+      expect(notASeed.isError).toBe(true);
+      expect(textOf(notASeed)).toMatch(/hoe is not a seed item/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("rng: get/set the engine RNG, determinism through MCP, rng://state resource, no prompts", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const initial = jsonOf(textOf(await client.callTool({ name: "rng.get", arguments: {} })));
+      expect(initial.seed).toEqual(engine.rng.state().seed);
+      expect(initial.calls).toBe(0);
+
+      engine.rng.next();
+      const afterDraw = jsonOf(textOf(await client.callTool({ name: "rng.get", arguments: {} })));
+      expect(afterDraw.calls).toBe(1);
+
+      const set = jsonOf(textOf(await client.callTool({ name: "rng.set", arguments: { seed: 2026 } })));
+      expect(set).toEqual({ seed: 2026, calls: 0 });
+
+      const first = engine.rng.next();
+      const second = engine.rng.next();
+      expect(first).toBeGreaterThanOrEqual(0);
+      expect(second).toBeGreaterThanOrEqual(0);
+      const state = jsonOf(contentsOf(await client.readResource({ uri: "rng://state" })));
+      expect(state).toEqual({ seed: 2026, calls: 2 });
+
+      const badSeed = await client.callTool({ name: "rng.set", arguments: { seed: Infinity } });
+      expect(badSeed.isError).toBe(true);
+      expect(textOf(badSeed)).toMatch(/seed must be a finite number/);
+
+      const prompts = await client.listPrompts();
+      expect(prompts.prompts.some((p) => p.name.startsWith("rng."))).toBe(false);
+    } finally {
+      await close();
+    }
+  });
+
+  it("animation.*: lifecycle validate + empty catalog over the root content dir (no writes)", async () => {
+    const { client, close } = await setup();
+    try {
+      const animationList = jsonOf(textOf(await client.callTool({ name: "animation.list", arguments: {} })));
+      expect(animationList).toEqual({ animations: [] });
+
+      const missingAnimation = await client.callTool({ name: "animation.get", arguments: { animationId: "player" } });
+      expect(missingAnimation.isError).toBe(true);
+      expect(textOf(missingAnimation)).toMatch(/animation not found: player/);
+
+      const validAnimation = jsonOf(
+        textOf(await client.callTool({
+          name: "animation.validate",
+          arguments: {
+            def: {
+              id: "player",
+              initial: "idle",
+              states: [
+                { id: "idle", spriteRef: "player", loop: true },
+                { id: "walk", spriteRef: "player-walk", fps: 8 },
+              ],
+            },
+          },
+        })),
+      );
+      expect(validAnimation).toEqual({
+        id: "player",
+        initial: "idle",
+        states: [
+          { id: "idle", spriteRef: "player", loop: true },
+          { id: "walk", spriteRef: "player-walk", fps: 8 },
+        ],
+      });
+
+      const badAnimation = await client.callTool({
+        name: "animation.validate",
+        arguments: { def: { id: "bad", initial: "nope", states: [{ id: "idle", spriteRef: "player" }] } },
+      });
+      expect(badAnimation.isError).toBe(true);
+      expect(textOf(badAnimation)).toMatch(/initial state must be a declared state/);
+
+      const animationCatalog = jsonOf(contentsOf(await client.readResource({ uri: "animation://catalog" })));
+      expect(animationCatalog).toEqual({ animations: [] });
+      await expect(client.readResource({ uri: "animation://missing" })).rejects.toThrow(/animation not found: missing/);
+
+      const animationPrompt = await client.getPrompt({ name: "animation.create-prompt", arguments: {} });
+      expect(String(animationPrompt.messages[0]?.content.text)).toContain("content/animations/<id>.yaml");
+    } finally {
+      await close();
+    }
+  });
+
+  it("particle.* and shader.*: lifecycle validate + empty catalogs over the root content dir (no writes)", async () => {
+    const { client, close } = await setup();
+    try {
+      const particleList = jsonOf(textOf(await client.callTool({ name: "particle.list", arguments: {} })));
+      expect(particleList).toEqual({ particles: [] });
+
+      const missingParticle = await client.callTool({ name: "particle.get", arguments: { particleId: "dust" } });
+      expect(missingParticle.isError).toBe(true);
+      expect(textOf(missingParticle)).toMatch(/particle not found: dust/);
+
+      const validParticle = jsonOf(
+        textOf(await client.callTool({ name: "particle.validate", arguments: { def: { id: "dust", colors: ["#d7ccc8"] } } })),
+      );
+      expect(validParticle).toEqual({ id: "dust", colors: ["#d7ccc8"] });
+
+      const badParticle = await client.callTool({
+        name: "particle.validate",
+        arguments: { def: { id: "bad", colors: ["green"] } },
+      });
+      expect(badParticle.isError).toBe(true);
+      expect(textOf(badParticle)).toMatch(/colors/);
+
+      const shaderList = jsonOf(textOf(await client.callTool({ name: "shader.list", arguments: {} })));
+      expect(shaderList).toEqual({ shaders: [] });
+
+      const missingShader = await client.callTool({ name: "shader.get", arguments: { shaderId: "rain-tint" } });
+      expect(missingShader.isError).toBe(true);
+      expect(textOf(missingShader)).toMatch(/shader not found: rain-tint/);
+
+      const validShader = jsonOf(
+        textOf(await client.callTool({ name: "shader.validate", arguments: { def: { id: "rain-tint", filter: "grayscale", params: { factor: 0.7 } } } })),
+      );
+      expect(validShader).toEqual({ id: "rain-tint", filter: "grayscale", params: { factor: 0.7 } });
+
+      const badShader = await client.callTool({ name: "shader.validate", arguments: { def: { id: "bad", filter: "vignette" } } });
+      expect(badShader.isError).toBe(true);
+      expect(textOf(badShader)).toMatch(/filter/);
+
+      const particleCatalog = jsonOf(contentsOf(await client.readResource({ uri: "particle://catalog" })));
+      expect(particleCatalog).toEqual({ particles: [] });
+      const shaderCatalog = jsonOf(contentsOf(await client.readResource({ uri: "shader://catalog" })));
+      expect(shaderCatalog).toEqual({ shaders: [] });
+      await expect(client.readResource({ uri: "particle://missing" })).rejects.toThrow(/particle not found: missing/);
+      await expect(client.readResource({ uri: "shader://missing" })).rejects.toThrow(/shader not found: missing/);
+
+      const particlePrompt = await client.getPrompt({ name: "particle.create-prompt", arguments: {} });
+      expect(String(particlePrompt.messages[0]?.content.text)).toContain("content/particles/<id>.yaml");
+      const shaderPrompt = await client.getPrompt({ name: "shader.create-prompt", arguments: {} });
+      expect(String(shaderPrompt.messages[0]?.content.text)).toContain("content/shaders/<id>.yaml");
+    } finally {
+      await close();
+    }
+  });
+});

+ 124 - 0
apps/mcp-server/test/roundtrip.test.ts

@@ -0,0 +1,124 @@
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine } from "@nengine/core";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { createNengineMcpServer, createGateway } from "../src/server.js";
+
+const repoRoot = resolve(process.cwd(), "../..");
+const contentDir = resolve(repoRoot, "content");
+const modsDir = resolve(repoRoot, "mods");
+
+interface Roundtrip {
+  engine: ReturnType<typeof createEngine>;
+  server: ReturnType<typeof createNengineMcpServer>;
+  client: Client;
+  close: () => Promise<void>;
+}
+
+async function setup(gateway?: ReturnType<typeof createGateway>): Promise<Roundtrip> {
+  const engine = createEngine({ contentDir, modsDir });
+  await engine.start();
+  const server = gateway ? createNengineMcpServer(engine, gateway) : createNengineMcpServer(engine);
+  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+  await server.connect(serverTransport);
+  const client = new Client({ name: "roundtrip-test", version: "0.0.1" });
+  await client.connect(clientTransport);
+  return {
+    engine,
+    server,
+    client,
+    close: async () => {
+      await client.close();
+      await server.close();
+      await engine.stop();
+    },
+  };
+}
+
+function textOf(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.find((block) => block.type === "text")?.text ?? "";
+}
+
+function contentsOf(result: { contents: { text?: string }[] }): string {
+  return result.contents[0]?.text ?? "";
+}
+
+describe("apps/mcp-server SDK roundtrip (in-memory transport)", () => {
+  it("initialize, listTools, callTool, resources, and prompts roundtrip", async () => {
+    const { client, close } = await setup();
+    try {
+      const tools = await client.listTools();
+      const toolNames = tools.tools.map((tool) => tool.name);
+      expect(toolNames).toEqual(
+        expect.arrayContaining([
+          "server.info",
+          "server.status",
+          "map.list",
+          "map.get",
+          "time.get",
+          "time.advance",
+          "time.set",
+        ]),
+      );
+
+      const result = await client.callTool({ name: "map.get", arguments: {} });
+      expect(result.isError).toBeUndefined();
+      const map = JSON.parse(textOf(result)) as { id: string; player: { x: number; y: number } };
+      expect(map.id).toBe("farm");
+      expect(map.player.x).toBe(2);
+
+      const advanced = await client.callTool({ name: "time.advance", arguments: { ticks: 480 } });
+      const time = JSON.parse(textOf(advanced)) as { day: number };
+      expect(time.day).toBe(2);
+
+      const resources = await client.listResources();
+      expect(resources.resources.map((resource) => resource.uri)).toEqual(
+        expect.arrayContaining(["server://status", "time://state", "map://{mapId}"]),
+      );
+      const state = await client.readResource({ uri: "time://state" });
+      expect(JSON.parse(contentsOf(state))).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+
+      const prompts = await client.listPrompts();
+      expect(prompts.prompts.map((prompt) => prompt.name)).toContain("server.orient");
+      const orient = await client.getPrompt({ name: "server.orient", arguments: {} });
+      expect(orient.messages[0]?.content).toMatchObject({ type: "text", text: expect.stringContaining("nEngine MCP server") });
+    } finally {
+      await close();
+    }
+  });
+
+  it("disabled namespaces are absent from tools/list and fail on tools/call", async () => {
+    const engine = createEngine({ contentDir, modsDir });
+    await engine.start();
+    const gateway = createGateway(engine);
+    gateway.disable("time");
+    const server = createNengineMcpServer(engine, gateway);
+    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+    await server.connect(serverTransport);
+    const client = new Client({ name: "roundtrip-test", version: "0.0.1" });
+    await client.connect(clientTransport);
+    try {
+      const tools = await client.listTools();
+      expect(tools.tools.some((tool) => tool.name.startsWith("time."))).toBe(false);
+      expect(tools.tools.some((tool) => tool.name === "map.get")).toBe(true);
+
+      const rejected = await client.callTool({ name: "time.get", arguments: {} });
+      expect(rejected.isError).toBe(true);
+      expect(textOf(rejected)).toMatch(/namespace "time" is disabled/);
+    } finally {
+      await client.close();
+      await server.close();
+      await engine.stop();
+    }
+  });
+
+  it("surfaces resource errors over the wire", async () => {
+    const { client, close } = await setup();
+    try {
+      await expect(client.readResource({ uri: "map://does-not-exist" })).rejects.toThrow(/map not found/);
+    } finally {
+      await close();
+    }
+  });
+});

+ 470 - 0
apps/mcp-server/test/sample-game.test.ts

@@ -0,0 +1,470 @@
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine, type IntentSystem, type InventorySystem, type PlantingSystem } from "@nengine/core";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { DEFAULT_DIRS, resolveDirs } from "../src/index.js";
+import { createNengineMcpServer, createGateway } from "../src/server.js";
+
+const repoRoot = resolve(process.cwd(), "../..");
+const sampleGameDir = resolve(repoRoot, "examples", "sample-game");
+const contentDir = resolve(sampleGameDir, "content");
+const modsDir = resolve(sampleGameDir, "mods");
+
+interface Harness {
+  engine: ReturnType<typeof createEngine>;
+  client: Client;
+  close: () => Promise<void>;
+}
+
+async function setup(): Promise<Harness> {
+  const dirs = resolveDirs({ argv: ["--content-dir", contentDir, "--mods-dir", modsDir], env: {}, cwd: repoRoot });
+  const engine = createEngine(dirs);
+  await engine.start();
+  const gateway = createGateway(engine);
+  const server = createNengineMcpServer(engine, gateway);
+  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+  await server.connect(serverTransport);
+  const client = new Client({ name: "sample-game-test", version: "0.0.1" });
+  await client.connect(clientTransport);
+  return {
+    engine,
+    client,
+    close: async () => {
+      await client.close();
+      await server.close();
+      await engine.stop();
+    },
+  };
+}
+
+function textOf(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.find((block) => block.type === "text")?.text ?? "";
+}
+
+function jsonOf(text: string): Record<string, any> {
+  return JSON.parse(text) as Record<string, any>;
+}
+
+async function callTool(client: Client, name: string, arguments_: Record<string, unknown>): Promise<Record<string, any>> {
+  const result = await client.callTool({ name, arguments: arguments_ });
+  if (result.isError) {
+    throw new Error(`tool ${name} failed: ${textOf(result)}`);
+  }
+  return jsonOf(textOf(result));
+}
+
+describe("apps/mcp-server resolveDirs (parameterized entry)", () => {
+  it("defaults to the monorepo content/mods when nothing is provided", () => {
+    expect(resolveDirs({ argv: [], env: {}, cwd: repoRoot })).toEqual(DEFAULT_DIRS);
+  });
+
+  it("resolves CLI paths relative to cwd", () => {
+    const dirs = resolveDirs({
+      argv: ["--content-dir", "examples/sample-game/content", "--mods-dir", "examples/sample-game/mods"],
+      env: {},
+      cwd: repoRoot,
+    });
+    expect(dirs.contentDir).toBe(contentDir);
+    expect(dirs.modsDir).toBe(modsDir);
+  });
+
+  it("reads env vars and lets CLI win over env, env win over default", () => {
+    const envDirs = resolveDirs({
+      argv: [],
+      env: { NENGINE_CONTENT_DIR: "env-content", NENGINE_MODS_DIR: "env-mods" },
+      cwd: repoRoot,
+    });
+    expect(envDirs.contentDir).toBe(resolve(repoRoot, "env-content"));
+    expect(envDirs.modsDir).toBe(resolve(repoRoot, "env-mods"));
+
+    const cliWins = resolveDirs({
+      argv: ["--content-dir", "cli-content"],
+      env: { NENGINE_CONTENT_DIR: "env-content" },
+      cwd: repoRoot,
+    });
+    expect(cliWins.contentDir).toBe(resolve(repoRoot, "cli-content"));
+    expect(cliWins.modsDir).toBe(DEFAULT_DIRS.modsDir);
+  });
+
+  it("leaves absolute CLI paths untouched", () => {
+    const dirs = resolveDirs({ argv: ["--content-dir", "/abs/content"], env: {}, cwd: repoRoot });
+    expect(dirs.contentDir).toBe("/abs/content");
+  });
+
+  it("resolves the optional ws port via CLI --ws-port / env NENGINE_WS_PORT / default", () => {
+    const viaCli = resolveDirs({ argv: ["--ws-port", "8787"], env: {}, cwd: repoRoot });
+    expect(viaCli.wsPort).toBe(8787);
+
+    const viaEnv = resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
+    expect(viaEnv.wsPort).toBe(9000);
+
+    const cliWins = resolveDirs({ argv: ["--ws-port", "8787"], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
+    expect(cliWins.wsPort).toBe(8787);
+
+    const unset = resolveDirs({ argv: [], env: {}, cwd: repoRoot });
+    expect(unset.wsPort).toBeUndefined();
+    expect(resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "" }, cwd: repoRoot }).wsPort).toBeUndefined();
+  });
+
+  it("rejects invalid ws port values", () => {
+    for (const bad of ["abc", "0", "-1", "8787.5", "70000"]) {
+      expect(() => resolveDirs({ argv: ["--ws-port", bad], env: {}, cwd: repoRoot })).toThrow(/invalid ws port/);
+    }
+  });
+});
+
+describe("examples/sample-game end-to-end through the parameterized entry", () => {
+  it("loads every content kind and server.info reflects the sample game", async () => {
+    const { client, close } = await setup();
+    try {
+      const info = await callTool(client, "server.info", {});
+      expect(info.content).toEqual({
+        animal: ["chicken"],
+        animation: ["player"],
+        behavior: ["starter-gift"],
+        building: ["coop", "fence", "shop"],
+        crop: ["carrot", "parsnip"],
+        dialogue: ["mayor"],
+        interact: ["default"],
+        map: ["farm"],
+        time: ["default"],
+        item: ["carrot-seed", "carrot", "egg", "fish", "hoe", "parsnip-seed", "parsnip", "watering-can"],
+        particle: ["dust", "rain", "sparkle", "wind"],
+        shader: ["rain-tint", "sparkle-glow"],
+        shop: ["general-store"],
+        sound: ["coin", "harvest", "till", "water"],
+        sprite: ["carrot-grown", "carrot-mature", "carrot-sprout", "chicken-chick", "chicken-grown", "chicken-laying", "coop", "fence", "parsnip-grown", "parsnip-mature", "parsnip-sprout", "player-walk", "player", "shop"],
+        statemachine: ["carrot-quest", "chicken-egg", "daily-stamina", "fishing-treat", "mayor-routine", "mayor-talk-quest", "starter-quest"],
+        weather: ["default"],
+      });
+      expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
+
+      const map = await callTool(client, "map.get", { mapId: "farm" });
+      expect(map.id).toBe("farm");
+      expect(map.width).toBe(16);
+      expect(map.height).toBe(16);
+    } finally {
+      await close();
+    }
+  });
+
+  it("loads the sample-game binary assets into the engine asset table (D31)", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      expect(engine.assets.size).toBe(21);
+      expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" });
+      expect(engine.assets.get("water-1")).toMatchObject({ kind: "image", mime: "image/png" });
+      expect(engine.assets.get("tilled-1")).toMatchObject({ kind: "image", mime: "image/png" });
+      expect(engine.assets.get("plant-sprout")).toMatchObject({ kind: "image" });
+      expect(engine.assets.get("coop-1")).toMatchObject({ kind: "image" });
+      expect(engine.assets.get("fence-1")).toMatchObject({ kind: "image" });
+      for (const id of ["click-1", "confirm-1", "error-1", "drop-1", "pluck-1"]) {
+        expect(engine.assets.get(id)).toMatchObject({ kind: "audio", mime: "audio/ogg" });
+      }
+      const info = await callTool(client, "server.info", {});
+      expect(info.content.sound).toEqual(["coin", "harvest", "till", "water"]);
+    } finally {
+      await close();
+    }
+  });
+
+  it("plays the full gameplay arc: gift, grow, harvest, sell, buy, build, chicken, egg, quest, dialogue", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      const planting = engine.systems.get<PlantingSystem>("planting");
+      if (!inv || !planting) throw new Error("systems not booted");
+      const player = () => engine.world.get("player");
+
+      expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [] });
+      expect(inv.items()).toEqual({});
+
+      const day2 = await callTool(client, "time.advance", { ticks: 480 });
+      expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+
+      expect(inv.items()).toEqual({ "parsnip-seed": 3, "carrot-seed": 2 });
+      expect(player()?.flag).toEqual({ "starter-gift": "given" });
+
+      expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [{ id: "npc:mayor", x: 6, y: 5 }] });
+      expect(engine.world.get("mod:sample-stats")).toMatchObject({ type: "mod-state", greetings: 1 });
+
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
+        machineId: "starter-quest",
+        state: "active",
+        started: true,
+      });
+
+      const weather = await callTool(client, "weather.get", {});
+      expect(["sunny", "rain", "wind", "storm"]).toContain(weather.weatherId);
+
+      const plots: [number, number][] = [
+        [6, 4],
+        [7, 4],
+        [8, 4],
+      ];
+      for (const [x, y] of plots) {
+        planting.till(x, y);
+        planting.sow(x, y, "parsnip-seed");
+        await ensureWatered(engine, client, x, y);
+      }
+
+      await callTool(client, "time.tick", { ticks: 480 });
+      await callTool(client, "time.tick", { ticks: 480 });
+      for (const [x, y] of plots) {
+        await ensureWatered(engine, client, x, y);
+      }
+      await callTool(client, "time.tick", { ticks: 480 });
+      await callTool(client, "time.tick", { ticks: 480 });
+      for (const [x, y] of plots) {
+        expect(engine.world.get(`plot:${x}:${y}`)).toMatchObject({ stageIndex: 2 });
+      }
+
+      for (const [x, y] of plots) {
+        planting.harvest(x, y);
+      }
+      expect(inv.count("parsnip")).toBe(3);
+      expect(inv.count("parsnip-seed")).toBe(2);
+      expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
+        machineId: "starter-quest",
+        state: "complete",
+        started: true,
+      });
+
+      expect(
+        await callTool(client, "shop.sell", { shopId: "general-store", itemId: "parsnip", qty: 3 }),
+      ).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 3, total: 90, gold: 90 });
+      expect(
+        await callTool(client, "shop.buy", { shopId: "general-store", itemId: "carrot-seed", qty: 1 }),
+      ).toEqual({ shopId: "general-store", itemId: "carrot-seed", qty: 1, total: 25, gold: 65 });
+
+      expect(await callTool(client, "placement.place", { buildingId: "coop", x: 10, y: 4 })).toMatchObject({
+        id: "building:coop:10:4",
+        type: "building",
+        buildingId: "coop",
+        x: 10,
+        y: 4,
+        w: 3,
+        h: 3,
+        blocksMovement: true,
+      });
+      expect(await callTool(client, "placement.place", { buildingId: "fence", x: 9, y: 8 })).toMatchObject({
+        id: "building:fence:9:8",
+      });
+
+      expect(await callTool(client, "animal.spawn", { animalId: "chicken", x: 9, y: 3 })).toMatchObject({
+        id: "animal:chicken:1",
+        type: "animal",
+        animalId: "chicken",
+        stageIndex: 0,
+      });
+      expect(await callTool(client, "animal.stage", { id: "animal:chicken:1", stageIndex: 2 })).toMatchObject({
+        stageIndex: 2,
+        stage: "laying",
+      });
+      expect(await callTool(client, "animal.feed", { id: "animal:chicken:1" })).toEqual({
+        id: "animal:chicken:1",
+        animalId: "chicken",
+        fed: true,
+      });
+
+      await callTool(client, "time.tick", { ticks: 480 });
+      expect((await callTool(client, "item.query_inventory", {})).inventory.egg).toBe(1);
+      expect(engine.world.get("mod:sample-stats")).toMatchObject({ greetings: 6 });
+      expect(await callTool(client, "npc.get", { npcId: "npc:mayor" })).toMatchObject({
+        id: "npc:mayor",
+        type: "npc",
+        name: "Mayor",
+        x: 6,
+        y: 6,
+      });
+
+      const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
+      expect(started.nodeId).toBe("greeting");
+      expect(started.choices.map((choice: { text: string }) => choice.text)).toEqual([
+        "Any farming tips?",
+        "Tell me about chickens.",
+        "Got anything for me?",
+        "I am just passing through.",
+      ]);
+
+      const rewarded = await callTool(client, "dialogue.choose", { index: 2 });
+      expect(rewarded.nodeId).toBe("quest-reward");
+      expect(inv.count("egg")).toBe(3);
+
+      const farewell = await callTool(client, "dialogue.choose", { index: 0 });
+      expect(farewell.nodeId).toBe("farewell");
+      expect(farewell.choices).toEqual([]);
+    } finally {
+      await close();
+    }
+  });
+});
+
+describe("examples/sample-game Tier 3 content (D29): quest chain, stamina/skills, fishing minigame", () => {
+  it("chains the quests: starter-quest complete → carrot-quest active → harvest carrots → complete + reward", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      if (!inv) throw new Error("inventory system not booted");
+      const player = () => engine.world.get("player");
+
+      await callTool(client, "time.advance", { ticks: 480 }); // day 2: starter gift + starter-quest active
+      await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2);
+      expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
+
+      await callTool(client, "time.tick", { ticks: 480 }); // next day-start activates carrot-quest
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
+        machineId: "carrot-quest",
+        state: "active",
+        started: true,
+      });
+
+      const carrotSeedBefore = inv.count("carrot-seed");
+      await growAndHarvest(engine, client, [[6, 4]], "carrot-seed", 2, 3);
+      expect(player()?.questProgress).toEqual({ "starter-quest": "complete", "carrot-quest": "complete" });
+      expect(inv.count("carrot")).toBe(1);
+      expect(inv.count("carrot-seed")).toBe(carrotSeedBefore + 1); // -1 sown, +2 quest reward
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
+        machineId: "carrot-quest",
+        state: "complete",
+        started: true,
+      });
+    } finally {
+      await close();
+    }
+  });
+
+  it("completes mayor-talk-quest by finishing the mayor dialogue and gates a new dialogue branch", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      if (!inv) throw new Error("inventory system not booted");
+      const player = () => engine.world.get("player");
+
+      await callTool(client, "time.advance", { ticks: 480 });
+      await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2); // starter-quest complete
+      await callTool(client, "time.tick", { ticks: 480 }); // carrot-quest active
+      await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3); // carrot-quest complete
+      await callTool(client, "time.tick", { ticks: 480 }); // mayor-talk-quest active
+
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
+        machineId: "mayor-talk-quest",
+        state: "active",
+        started: true,
+      });
+
+      const eggBefore = inv.count("egg");
+      const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
+      expect(started.choices.map((choice: { text: string }) => choice.text)).not.toContain("Tell me about fishing.");
+      await callTool(client, "dialogue.choose", { index: started.choices.length - 1 }); // farewell → dialogue-end
+
+      expect(player()?.questProgress).toEqual({
+        "starter-quest": "complete",
+        "carrot-quest": "complete",
+        "mayor-talk-quest": "complete",
+      });
+      expect(inv.count("egg")).toBe(eggBefore + 1);
+      expect(player()?.gold).toBe(100);
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
+        machineId: "mayor-talk-quest",
+        state: "complete",
+        started: true,
+      });
+
+      const again = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
+      expect(again.choices.map((choice: { text: string }) => choice.text)).toContain("Tell me about fishing.");
+    } finally {
+      await close();
+    }
+  });
+
+  it("fishes at the pond via IntentSystem: fish gained, stamina drained, skill incremented, mod event + treat reward", async () => {
+    const { engine, client, close } = await setup();
+    try {
+      const inv = engine.systems.get<InventorySystem>("inventory");
+      const intent = engine.systems.get<IntentSystem>("intent");
+      if (!inv || !intent) throw new Error("systems not booted");
+      const player = () => engine.world.get("player");
+
+      const caught: unknown[] = [];
+      engine.events.on("mod:fish-caught", (payload) => caught.push(payload));
+      const tired: unknown[] = [];
+      engine.events.on("mod:fish-too-tired", (payload) => tired.push(payload));
+
+      expect(player()).toMatchObject({ stamina: 100, skills: {} });
+
+      await callTool(client, "time.advance", { ticks: 480 }); // day 2: daily-stamina machine restores to ready
+      expect(player()?.stamina).toBe(100);
+
+      expect(intent.handle("move", { tile: { x: 3, y: 3 } })).toMatchObject({ ok: true });
+
+      for (let i = 0; i < 3; i++) {
+        expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({
+          action: "interact",
+          tile: { x: 3, y: 2 },
+          ok: true,
+        });
+      }
+
+      expect(inv.count("fish")).toBe(3);
+      expect(player()).toMatchObject({ stamina: 85, skills: { fishing: 3 } });
+      expect(caught).toHaveLength(3);
+      expect(caught[0]).toMatchObject({ fish: "fish", size: 1, x: 3, y: 2, stamina: 95 });
+      expect(caught[2]).toMatchObject({ fish: "fish", size: 3, catches: 3, stamina: 85 });
+      expect(inv.count("egg")).toBe(1); // every third catch: fishing-treat machine matches mod:fish-caught
+      expect(engine.world.get("plot:3:2")).toBeUndefined(); // the Mod normalizes the default till-on-water
+
+      (player() as { stamina: number }).stamina = 3;
+      expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({ ok: true });
+      expect(inv.count("fish")).toBe(3);
+      expect(player()).toMatchObject({ stamina: 3, skills: { fishing: 3 } });
+      expect(tired).toHaveLength(1);
+      expect(tired[0]).toMatchObject({ x: 3, y: 2, stamina: 3 });
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "daily-stamina" })).toEqual({
+        machineId: "daily-stamina",
+        state: "low",
+        started: true,
+      });
+    } finally {
+      await close();
+    }
+  });
+});
+
+async function ensureWatered(
+  engine: ReturnType<typeof createEngine>,
+  client: Client,
+  x: number,
+  y: number,
+): Promise<void> {
+  const plot = engine.world.get(`plot:${x}:${y}`) as { watered?: boolean } | undefined;
+  if (!plot || plot.watered) return;
+  await callTool(client, "crop.water", { x, y });
+}
+
+async function growAndHarvest(
+  engine: ReturnType<typeof createEngine>,
+  client: Client,
+  plots: [number, number][],
+  seedItemId: string,
+  sproutDays: number,
+  grownDays: number,
+): Promise<void> {
+  const planting = engine.systems.get<PlantingSystem>("planting");
+  if (!planting) throw new Error("planting system not booted");
+  for (const [x, y] of plots) {
+    planting.till(x, y);
+    planting.sow(x, y, seedItemId);
+    await ensureWatered(engine, client, x, y);
+  }
+  await callTool(client, "time.tick", { ticks: 480 * sproutDays });
+  for (const [x, y] of plots) {
+    await ensureWatered(engine, client, x, y);
+  }
+  await callTool(client, "time.tick", { ticks: 480 * grownDays });
+  for (const [x, y] of plots) {
+    planting.harvest(x, y);
+  }
+}

+ 498 - 0
apps/mcp-server/test/ws-broadcast.test.ts

@@ -0,0 +1,498 @@
+import { resolve } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { createEngine, type TimeSystem } from "@nengine/core";
+import { WebSocket } from "ws";
+import { startWsBroadcast, type WsBroadcastHandle, type WsEventFrame, type WsSnapshotFrame } from "../src/ws-broadcast.js";
+
+const repoRoot = resolve(process.cwd(), "../..");
+const sampleGameDir = resolve(repoRoot, "examples", "sample-game");
+const contentDir = resolve(sampleGameDir, "content");
+const modsDir = resolve(sampleGameDir, "mods");
+
+interface Harness {
+  engine: ReturnType<typeof createEngine>;
+  ws: WsBroadcastHandle;
+  client: WebSocket;
+  next: (predicate?: (frame: unknown) => boolean) => Promise<unknown>;
+  close: () => Promise<void>;
+}
+
+const openHandles: Harness[] = [];
+
+afterEach(async () => {
+  while (openHandles.length > 0) {
+    const harness = openHandles.pop();
+    if (harness) await harness.close();
+  }
+});
+
+async function setup(): Promise<Harness> {
+  const engine = createEngine({ contentDir, modsDir });
+  await engine.start();
+  const ws = await startWsBroadcast(engine, 0);
+  const client = new WebSocket(`ws://localhost:${ws.port}`);
+
+  const messages: unknown[] = [];
+  const waiters: ((frame: unknown) => void)[] = [];
+  client.on("message", (data) => {
+    const frame = JSON.parse(String(data)) as unknown;
+    const waiter = waiters.shift();
+    if (waiter) {
+      waiter(frame);
+    } else {
+      messages.push(frame);
+    }
+  });
+
+  await new Promise<void>((resolveOpen, rejectOpen) => {
+    client.once("open", () => resolveOpen());
+    client.once("error", (err) => rejectOpen(err));
+  });
+
+  const next = (predicate?: (frame: unknown) => boolean): Promise<unknown> => {
+    const index = messages.findIndex((frame) => !predicate || predicate(frame));
+    if (index !== -1) {
+      return Promise.resolve(messages.splice(index, 1)[0]);
+    }
+    return new Promise((resolveMessage, rejectMessage) => {
+      const timer = setTimeout(() => rejectMessage(new Error("timeout waiting for ws frame")), 2000);
+      const waiter = (frame: unknown): void => {
+        if (predicate && !predicate(frame)) {
+          waiters.push(waiter);
+          return;
+        }
+        clearTimeout(timer);
+        resolveMessage(frame);
+      };
+      waiters.push(waiter);
+    });
+  };
+
+  const harness: Harness = {
+    engine,
+    ws,
+    client,
+    next,
+    close: async () => {
+      client.close();
+      await ws.close();
+      await engine.stop();
+    },
+  };
+  openHandles.push(harness);
+  return harness;
+}
+
+describe("apps/mcp-server WebSocket world broadcast", () => {
+  it("sends a {type:snapshot} frame with map + world + time on connect", async () => {
+    const { next, close } = await setup();
+    try {
+      const frame = (await next()) as WsSnapshotFrame;
+      expect(frame.type).toBe("snapshot");
+      expect(frame.map).toMatchObject({ id: "farm", width: 16, height: 16 });
+      const ids = (frame.world as { id: string }[]).map((e) => e.id);
+      expect(ids).toContain("farm");
+      expect(ids).toContain("player");
+      expect(frame.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("reflects the current time state in fresh snapshot frames", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      const time = engine.systems.get<TimeSystem>("time");
+      time!.advance(480);
+      client.send(JSON.stringify({ type: "sync" }));
+
+      const isSnapshot = (frame: unknown): boolean => (frame as WsSnapshotFrame).type === "snapshot";
+      const refreshed = (await next(isSnapshot)) as WsSnapshotFrame;
+      expect(refreshed.time).toEqual({ day: 2, season: "spring", tickInDay: 0 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("broadcasts {type:event} frames when time advances", async () => {
+    const { engine, next, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      const time = engine.systems.get<TimeSystem>("time");
+      time!.advance(480);
+
+      const isDayStart = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start";
+      const event = (await next(isDayStart)) as WsEventFrame;
+      expect(event.type).toBe("event");
+      expect(event.event).toBe("day-start");
+      expect(event.payload).toEqual({ day: 2, season: "spring" });
+    } finally {
+      await close();
+    }
+  });
+
+  it("sends a fresh snapshot in reply to a {type:sync} client message", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      engine.world.create({ id: "npc:test", type: "npc", x: 3, y: 4 });
+      client.send(JSON.stringify({ type: "sync" }));
+
+      const refreshed = (await next()) as WsSnapshotFrame;
+      expect(refreshed.type).toBe("snapshot");
+      expect((refreshed.world as { id: string }[]).some((e) => e.id === "npc:test")).toBe(true);
+    } finally {
+      await close();
+    }
+  });
+
+  it("reaches two connected clients (broadcast fan-out)", async () => {
+    const { engine, ws, next, close } = await setup();
+    try {
+      const second = new WebSocket(`ws://localhost:${ws.port}`);
+      const messages2: unknown[] = [];
+      second.on("message", (data) => {
+        messages2.push(JSON.parse(String(data)) as unknown);
+      });
+      await new Promise<void>((resolveOpen, rejectOpen) => {
+        second.once("open", () => resolveOpen());
+        second.once("error", (err) => rejectOpen(err));
+      });
+
+      const firstSnapshot = (await next()) as WsSnapshotFrame;
+      expect(firstSnapshot.type).toBe("snapshot");
+      await waitFor(() => messages2.some((frame) => (frame as WsSnapshotFrame).type === "snapshot"));
+
+      const time = engine.systems.get<TimeSystem>("time");
+      time!.advance(480);
+
+      const isDayStart = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start";
+      const firstEvent = (await next(isDayStart)) as WsEventFrame;
+      expect(firstEvent.event).toBe("day-start");
+
+      await waitFor(() =>
+        messages2.some(
+          (frame) => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start",
+        ),
+      );
+      second.close();
+    } finally {
+      await close();
+    }
+  });
+
+  it("skips non-serializable event payloads without crashing the server", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      const circular: Record<string, unknown> = {};
+      circular.self = circular;
+      engine.events.emit("test:circular", circular);
+
+      const time = engine.systems.get<TimeSystem>("time");
+      time!.advance(480);
+
+      const isDayStart = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start";
+      const event = (await next(isDayStart)) as WsEventFrame;
+      expect(event.type).toBe("event");
+      expect(event.event).toBe("day-start");
+      expect(client.readyState).toBe(WebSocket.OPEN);
+    } finally {
+      await close();
+    }
+  });
+
+  it("moves the player in reply to a {type:intent} message and broadcasts player-intent", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "intent", action: "move", args: { tile: { x: 3, y: 4 } } }));
+
+      const isPlayerIntent = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
+      const event = (await next(isPlayerIntent)) as WsEventFrame;
+      expect(event.payload).toMatchObject({ action: "move", tile: { x: 3, y: 4 }, ok: true });
+      await waitFor(() => {
+        const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
+        return player?.x === 3 && player?.y === 4;
+      });
+    } finally {
+      await close();
+    }
+  });
+
+  it("interacts with a tile in reply to a {type:intent} message (tills a plot)", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "intent", action: "interact", args: { tile: { x: 9, y: 4 } } }));
+
+      const isPlayerIntent = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
+      const event = (await next(isPlayerIntent)) as WsEventFrame;
+      expect(event.payload).toMatchObject({ action: "interact", tile: { x: 9, y: 4 }, ok: true, detail: "tilled plot" });
+      await waitFor(() => engine.world.get("plot:9:4") !== undefined);
+    } finally {
+      await close();
+    }
+  });
+
+  it("sends a player-intent error frame for invalid intent input without crashing", async () => {
+    const { next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "intent", action: "dance", args: {} }));
+
+      const isPlayerIntent = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
+      const event = (await next(isPlayerIntent)) as WsEventFrame;
+      expect(event.payload).toMatchObject({ action: "dance", ok: false });
+      expect((event.payload as { error?: string }).error).toMatch(/intent action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"/);
+      expect(client.readyState).toBe(WebSocket.OPEN);
+    } finally {
+      await close();
+    }
+  });
+
+  it("includes the registered sprites and sounds catalogs in the snapshot frame", async () => {
+    const { next, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+      expect(Array.isArray(snapshot.sprites)).toBe(true);
+      expect(Array.isArray(snapshot.sounds)).toBe(true);
+      const spriteIds = (snapshot.sprites as { id: string }[]).map((s) => s.id);
+      expect(spriteIds).toEqual(
+        expect.arrayContaining(["player", "parsnip-sprout", "parsnip-grown", "parsnip-mature", "coop", "fence"]),
+      );
+      const soundIds = (snapshot.sounds as { id: string }[]).map((s) => s.id);
+      expect(soundIds).toEqual(expect.arrayContaining(["till", "water", "harvest", "coin"]));
+      const shopIds = (snapshot.shops as { id: string }[]).map((s) => s.id);
+      expect(shopIds).toEqual(["general-store"]);
+      const animationIds = (snapshot.animations as { id: string }[]).map((a) => a.id);
+      expect(animationIds).toEqual(["player"]);
+      const playerAnim = (snapshot.animations as { id: string; states: { id: string; spriteRef: string }[] }[]).find(
+        (a) => a.id === "player",
+      );
+      expect(playerAnim?.states.map((s) => s.spriteRef)).toEqual(["player", "player-walk"]);
+    } finally {
+      await close();
+    }
+  });
+
+  it("ships a metadata-only assetsIndex in the snapshot (no base64 bytes per snapshot, D37)", async () => {
+    const { next, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+      expect(Object.keys(snapshot.assetsIndex)).toEqual(
+        expect.arrayContaining(["grass-1", "water-1", "tilled-1", "coop-1", "click-1"]),
+      );
+      expect(snapshot.assetsIndex["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
+      expect(typeof snapshot.assetsIndex["grass-1"].size).toBe("number");
+      expect(snapshot.assetsIndex["grass-1"].size).toBeGreaterThan(0);
+      // metadata only — no base64 bytes travel in the snapshot
+      expect((snapshot as unknown as { assets?: unknown }).assets).toBeUndefined();
+      const serialized = JSON.stringify(snapshot);
+      expect(serialized).not.toContain(";base64,");
+    } finally {
+      await close();
+    }
+  });
+
+  it("replies to an assets-request with base64 asset entries (on-demand roundtrip, D37)", async () => {
+    const { next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "assets-request", ids: ["grass-1", "click-1", "does-not-exist"] }));
+
+      const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets";
+      const frame = (await next(isAssets)) as { type: "assets"; assets: Record<string, { kind: string; mime: string; data?: string }> };
+      expect(frame.assets["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
+      expect(typeof frame.assets["grass-1"].data).toBe("string");
+      expect(frame.assets["grass-1"].data?.length).toBeGreaterThan(0);
+      expect(frame.assets["click-1"]).toMatchObject({ kind: "audio", mime: "audio/ogg" });
+      // unknown ids are ignored gracefully
+      expect(frame.assets["does-not-exist"]).toBeUndefined();
+      expect(Object.keys(frame.assets).sort()).toEqual(["click-1", "grass-1"]);
+    } finally {
+      await close();
+    }
+  });
+
+  it("replies to an empty assets-request with an empty assets frame", async () => {
+    const { next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "assets-request", ids: [] }));
+
+      const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets";
+      const frame = (await next(isAssets)) as { type: "assets"; assets: unknown };
+      expect(frame.assets).toEqual({});
+    } finally {
+      await close();
+    }
+  });
+
+  it("truncates an assets-request above the 64-id cap and rejects non-string ids", async () => {
+    const { next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      // grass-1 first (within the cap); 42 is non-string; fake-0..fake-98 fill the rest past the cap
+      const ids: unknown[] = ["grass-1", 42, ...Array.from({ length: 99 }, (_, i) => `fake-${i}`)];
+      client.send(JSON.stringify({ type: "assets-request", ids }));
+
+      const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets";
+      const frame = (await next(isAssets)) as { type: "assets"; assets: Record<string, unknown> };
+      // grass-1 is within the first 64 string ids and known → returned; 42 (non-string) dropped;
+      // fake ids are unknown → ignored; ids past the 64-id cap are truncated and never answered
+      expect(frame.assets["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
+      expect(frame.assets["fake-0"]).toBeUndefined();
+      expect(frame.assets["fake-63"]).toBeUndefined();
+      expect(Object.keys(frame.assets)).toHaveLength(1);
+    } finally {
+      await close();
+    }
+  });
+
+  it("emits an input event frame in reply to a {type:input} message", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "input", input: "key:Space" }));
+
+      const isInput = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
+      const event = (await next(isInput)) as WsEventFrame;
+      expect(event.payload).toEqual({ input: "key:Space" });
+
+      const player = engine.world.get("player") as { x: number; y: number };
+      expect(player).toMatchObject({ x: 2, y: 4 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("maps key:Arrow* inputs to a one-tile IntentSystem.move from the player's current position", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "input", input: "key:ArrowRight" }));
+
+      const isInput = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
+      const inputEvent = (await next(isInput)) as WsEventFrame;
+      expect(inputEvent.payload).toEqual({ input: "key:ArrowRight" });
+
+      const isPlayerIntent = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
+      const intentEvent = (await next(isPlayerIntent)) as WsEventFrame;
+      expect(intentEvent.payload).toMatchObject({ action: "move", tile: { x: 3, y: 4 }, ok: true });
+
+      await waitFor(() => {
+        const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
+        return player?.x === 3 && player?.y === 4;
+      });
+
+      client.send(JSON.stringify({ type: "input", input: "key:ArrowUp" }));
+      const secondIntent = (await next(isPlayerIntent)) as WsEventFrame;
+      expect(secondIntent.payload).toMatchObject({ action: "move", tile: { x: 3, y: 3 }, ok: true });
+      await waitFor(() => {
+        const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
+        return player?.x === 3 && player?.y === 3;
+      });
+    } finally {
+      await close();
+    }
+  });
+
+  it("does not move the player for non-arrow key inputs (broadcast-only)", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "input", input: "pointer:click:10:10" }));
+      const isInput = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
+      const event = (await next(isInput)) as WsEventFrame;
+      expect(event.payload).toEqual({ input: "pointer:click:10:10" });
+
+      await new Promise((resolveWait) => setTimeout(resolveWait, 100));
+      const player = engine.world.get("player") as { x: number; y: number };
+      expect(player).toMatchObject({ x: 2, y: 4 });
+    } finally {
+      await close();
+    }
+  });
+
+  it("never crashes on malformed input messages", async () => {
+    const { engine, next, client, close } = await setup();
+    try {
+      const snapshot = (await next()) as WsSnapshotFrame;
+      expect(snapshot.type).toBe("snapshot");
+
+      client.send(JSON.stringify({ type: "input", input: "" }));
+      const isInput = (frame: unknown): boolean =>
+        (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
+      const bad = (await next(isInput)) as WsEventFrame;
+      expect(bad.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" });
+
+      client.send(JSON.stringify({ type: "input", input: 42 }));
+      const badNumber = (await next(isInput)) as WsEventFrame;
+      expect(badNumber.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" });
+
+      client.send(JSON.stringify({ type: "input" }));
+      const badMissing = (await next(isInput)) as WsEventFrame;
+      expect(badMissing.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" });
+
+      client.send(JSON.stringify({ type: "input", input: "key:ArrowLeft" }));
+      const moved = (await next(isInput)) as WsEventFrame;
+      expect(moved.payload).toEqual({ input: "key:ArrowLeft" });
+
+      await waitFor(() => {
+        const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
+        return player?.x === 1 && player?.y === 4;
+      });
+      expect(client.readyState).toBe(WebSocket.OPEN);
+    } finally {
+      await close();
+    }
+  });
+});
+
+async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
+  const start = Date.now();
+  while (!condition()) {
+    if (Date.now() - start > timeoutMs) throw new Error("timeout waiting for condition");
+    await new Promise((resolveWait) => setTimeout(resolveWait, 10));
+  }
+}

+ 9 - 0
apps/mcp-server/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "noEmit": false,
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"]
+}

+ 33 - 0
apps/pack/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@nengine/pack",
+  "version": "0.1.0",
+  "type": "module",
+  "description": "Packaging CLI: bundles a game's content (YAML) into a self-contained static site (bundle.json + index.html + assets).",
+  "bin": {
+    "nengine-pack": "./dist/index.js"
+  },
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsc -p tsconfig.json",
+    "test": "vitest run",
+    "pack": "node dist/index.js"
+  },
+  "dependencies": {
+    "@nengine/core": "workspace:*",
+    "vite": "^7.0.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "electron": "^43.4.0",
+    "typescript": "^5.6.0",
+    "vitest": "^3.0.0"
+  }
+}

+ 274 - 0
apps/pack/src/desktop.ts

@@ -0,0 +1,274 @@
+import { cp, mkdir, writeFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+export const DESKTOP_DIR = "desktop";
+export const DEFAULT_PRODUCT_NAME = "Farm Game";
+export const ELECTRON_BUILDER_VERSION = "^26.0.0";
+
+export interface DesktopOptions {
+  productName?: string;
+  installer?: boolean;
+}
+
+export interface DesktopResult {
+  dir: string;
+  mainFile: string;
+  packageFile: string;
+  installerFile?: string;
+}
+
+/** npm-safe package name derived from the product name. */
+function npmName(productName: string): string {
+  const slug = productName
+    .toLowerCase()
+    .replace(/\s+/g, "-")
+    .replace(/[^a-z0-9-]/g, "");
+  return slug === "" ? "nengine-game" : slug;
+}
+
+function installerYaml(productName: string, appName: string): string {
+  return `appId: com.nengine.${appName}
+productName: ${productName}
+directories:
+  output: dist
+  buildResources: build
+files:
+  - main.cjs
+  - package.json
+  - index.html
+  - assets/**
+  - nengine-assets/**
+  - bundle.json
+linux:
+  target:
+    - AppImage
+  category: Game
+win:
+  target:
+    - nsis
+nsis:
+  oneClick: true
+`;
+}
+
+function mainCjs(productName: string): string {
+  const title = JSON.stringify(productName);
+  return `"use strict";
+// Electron main process for the nEngine desktop shell (D32).
+// Serves the packaged static site (this file's parent directory) over a local
+// HTTP server and loads it in a BrowserWindow. The site is fully
+// self-contained (bundle mode — createEngine({bundle})), so the window needs
+// no Node integration: contextIsolation on, nodeIntegration off.
+
+const { app, BrowserWindow } = require("electron");
+const { createServer } = require("node:http");
+const { createReadStream, existsSync, statSync } = require("node:fs");
+const { extname, normalize, resolve, sep } = require("node:path");
+
+const PRODUCT_NAME = ${title};
+// installer layout copies the site next to main.cjs; a plain --desktop shell keeps it in the parent dir
+const SITE_ROOT = existsSync(resolve(__dirname, "index.html")) ? __dirname : resolve(__dirname, "..");
+
+const MIME = {
+  ".html": "text/html; charset=utf-8",
+  ".js": "text/javascript; charset=utf-8",
+  ".json": "application/json; charset=utf-8",
+  ".png": "image/png",
+  ".ogg": "audio/ogg",
+  ".css": "text/css; charset=utf-8",
+  ".svg": "image/svg+xml",
+  ".ico": "image/x-icon",
+  ".map": "application/json; charset=utf-8",
+  ".txt": "text/plain; charset=utf-8",
+};
+
+function contentType(path) {
+  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
+}
+
+function serve(req, res) {
+  if (req.method !== "GET") {
+    res.writeHead(405, { "Content-Type": "text/plain" });
+    res.end("method not allowed");
+    return;
+  }
+  let pathname;
+  try {
+    pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
+  } catch {
+    res.writeHead(400, { "Content-Type": "text/plain" });
+    res.end("bad request");
+    return;
+  }
+  const file = resolve(SITE_ROOT, "." + normalize(pathname));
+  if (file !== SITE_ROOT && !file.startsWith(SITE_ROOT + sep)) {
+    res.writeHead(403, { "Content-Type": "text/plain" });
+    res.end("forbidden");
+    return;
+  }
+  try {
+    if (statSync(file).isFile()) {
+      res.writeHead(200, { "Content-Type": contentType(file) });
+      createReadStream(file).pipe(res);
+      return;
+    }
+  } catch {
+    // fall through to 404 (exact files only — no index.html fallback)
+  }
+  res.writeHead(404, { "Content-Type": "text/plain" });
+  res.end("not found");
+}
+
+let server = null;
+let port = 0;
+
+function startServer() {
+  return new Promise((resolveStart, reject) => {
+    server = createServer(serve);
+    server.on("error", reject);
+    server.listen(0, "127.0.0.1", () => {
+      port = server.address().port;
+      resolveStart(port);
+    });
+  });
+}
+
+function createWindow() {
+  const win = new BrowserWindow({
+    width: 960,
+    height: 640,
+    resizable: true,
+    title: PRODUCT_NAME,
+    autoHideMenuBar: true,
+    webPreferences: { contextIsolation: true, nodeIntegration: false },
+  });
+  win.on("page-title-updated", (event) => event.preventDefault());
+  win.loadURL("http://127.0.0.1:" + port + "/index.html");
+  win.on("closed", () => {
+    if (server) server.close();
+  });
+  return win;
+}
+
+app.whenReady().then(async () => {
+  await startServer();
+  createWindow();
+  app.on("activate", () => {
+    if (BrowserWindow.getAllWindows().length === 0) createWindow();
+  });
+});
+
+app.on("window-all-closed", () => {
+  app.quit();
+});
+`;
+}
+
+function readme(productName: string, installer: boolean): string {
+  const installerSection = installer
+    ? `## Build an installer (D36)
+
+With \`nengine-pack --installer\`, this folder also ships an
+\`electron-builder.yml\` and a copy of the site files (\`index.html\` +
+\`assets/\` + \`bundle.json\`), so the builder can package the whole app
+(\`main.cjs\` resolves the site as \`..\` — which IS the app dir once packaged):
+
+\`\`\`sh
+npm install
+npm run dist      # builds dist/*.AppImage (Linux)
+npm run dist:win  # builds a Windows NSIS installer (run on Windows)
+\`\`\`
+
+Output lands in \`dist/\` (e.g. \`dist/*.AppImage\`). Only the Linux target is
+buildable in this environment; the Windows target is documented config.
+`
+    : "";
+  return `# ${productName} — desktop app
+
+This folder is an **Electron desktop shell** around the packaged static site
+in the parent directory (\`index.html\` + \`assets/\` + \`bundle.json\`). It was
+generated by \`nengine-pack --desktop\` (D32).
+
+## Prerequisites
+
+- Node.js 20+ (only to install Electron once — the app bundles Chromium, so
+  the game itself needs no system dependencies).
+
+## Run
+
+\`\`\`sh
+npm install   # installs Electron (~100 MB, one time)
+npm start     # opens the game window
+\`\`\`
+
+Or with pnpm: \`pnpm i\` + \`pnpm start\`.
+
+The app opens a 960×640 window titled "${productName}", serving the parent
+directory over a local loopback HTTP server (ephemeral port) and loading
+\`index.html\`. It works from any working directory — the shell resolves the
+site folder relative to its own location.
+${installerSection}
+## Notes (v1)
+
+- The window runs the page with \`contextIsolation: true\` and
+  \`nodeIntegration: false\` — the game page needs nothing from Node.
+${installer ? `- Installer config: \`electron-builder.yml\` is generated (D36) — run \`npm run dist\` inside this folder to build a Linux AppImage (see the Installer section above). The default Electron icon is used (no custom icon in v1).` : `- No installer is built in v1 (\`electron-builder\` is not a dependency);
+  distributable desktop installers are a documented future path.`}
+- The default Electron icon is used (no custom icon in v1).
+- The shell wraps the packaged game unchanged: same bundle mode, same UI,
+  with any Mods packaged via \`nengine-pack --mods\` (D36).
+`;
+}
+
+/**
+ * Write an Electron desktop shell for the packaged static site at `outDir`
+ * (the site root — index.html + assets/ + bundle.json). Outputs into
+ * `outDir/desktop/`: `main.cjs` (HTTP server + BrowserWindow), `package.json`
+ * (npm-safe name, `main`, `scripts.start`), and `README-desktop.md`. With
+ * `installer: true` (D36) it additionally copies the site files into the
+ * desktop dir, writes `electron-builder.yml`, and extends package.json with
+ * `electron-builder` + `dist`/`dist:win` scripts.
+ */
+export async function generateDesktop(outDir: string, opts: DesktopOptions = {}): Promise<DesktopResult> {
+  const productName = opts.productName !== undefined && opts.productName.trim() !== "" ? opts.productName : DEFAULT_PRODUCT_NAME;
+  const installer = opts.installer === true;
+  const appName = npmName(productName);
+  const dir = resolve(outDir, DESKTOP_DIR);
+  const mainFile = join(dir, "main.cjs");
+  const packageFile = join(dir, "package.json");
+  await mkdir(dir, { recursive: true });
+  await writeFile(mainFile, mainCjs(productName), "utf8");
+  const scripts: Record<string, string> = { start: "electron ." };
+  const devDependencies: Record<string, string> = { electron: "^43.0.0" };
+  if (installer) {
+    for (const file of ["index.html", "bundle.json"]) {
+      await cp(join(outDir, file), join(dir, file));
+    }
+    await cp(join(outDir, "assets"), join(dir, "assets"), { recursive: true });
+    const nengineAssets = join(outDir, "nengine-assets");
+    if (existsSync(nengineAssets)) {
+      await cp(nengineAssets, join(dir, "nengine-assets"), { recursive: true });
+    }
+    scripts.dist = "electron-builder --linux";
+    scripts["dist:win"] = "electron-builder --win";
+    devDependencies["electron-builder"] = ELECTRON_BUILDER_VERSION;
+  }
+  const pkg = {
+    name: appName,
+    version: "0.1.0",
+    private: true,
+    description: `Desktop app for "${productName}" — Electron shell over the packaged static site (D32).`,
+    main: "main.cjs",
+    scripts,
+    devDependencies,
+  };
+  await writeFile(packageFile, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
+  await writeFile(join(dir, "README-desktop.md"), readme(productName, installer), "utf8");
+  const result: DesktopResult = { dir, mainFile, packageFile };
+  if (installer) {
+    result.installerFile = join(dir, "electron-builder.yml");
+    await writeFile(result.installerFile, installerYaml(productName, appName), "utf8");
+  }
+  return result;
+}

+ 314 - 0
apps/pack/src/index.ts

@@ -0,0 +1,314 @@
+#!/usr/bin/env node
+import { existsSync } from "node:fs";
+import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { basename, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { parseArgs } from "node:util";
+import { build } from "vite";
+import {
+  BUNDLE_FORMAT,
+  BUNDLE_VERSION,
+  contentSchemas,
+  ContentLoader,
+  ContentRegistry,
+  assetByteSize,
+  assetFileExtension,
+  entryFileOf,
+  readModEntry,
+  type AssetEntry,
+  type AssetIndexEntry,
+  type BundledMod,
+  type ContentBundle,
+  type ContentKind,
+} from "@nengine/core";
+import { generateDesktop } from "./desktop.js";
+
+export const DEFAULT_OUT_DIR = "./dist-pack";
+
+export const USAGE = [
+  "Usage: nengine-pack --content <dir> [--out <dir>] [--mods <dir>] [--split-assets] [--desktop] [--installer] [--name <name>]",
+  "",
+  "Package a game's content (YAML) into a self-contained static site:",
+  "  --content <dir>   the game's content directory (required)",
+  "  --out <dir>       output directory (default: ./dist-pack)",
+  "  --mods <dir>      Mods directory (each subdir with index.mjs/index.js is",
+  "                    packaged into bundle.mods — executed in bundle mode, D36)",
+  "  --split-assets    write binary assets as separate out/nengine-assets/ files",
+  "                    instead of base64-inlining them in bundle.json (D37);",
+  "                    bundle.json carries the metadata index (assetsIndex)",
+  "  --desktop         also generate an Electron desktop app in <out>/desktop/",
+  "  --installer       implies --desktop + writes an electron-builder config so",
+  "                    `npm run dist` inside desktop/ builds installers (D36)",
+  "  --name <name>     desktop product name (default: \"Farm Game\")",
+  "  --help            show this help",
+  "",
+  "The output folder (index.html + assets/ + bundle.json) runs in the browser",
+  "via the engine's bundle mode (createEngine({bundle})) — no dev server, no",
+  "headless core. Packaged Mods (--mods) execute in the browser/desktop via the",
+  "engine's data-URL Mod loader (same hooks, zero fs). With --desktop, desktop/",
+  "holds an Electron shell (main.cjs + package.json) that serves this folder",
+  "over a local HTTP server and opens it in a BrowserWindow; with --installer",
+  "the shell gains an electron-builder.yml (cd desktop && npm run dist).",
+].join("\n");
+
+export interface PackArgs {
+  contentDir: string;
+  outDir: string;
+  desktop: boolean;
+  installer: boolean;
+  splitAssets: boolean;
+  modsDir?: string;
+  name?: string;
+}
+
+export interface PackResult {
+  bundle: ContentBundle;
+  outDir: string;
+  bundleFile: string;
+  desktopDir?: string;
+  modCount: number;
+}
+
+/** Parse CLI args into pack options. Returns null when --help is requested. */
+export function parsePackArgs(input: { argv?: string[]; cwd?: string } = {}): PackArgs | null {
+  const argv = input.argv ?? process.argv.slice(2);
+  const cwd = input.cwd ?? process.cwd();
+  const parsed = parseArgs({
+    args: argv,
+    options: {
+      content: { type: "string" },
+      out: { type: "string" },
+      mods: { type: "string" },
+      "split-assets": { type: "boolean" },
+      desktop: { type: "boolean" },
+      installer: { type: "boolean" },
+      name: { type: "string" },
+      help: { type: "boolean", short: "h" },
+    },
+    strict: false,
+  });
+  if (parsed.values["help"] === true) return null;
+  const content = parsed.values["content"];
+  if (typeof content !== "string" || content === "") {
+    throw new Error("nengine-pack: --content <dir> is required (the game's content directory)");
+  }
+  const out = parsed.values["out"];
+  const outRaw = typeof out === "string" && out !== "" ? out : DEFAULT_OUT_DIR;
+  const nameRaw = parsed.values["name"];
+  const installer = parsed.values["installer"] === true;
+  const modsRaw = parsed.values["mods"];
+  const result: PackArgs = {
+    contentDir: resolve(cwd, content),
+    outDir: resolve(cwd, outRaw),
+    desktop: parsed.values["desktop"] === true || installer,
+    installer,
+    splitAssets: parsed.values["split-assets"] === true,
+  };
+  if (typeof modsRaw === "string" && modsRaw !== "") result.modsDir = resolve(cwd, modsRaw);
+  if (typeof nameRaw === "string" && nameRaw !== "") result.name = nameRaw;
+  return result;
+}
+
+/** Load + validate a content dir (per-item zod, errors fail loudly with
+ * 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);
+  return splitAssets ? splitBundleAssets(bundle) : bundle;
+}
+
+async function compileInlineBundle(contentDir: string, modsDir?: string): Promise<ContentBundle> {
+  const registry = new ContentRegistry();
+  const loader = new ContentLoader(registry);
+  const report = await loader.load(contentDir);
+  if (!report.ok) {
+    const failures = report.items
+      .filter((i) => i.status === "error")
+      .map((i) => `- ${i.kind}/${i.id}: ${i.errors?.join("; ") ?? "unknown error"}`);
+    throw new Error(`nengine-pack: content validation failed for ${contentDir}:\n${failures.join("\n")}`);
+  }
+  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 assets: Record<string, AssetEntry> = {};
+  const assetsIndex: Record<string, AssetIndexEntry> = {};
+  for (const [id, entry] of registry.assets) {
+    assets[id] = entry;
+    assetsIndex[id] = {
+      kind: entry.kind,
+      mime: entry.mime,
+      size: assetByteSize(entry),
+      file: `nengine-assets/${id}.${assetFileExtension(entry.mime)}`,
+    };
+  }
+  const bundle: ContentBundle = { format: BUNDLE_FORMAT, version: BUNDLE_VERSION, content };
+  if (Object.keys(assets).length > 0) {
+    bundle.assets = assets;
+    bundle.assetsIndex = assetsIndex;
+  }
+  if (modsDir !== undefined) {
+    const mods = await compileMods(modsDir);
+    if (Object.keys(mods).length > 0) bundle.mods = mods;
+  }
+  return bundle;
+}
+
+/** Strip an inline bundle's asset bytes into the D37 split form: `assets` is
+ * removed and the metadata `assetsIndex` (already carrying `size`/`file`) is
+ * kept. No files are written here — `writeSplitAssets` does that. */
+export function splitBundleAssets(bundle: ContentBundle): ContentBundle {
+  const split: ContentBundle = { ...bundle };
+  delete split.assets;
+  return split;
+}
+
+/** 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>> {
+  if (!existsSync(modsDir)) {
+    throw new Error(`nengine-pack: mods dir not found: ${modsDir}`);
+  }
+  const entries = await readdirSorted(modsDir);
+  const mods: Record<string, BundledMod> = {};
+  const failures: string[] = [];
+  for (const name of entries) {
+    const dir = resolve(modsDir, name);
+    if (!(await isDirectory(dir))) continue;
+    const fallbackId = basename(dir);
+    if (!entryFileOf(dir)) {
+      failures.push(`- ${fallbackId}: no index.mjs or index.js entry`);
+      continue;
+    }
+    let def: { default?: unknown };
+    try {
+      def = (await import(pathToFileURL(entryFileOf(dir) as string).href)) as { default?: unknown };
+    } catch (err) {
+      failures.push(`- ${fallbackId}: import failed: ${err instanceof Error ? err.message : String(err)}`);
+      continue;
+    }
+    const manifest = def.default as { manifest?: unknown } | undefined;
+    const manifestObj = manifest?.manifest as { id?: unknown; version?: unknown } | undefined;
+    if (!manifestObj || typeof manifestObj.id !== "string" || typeof manifestObj.version !== "string") {
+      failures.push(`- ${fallbackId}: manifest { id, version } required`);
+      continue;
+    }
+    const code = await readModEntry(dir);
+    if (code === undefined) {
+      failures.push(`- ${fallbackId}: no index.mjs or index.js entry`);
+      continue;
+    }
+    mods[manifestObj.id] = { manifest: { id: manifestObj.id, version: manifestObj.version }, code };
+  }
+  if (failures.length > 0) {
+    throw new Error(`nengine-pack: mod packaging failed for ${modsDir}:\n${failures.join("\n")}`);
+  }
+  return mods;
+}
+
+/** Compile the bundle, build the static site (reusing the apps/dev Vite app in
+ * bundle mode), write bundle.json into the out dir, and — with the --desktop
+ * flag — generate an Electron desktop shell in <out>/desktop/ (D32); with the
+ * --installer flag the shell additionally gains an electron-builder config
+ * (D36). With --split-assets (D37) the asset bytes are written as sibling
+ * `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);
+  let bundle = inline;
+  await mkdir(options.outDir, { recursive: true });
+  await buildViteSite(options.outDir);
+  if (options.splitAssets) {
+    await writeSplitAssets(options.outDir, inline);
+    bundle = splitBundleAssets(inline);
+  }
+  const bundleFile = resolve(options.outDir, "bundle.json");
+  await writeFile(bundleFile, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
+  const result: PackResult = { bundle, outDir: options.outDir, bundleFile, modCount: Object.keys(bundle.mods ?? {}).length };
+  if (options.desktop) {
+    const desktop = await generateDesktop(options.outDir, {
+      ...(options.name !== undefined ? { productName: options.name } : {}),
+      ...(options.installer ? { installer: true } : {}),
+    });
+    result.desktopDir = desktop.dir;
+  }
+  return result;
+}
+
+/** Write the split-assets files for an inline bundle (D37): one file per
+ * asset at `<outDir>/nengine-assets/<id>.<ext>` (ext from the MIME type). The
+ * `file` paths recorded in the bundle's `assetsIndex` match these names. */
+async function writeSplitAssets(outDir: string, bundle: ContentBundle): Promise<void> {
+  const assetsDir = resolve(outDir, "nengine-assets");
+  await mkdir(assetsDir, { recursive: true });
+  for (const [id, entry] of Object.entries(bundle.assets ?? {})) {
+    if (entry.data === undefined) continue;
+    await writeFile(resolve(assetsDir, `${id}.${assetFileExtension(entry.mime)}`), Buffer.from(entry.data, "base64"));
+  }
+}
+
+async function readdirSorted(dir: string): Promise<string[]> {
+  return (await readdir(dir)).sort();
+}
+
+async function isDirectory(dir: string): Promise<boolean> {
+  try {
+    return (await stat(dir)).isDirectory();
+  } catch {
+    return false;
+  }
+}
+
+async function buildViteSite(outDir: string): Promise<void> {  const configFile = fileURLToPath(new URL("../../dev/vite.config.ts", import.meta.url));
+  await build({
+    configFile,
+    base: "./",
+    define: {
+      __NENGINE_BUNDLE_DEFAULT__: JSON.stringify("../bundle.json"),
+      __NENGINE_ASSETS_BASE__: JSON.stringify("./nengine-assets"),
+    },
+    build: { outDir },
+    logLevel: "warn",
+  });
+  const nested = resolve(outDir, "apps/dev/index.html");
+  if (existsSync(nested)) {
+    const html = await readFile(nested, "utf8");
+    await rm(resolve(outDir, "apps"), { recursive: true, force: true });
+    await writeFile(resolve(outDir, "index.html"), html.replaceAll("../../assets/", "./assets/"), "utf8");
+  }
+}
+
+async function run(): Promise<void> {
+  const parsed = parsePackArgs();
+  if (parsed === null) {
+    console.log(USAGE);
+    return;
+  }
+  const result = await packGame(parsed);
+  const defCount = Object.values(result.bundle.content).reduce((sum, defs) => sum + defs.length, 0);
+  const kinds = Object.keys(result.bundle.content).length;
+  const assetCount = Object.keys(result.bundle.assets ?? result.bundle.assetsIndex ?? {}).length;
+  const assetNote = parsed.splitAssets ? " (split into nengine-assets/)" : "";
+  console.log(`packed ${defCount} content defs across ${kinds} kinds, ${assetCount} assets${assetNote}, ${result.modCount} mods`);
+  console.log(`  bundle: ${result.bundleFile}`);
+  console.log(`  site:   ${resolve(result.outDir, "index.html")}  (serve the folder statically and open it)`);
+  if (result.desktopDir !== undefined) {
+    console.log(`  desktop: ${result.desktopDir}  (cd there, then: npm i && npm start)`);
+    if (parsed.installer) {
+      console.log(`           (installer config: npm run dist builds Linux AppImage installers)`);
+    }
+  }
+}
+
+const entry = process.argv[1];
+if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
+  run().catch((err: unknown) => {
+    console.error(err instanceof Error ? err.message : String(err));
+    process.exit(1);
+  });
+}

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

@@ -0,0 +1,429 @@
+import { mkdtemp, readFile, rm, writeFile, mkdir, stat, readdir } from "node:fs/promises";
+import { execFileSync } from "node:child_process";
+import { tmpdir } from "node:os";
+import { existsSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { assetByteSize, createEngine, type ContentBundle, type IntentSystem, type TimeSystem } from "@nengine/core";
+import { DEFAULT_OUT_DIR, compileBundle, packGame, parsePackArgs, splitBundleAssets, USAGE } from "../src/index.js";
+import { DEFAULT_PRODUCT_NAME, generateDesktop } from "../src/desktop.js";
+
+const repoRoot = resolve(process.cwd(), "../..");
+const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content");
+const sampleModsDir = resolve(repoRoot, "examples", "sample-game", "mods");
+
+describe("parsePackArgs", () => {
+  it("resolves --content and --out relative to cwd", () => {
+    const args = parsePackArgs({ argv: ["--content", "examples/sample-game/content", "--out", "out"], cwd: repoRoot });
+    expect(args).not.toBeNull();
+    expect(args?.contentDir).toBe(sampleContentDir);
+    expect(args?.outDir).toBe(resolve(repoRoot, "out"));
+  });
+
+  it("defaults --out to ./dist-pack", () => {
+    const args = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
+    expect(args?.outDir).toBe(resolve(repoRoot, DEFAULT_OUT_DIR));
+  });
+
+  it("returns null for --help", () => {
+    expect(parsePackArgs({ argv: ["--help"] })).toBeNull();
+    expect(parsePackArgs({ argv: ["-h"] })).toBeNull();
+    expect(USAGE).toContain("Usage: nengine-pack");
+  });
+
+  it("throws when --content is missing", () => {
+    expect(() => parsePackArgs({ argv: [] })).toThrow("--content <dir> is required");
+  });
+
+  it("parses --desktop and --name (D32)", () => {
+    const args = parsePackArgs({
+      argv: ["--content", "content", "--desktop", "--name", "Pinefield Valley"],
+      cwd: repoRoot,
+    });
+    expect(args?.desktop).toBe(true);
+    expect(args?.name).toBe("Pinefield Valley");
+
+    const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
+    expect(plain?.desktop).toBe(false);
+    expect(plain?.name).toBeUndefined();
+    expect(USAGE).toContain("--desktop");
+    expect(USAGE).toContain("--name");
+  });
+
+  it("parses --mods and resolves it relative to cwd (D36)", () => {
+    const args = parsePackArgs({ argv: ["--content", "content", "--mods", "examples/sample-game/mods"], cwd: repoRoot });
+    expect(args?.modsDir).toBe(sampleModsDir);
+
+    const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
+    expect(plain?.modsDir).toBeUndefined();
+    expect(USAGE).toContain("--mods <dir>");
+  });
+
+  it("--installer implies --desktop (D36)", () => {
+    const args = parsePackArgs({ argv: ["--content", "content", "--installer"], cwd: repoRoot });
+    expect(args?.installer).toBe(true);
+    expect(args?.desktop).toBe(true);
+
+    const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
+    expect(plain?.installer).toBe(false);
+    expect(plain?.desktop).toBe(false);
+    expect(USAGE).toContain("--installer");
+  });
+
+  it("parses --split-assets (D37) with the default staying inline", () => {
+    const args = parsePackArgs({ argv: ["--content", "content", "--split-assets"], cwd: repoRoot });
+    expect(args?.splitAssets).toBe(true);
+    expect(USAGE).toContain("--split-assets");
+
+    const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
+    expect(plain?.splitAssets).toBe(false);
+  });
+});
+
+describe("desktop packaging (D32)", () => {
+  it("generateDesktop writes a runnable shell: valid package.json JSON + main.cjs wiring + node --check", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-desktop-"));
+    try {
+      const result = await generateDesktop(out, { productName: "Pinefield Valley" });
+      expect(result.dir).toBe(join(out, "desktop"));
+      expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true);
+      expect(existsSync(join(out, "desktop", "package.json"))).toBe(true);
+      expect(existsSync(join(out, "desktop", "README-desktop.md"))).toBe(true);
+
+      const pkg = JSON.parse(await readFile(result.packageFile, "utf8"));
+      expect(pkg.name).toBe("pinefield-valley");
+      expect(pkg.main).toBe("main.cjs");
+      expect(pkg.scripts.start).toBe("electron .");
+      expect(pkg.devDependencies.electron).toMatch(/^\^\d+\./);
+
+      const main = await readFile(result.mainFile, "utf8");
+      expect(main).toContain("createServer");
+      expect(main).toContain("127.0.0.1");
+      expect(main).toContain("loadURL");
+      expect(main).toContain("Pinefield Valley");
+      expect(main).toContain("contextIsolation: true");
+      expect(main).toContain("nodeIntegration: false");
+      expect(main).toContain("window-all-closed");
+      expect(main).toContain('resolve(__dirname, "..")');
+
+      execFileSync(process.execPath, ["--check", result.mainFile], { stdio: "pipe" });
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+
+  it("generateDesktop defaults the product name when --name is absent", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-desktop-"));
+    try {
+      const result = await generateDesktop(out);
+      const main = await readFile(result.mainFile, "utf8");
+      expect(main).toContain(DEFAULT_PRODUCT_NAME);
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+
+  it("packGame --desktop writes the desktop app alongside the static site", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-pack-desktop-"));
+    try {
+      const result = await packGame({
+        contentDir: sampleContentDir,
+        outDir: out,
+        desktop: true,
+        name: "Pinefield Valley",
+      });
+      expect(result.desktopDir).toBe(join(out, "desktop"));
+      expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true);
+      expect(existsSync(join(out, "desktop", "package.json"))).toBe(true);
+      const pkg = JSON.parse(await readFile(join(out, "desktop", "package.json"), "utf8"));
+      expect(pkg.name).toBe("pinefield-valley");
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+
+  it("generateDesktop installer: electron-builder.yml + dist scripts + site files copied (D36)", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-installer-"));
+    try {
+      await mkdir(join(out, "assets"), { recursive: true });
+      await writeFile(join(out, "index.html"), "<html></html>", "utf8");
+      await writeFile(join(out, "bundle.json"), "{}", "utf8");
+      const result = await generateDesktop(out, { productName: "Pinefield Valley", installer: true });
+
+      expect(result.installerFile).toBe(join(out, "desktop", "electron-builder.yml"));
+      expect(existsSync(result.installerFile as string)).toBe(true);
+
+      const yaml = await readFile(result.installerFile as string, "utf8");
+      expect(yaml).toContain("appId: com.nengine.pinefield-valley");
+      expect(yaml).toContain("productName: Pinefield Valley");
+      expect(yaml).toContain("files:");
+      expect(yaml).toContain("bundle.json");
+      expect(yaml).toContain("assets/**");
+      expect(yaml).toContain("linux:");
+      expect(yaml).toContain("- AppImage");
+      expect(yaml).toContain("category: Game");
+      expect(yaml).toContain("win:");
+      expect(yaml).toContain("- nsis");
+      expect(yaml).toContain("oneClick: true");
+
+      const pkg = JSON.parse(await readFile(result.packageFile, "utf8"));
+      expect(pkg.scripts.dist).toBe("electron-builder --linux");
+      expect(pkg.scripts["dist:win"]).toBe("electron-builder --win");
+      expect(pkg.devDependencies["electron-builder"]).toMatch(/^\^\d+\./);
+
+      // installer mode copies the site into desktop/ so electron-builder can
+      // package it (main.cjs resolves the site as ".." — the app dir once packaged)
+      expect(existsSync(join(out, "desktop", "index.html"))).toBe(true);
+      expect(existsSync(join(out, "desktop", "bundle.json"))).toBe(true);
+      expect(existsSync(join(out, "desktop", "assets"))).toBe(true);
+
+      const readme = await readFile(join(out, "desktop", "README-desktop.md"), "utf8");
+      expect(readme).toContain("Build an installer");
+      expect(readme).toContain("npm run dist");
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("compileBundle", () => {
+  it("compiles a valid content dir into the nengine-bundle format", async () => {
+    const bundle = await compileBundle(sampleContentDir);
+    expect(bundle.format).toBe("nengine-bundle");
+    expect(bundle.version).toBe(1);
+    expect(bundle.content["map"]?.[0]).toMatchObject({ id: "farm", width: 16, height: 16 });
+    expect(bundle.content["time"]?.[0]).toMatchObject({ id: "default" });
+    expect(bundle.content["item"]?.some((d) => (d as { id?: string }).id === "hoe")).toBe(true);
+    expect(bundle.content["sprite"]?.some((d) => (d as { id?: string }).id === "player")).toBe(true);
+  });
+
+  it("collects content/assets/ binaries into bundle.assets as base64 (D31)", async () => {
+    const bundle = await compileBundle(sampleContentDir);
+    expect(bundle.assets).toBeDefined();
+    const ids = Object.keys(bundle.assets ?? {});
+    expect(ids).toEqual(
+      expect.arrayContaining(["grass-1", "water-1", "tilled-1", "plant-sprout", "coop-1", "fence-1"]),
+    );
+    expect(bundle.assets?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
+    expect(typeof bundle.assets?.["grass-1"]?.data).toBe("string");
+    expect(bundle.assets?.["click-1"]).toMatchObject({ kind: "audio", mime: "audio/ogg" });
+  });
+
+  it("inline bundles carry a metadata assetsIndex alongside the base64 assets (D37)", async () => {
+    const bundle = await compileBundle(sampleContentDir);
+    expect(bundle.assetsIndex).toBeDefined();
+    expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
+    expect(typeof bundle.assetsIndex?.["grass-1"]?.size).toBe("number");
+    expect(bundle.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png");
+    expect(bundle.assets?.["grass-1"]?.data).toBeDefined();
+  });
+
+  it("splitBundleAssets strips the bytes into a metadata-only assetsIndex (D37)", async () => {
+    const inline = await compileBundle(sampleContentDir);
+    const split = splitBundleAssets(inline);
+    expect(split.assets).toBeUndefined();
+    expect(split.assetsIndex).toBeDefined();
+    const grass = split.assetsIndex?.["grass-1"];
+    expect(grass).toMatchObject({ kind: "image", mime: "image/png", size: assetByteSize(inline.assets?.["grass-1"] as { data: string }) });
+    expect(grass?.file).toBe("nengine-assets/grass-1.png");
+    expect(grass?.file?.endsWith(".png")).toBe(true);
+    expect(split.assetsIndex?.["click-1"]?.file?.endsWith(".ogg")).toBe(true);
+    // no base64 bytes in the split form
+    expect(JSON.stringify(split)).not.toContain(";base64,");
+    // content/mods are untouched
+    expect(split.content).toEqual(inline.content);
+  });
+
+  it("compileBundle(splitAssets: true) returns the split layout", async () => {
+    const split = await compileBundle(sampleContentDir, undefined, true);
+    expect(split.assets).toBeUndefined();
+    expect(Object.keys(split.assetsIndex ?? {}).length).toBeGreaterThan(0);
+    expect(split.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png");
+  });
+
+  it("throws for a missing content dir", async () => {
+    await expect(compileBundle(resolve(repoRoot, "does-not-exist"))).rejects.toThrow(/validation failed/);
+  });
+
+  it("fails loudly with per-kind/per-id messages for invalid content", async () => {
+    const tmp = await mkdtemp(join(tmpdir(), "nengine-pack-"));
+    try {
+      await mkdir(join(tmp, "maps"), { recursive: true });
+      await writeFile(join(tmp, "maps", "bad.yaml"), ["id: bad", "width: nope", "height: 2", ""].join("\n"));
+      await expect(compileBundle(tmp)).rejects.toThrow(/map\/bad/);
+    } finally {
+      await rm(tmp, { recursive: true, force: true });
+    }
+  });
+
+  it("packages a mods dir into bundle.mods with manifest + entry source text (D36)", async () => {
+    const bundle = await compileBundle(sampleContentDir, sampleModsDir);
+    expect(bundle.mods).toBeDefined();
+    const mod = bundle.mods?.["sample-game-mod"];
+    expect(mod).toBeDefined();
+    expect(mod?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" });
+    expect(typeof mod?.code).toBe("string");
+    expect(mod?.code.length).toBeGreaterThan(100);
+    expect(mod?.code).toContain("onDayStart");
+  });
+
+  it("leaves bundle.mods undefined without --mods (zero regression)", async () => {
+    const bundle = await compileBundle(sampleContentDir);
+    expect(bundle.mods).toBeUndefined();
+  });
+
+  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 {
+      await mkdir(join(tmp, "no-entry"), { recursive: true });
+      await mkdir(join(tmp, "no-manifest"), { recursive: true });
+      await writeFile(join(tmp, "no-manifest", "index.mjs"), "export default { hooks: {} };", "utf8");
+      await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/mod packaging failed/);
+      await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-entry: no index\.mjs or index\.js entry/);
+      await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-manifest: manifest \{ id, version \} required/);
+    } finally {
+      await rm(tmp, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("packGame integration", () => {
+  it("builds a static site + bundle.json and the bundle boots via createEngine({bundle})", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-pack-out-"));
+    try {
+      const result = await packGame({ contentDir: sampleContentDir, outDir: out });
+
+      expect(existsSync(join(out, "bundle.json"))).toBe(true);
+      expect(existsSync(join(out, "index.html"))).toBe(true);
+      expect(existsSync(join(out, "assets"))).toBe(true);
+
+      const html = await readFile(join(out, "index.html"), "utf8");
+      expect(html).toMatch(/src="\.\/assets\/[^"]+\.js"/);
+      expect(html).not.toContain("apps/dev");
+
+      const raw = await readFile(join(out, "bundle.json"), "utf8");
+      const bundle = JSON.parse(raw) as ContentBundle;
+      expect(bundle.format).toBe("nengine-bundle");
+      expect(bundle.version).toBe(1);
+      expect(Object.keys(bundle.content).length).toBeGreaterThanOrEqual(11);
+      expect(Object.keys(bundle.assets ?? {}).length).toBeGreaterThan(0);
+      expect(bundle.assets?.["grass-1"]?.kind).toBe("image");
+
+      const engine = createEngine({ bundle });
+      await engine.start();
+      try {
+        const player = engine.world.get("player");
+        expect(player?.type).toBe("player");
+        expect(player?.x).toBe(2);
+        expect(player?.y).toBe(4);
+        expect(engine.assets.size).toBeGreaterThan(0);
+        expect(engine.assets.get("grass-1")?.kind).toBe("image");
+
+        const time = engine.systems.get<TimeSystem>("time");
+        expect(time?.get()).toEqual({ day: 1, season: "spring", tickInDay: 0 });
+
+        const intent = engine.systems.get<IntentSystem>("intent");
+        if (!intent) throw new Error("intent system not booted");
+        const interact = intent.handle("interact", { tile: { x: 10, y: 10 } });
+        expect(interact.ok).toBe(true);
+        expect(engine.world.get("plot:10:10")?.tilled).toBe(true);
+
+        engine.tick(480);
+        expect(time?.get().day).toBe(2);
+
+        // engine.tick is the packaged game's day driver (D23 — the dev
+        // "Advance day" button steps the same simulation path).
+        const sow = intent.handle("interact", { tile: { x: 10, y: 10 } });
+        expect(sow.ok).toBe(true);
+        expect(sow.detail).toContain("sowed");
+        expect(engine.world.get("plot:10:10")?.spriteRef).toBe("parsnip-sprout");
+        expect(intent.handle("interact", { tile: { x: 10, y: 10 } }).detail).toBe("watered plot");
+        engine.tick(480);
+        engine.tick(480);
+        const grown = engine.world.get("plot:10:10") as { stageIndex?: number; spriteRef?: string };
+        expect(grown?.stageIndex).toBe(1);
+        expect(grown?.spriteRef).toBe("parsnip-grown");
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+
+  it("packGame --mods packages Mods into bundle.json and they boot in bundle mode (D36)", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-pack-mods-"));
+    try {
+      const result = await packGame({ contentDir: sampleContentDir, outDir: out, modsDir: sampleModsDir });
+      expect(result.modCount).toBe(1);
+      const raw = await readFile(join(out, "bundle.json"), "utf8");
+      const bundle = JSON.parse(raw) as ContentBundle;
+      expect(bundle.mods?.["sample-game-mod"]?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" });
+      expect(typeof bundle.mods?.["sample-game-mod"]?.code).toBe("string");
+
+      const engine = createEngine({ bundle });
+      await engine.start();
+      try {
+        expect(engine.mods).toHaveLength(1);
+        expect(engine.mods[0]?.id).toBe("sample-game-mod");
+        engine.tick(480);
+        expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1);
+        expect(engine.world.get("npc:mayor")?.type).toBe("npc");
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+
+  it("packGame --split-assets writes nengine-assets/ files + a metadata-only bundle.json (D37)", async () => {
+    const out = await mkdtemp(join(tmpdir(), "nengine-pack-split-"));
+    try {
+      const inline = await compileBundle(sampleContentDir);
+      const result = await packGame({ contentDir: sampleContentDir, outDir: out, splitAssets: true });
+
+      expect(result.bundle.assets).toBeUndefined();
+      expect(result.bundle.assetsIndex).toBeDefined();
+
+      const raw = await readFile(join(out, "bundle.json"), "utf8");
+      const bundle = JSON.parse(raw) as ContentBundle;
+      expect(bundle.assets).toBeUndefined();
+      expect(bundle.assetsIndex).toBeDefined();
+      expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png", file: "nengine-assets/grass-1.png" });
+      expect(bundle.assetsIndex?.["click-1"]?.file).toBe("nengine-assets/click-1.ogg");
+      expect(raw).not.toContain(";base64,");
+
+      // sibling files exist and match the source bytes
+      const nengineAssets = join(out, "nengine-assets");
+      expect(existsSync(nengineAssets)).toBe(true);
+      expect(existsSync(join(nengineAssets, "grass-1.png"))).toBe(true);
+      expect(existsSync(join(nengineAssets, "click-1.ogg"))).toBe(true);
+      const files = await readdir(nengineAssets);
+      expect(files.length).toBe(Object.keys(inline.assets ?? {}).length);
+      expect(files.filter((f) => f.endsWith(".png")).length).toBeGreaterThan(0);
+      expect(files.filter((f) => f.endsWith(".ogg")).length).toBeGreaterThan(0);
+
+      // sizes recorded in the index match the actual split files (and the source)
+      for (const [id, meta] of Object.entries(bundle.assetsIndex ?? {})) {
+        const file = join(nengineAssets, meta.file?.split("/")[1] ?? "");
+        const bytes = (await stat(file)).size;
+        expect(bytes).toBe(meta.size);
+        expect(bytes).toBe(assetByteSize(inline.assets?.[id] as { data: string }));
+      }
+
+      // the split bundle still boots via createEngine (metadata-only asset table)
+      const engine = createEngine({ bundle });
+      await engine.start();
+      try {
+        expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" });
+        expect(engine.assets.get("grass-1")?.data).toBeUndefined();
+        expect(engine.assets.get("grass-1")?.file).toBe("nengine-assets/grass-1.png");
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rm(out, { recursive: true, force: true });
+    }
+  });
+});

+ 9 - 0
apps/pack/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "noEmit": false,
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"]
+}

+ 15 - 0
apps/pack/vitepacktest.mjs

@@ -0,0 +1,15 @@
+import { build } from "vite";
+import { fileURLToPath } from "node:url";
+
+const configFile = fileURLToPath(new URL("../../apps/dev/vite.config.ts", import.meta.url));
+const root = fileURLToPath(new URL("../../apps/dev", import.meta.url));
+const out = "/tmp/opencode/vite-out-test2";
+await build({
+  configFile,
+  root,
+  base: "./",
+  define: { __NENGINE_BUNDLE_DEFAULT__: JSON.stringify("../bundle.json") },
+  build: { outDir: out, emptyOutDir: false },
+  logLevel: "warn",
+});
+console.log("done:", out);

+ 13 - 0
content/animals/chicken.yaml

@@ -0,0 +1,13 @@
+# Example animal (D19/D23: GrowthSystem reuse — no animal system). Chicken
+# advances through stages exactly like a crop: feedRequired replaces
+# waterRequired (the animal must be fed via GrowthSystem.feed / animal.feed to
+# advance a feedRequired stage, and to produce while mature). The produce table
+# is data only — the egg is actually granted by behavior-language content that
+# hooks the animal-produce event (see content/statemachines/chicken-egg.yaml).
+id: chicken
+stages:
+  - { name: chick, days: 2, feedRequired: false }
+  - { name: grown, days: 2, feedRequired: true }
+  - { name: laying, days: 1, feedRequired: true }
+produces:
+  - { itemId: egg, qty: 1 }

+ 21 - 0
content/behaviors/season-welcome.yaml

@@ -0,0 +1,21 @@
+# Example trigger demonstrating condition composition (and/or/not). On the
+# first day of a season that is summer or autumn — and only before the welcome
+# flag has been set — the engine emits a bus event and marks the player.
+# Demonstrates the v1 condition grammar: { op, path, value? } comparisons
+# combined via and/or/not, and the emit-event / set-state actions.
+id: season-welcome
+when:
+  and:
+    - { op: eq, path: time.day, value: 1 }
+    - or:
+        - { op: eq, path: time.season, value: summer }
+        - { op: eq, path: time.season, value: autumn }
+    - not: { op: exists, path: player.flag }
+fire:
+  - action: emit-event
+    event: behavior:season-welcome
+    payload:
+      season: welcome
+  - action: set-state
+    path: player.flag
+    value: welcomed

+ 14 - 0
content/behaviors/spring-gift.yaml

@@ -0,0 +1,14 @@
+# Example trigger (D15: behavior language v1). From day 5 of any season the
+# engine grants the player one parsnip-seed whenever a trigger match runs
+# (the condition stays true, so it fires on every matched event — single-shot
+# / re-arming triggers are v2). Condition grammar v1: comparisons
+# ({ op, path, value? }) combined with and/or/not.
+id: spring-gift
+when:
+  op: gte
+  path: time.day
+  value: 5
+fire:
+  - action: give-item
+    itemId: parsnip-seed
+    qty: 1

+ 9 - 0
content/buildings/fence.yaml

@@ -0,0 +1,9 @@
+# Example building (D4: content as data). Fence: 1x1 footprint, only
+# placeable on grass tiles (buildable: { tiles: [grass] }), blocks NPC
+# movement.
+id: fence
+name: Fence
+footprint: { w: 1, h: 1 }
+buildable: { tiles: [grass] }
+blocksMovement: true
+description: "A wooden fence post."

+ 10 - 0
content/buildings/shed.yaml

@@ -0,0 +1,10 @@
+# Example building (D4: content as data). Shed: 2x2 footprint, placeable on
+# any map tile ("map" buildable = no tile-type restriction), blocks NPC
+# movement. Building definitions are data; placed instances are world
+# entities created by the PlacementSystem.
+id: shed
+name: Shed
+footprint: { w: 2, h: 2 }
+buildable: map
+blocksMovement: true
+description: "Stores tools and harvests."

+ 13 - 0
content/crops/parsnip.yaml

@@ -0,0 +1,13 @@
+# Example crop (D4: content as data). Parsnip grows from the parsnip-seed
+# item; stages map to the TimeSystem day cadence; a mature crop drops parsnip.
+# waterRequired: true means the plot must be watered each day of that stage.
+id: parsnip
+seedItem: parsnip-seed
+stages:
+  - { name: sprout, days: 2, waterRequired: true }
+  - { name: grown, days: 2, waterRequired: true }
+  - { name: mature, days: 1, waterRequired: false }
+seasons:
+  - spring
+drops:
+  - { itemId: parsnip, qty: 1 }

+ 31 - 0
content/dialogues/welcome.yaml

@@ -0,0 +1,31 @@
+# Example dialogue demonstrating branches, a condition-gated choice, and an
+# action (give-item). The 'returning' choice is gated on player.flag — which the
+# season-welcome trigger sets after the first summer/autumn welcome — so fresh
+# players never see it.
+id: welcome
+startNode: hello
+nodes:
+  - id: hello
+    text: Welcome to your farm! A new season is about to begin.
+    choices:
+      - text: Thanks! What should I plant first?
+        to: seed-tip
+        actions:
+          - action: give-item
+            itemId: parsnip-seed
+            qty: 1
+      - text: You remember me from last season?
+        to: returning
+        when:
+          op: exists
+          path: player.flag
+      - text: I am just exploring today.
+        to: farewell
+  - id: seed-tip
+    text: Parsnip seeds grow well in spring — here is one to start with.
+    to: farewell
+  - id: returning
+    text: Of course! Welcome back to the farm.
+    to: farewell
+  - id: farewell
+    text: See you around!

+ 9 - 0
content/items/egg.yaml

@@ -0,0 +1,9 @@
+# Example item (D4: content as data). Egg is the produce of the chicken animal;
+# the produce TABLE is data on the animal def, while produce RULES (when/how the
+# egg is granted) are behavior-language content (decision #9 / D19).
+id: egg
+name: Egg
+kind: material
+stackable: true
+price: 15
+use: a chicken's daily produce

+ 8 - 0
content/items/hoe.yaml

@@ -0,0 +1,8 @@
+# Example tool (D4: content as data). A hoe tills soil for planting;
+# the `use` field is descriptive content only in this slice.
+id: hoe
+name: Hoe
+kind: tool
+stackable: false
+price: 500
+use: tills a soil tile for planting

+ 7 - 0
content/items/parsnip-seed.yaml

@@ -0,0 +1,7 @@
+# Example seed. Planted in tilled soil; growth stages are a crop.* concern.
+id: parsnip-seed
+name: Parsnip Seed
+kind: seed
+stackable: true
+price: 20
+use: plant in tilled soil to grow a parsnip

+ 6 - 0
content/items/parsnip.yaml

@@ -0,0 +1,6 @@
+# Example harvest material.
+id: parsnip
+name: Parsnip
+kind: material
+stackable: true
+price: 35

+ 23 - 0
content/maps/farm.yaml

@@ -0,0 +1,23 @@
+# Walking-skeleton map: the first piece of content the engine will load.
+# Tile ids are strings; the render adapter maps them to visuals (tileset data
+# schema is a Tier-1 concern; here the render side keeps a minimal palette).
+id: farm
+width: 12
+height: 12
+tilesetRef: "farm-tiles"
+spawn: { x: 2, y: 2 }
+layers:
+  - name: ground
+    data:
+      - [water, water, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [water, water, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, tilled, tilled, tilled, tilled, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]
+      - [grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass, grass]

+ 11 - 0
content/shops/general-store.yaml

@@ -0,0 +1,11 @@
+# Example shop (D4: content as data). The general store sells tools and seeds
+# to the player (sells lines) and buys harvest materials back (buys lines).
+# Convention: sells = shop sells to player (player pays sells[].price);
+# buys = shop buys from player (player earns buys[].price). stock is optional
+# and informational in this slice (no restock logic); omit for infinite stock.
+id: general-store
+buys:
+  - { itemId: parsnip, price: 30 }
+sells:
+  - { itemId: hoe, price: 500 }
+  - { itemId: parsnip-seed, price: 20, stock: 99 }

+ 31 - 0
content/statemachines/chicken-egg.yaml

@@ -0,0 +1,31 @@
+# Example state machine (D15/D20 behavior language v2) — decision #9's
+# "produce rules via language". The engine emits animal-produce per eligible day
+# for a mature (final-stage) chicken; this machine turns that EVENT into an egg
+# grant via a give-item onEnter. The machine alternates on successive
+# animal-produce events (one transition per machine per event), so the egg is
+# granted on every second produce — frequency/quantity are exactly the kind of
+# rule the language (not the engine) owns.
+id: chicken-egg
+initial: waiting
+states:
+  - id: waiting
+  - id: laid
+    onEnter:
+      - action: give-item
+        itemId: egg
+        qty: 1
+transitions:
+  - from: waiting
+    to: laid
+    on: animal-produce
+    when:
+      op: eq
+      path: payload.animalDefId
+      value: chicken
+  - from: laid
+    to: waiting
+    on: animal-produce
+    when:
+      op: eq
+      path: payload.animalDefId
+      value: chicken

+ 31 - 0
content/statemachines/daily-routine.yaml

@@ -0,0 +1,31 @@
+# Example state machine (D15/D20: behavior language v2) — NPC schedule
+# placeholder. An anima/npc entity does not exist yet in this slice, so the
+# machine runs on a generic instance entity (id "sm:daily-routine", type
+# "statemachine") driven by time events and bus events. Demonstrates the
+# onSchedule time primitive with a when condition (first matching entry wins:
+# market day on day >= 28, normal work otherwise) and an event-driven
+# transition (item-acquire interrupts work).
+id: daily-routine
+initial: sleeping
+states:
+  - id: sleeping
+  - id: working
+    onEnter:
+      - action: emit-event
+        event: behavior:daily-routine-working
+        payload:
+          state: working
+  - id: resting
+transitions:
+  - from: working
+    to: resting
+    on: item-acquire
+onSchedule:
+  - at: day-start
+    to: resting
+    when:
+      op: gte
+      path: payload.day
+      value: 28
+  - at: day-start
+    to: working

+ 32 - 0
content/statemachines/spring-quest.yaml

@@ -0,0 +1,32 @@
+# Example state machine (D15/D20: behavior language v2) — quest prototype.
+# State = quest stage (inactive -> active -> complete); transitions = event +
+# condition; rewards/state = actions. Demonstrates onEnter actions (give-item,
+# set-state) and the player.questProgress.<id> convention from boundaries.md.
+id: spring-quest
+initial: inactive
+states:
+  - id: inactive
+  - id: active
+    onEnter:
+      - action: give-item
+        itemId: parsnip-seed
+        qty: 1
+  - id: complete
+    onEnter:
+      - action: set-state
+        path: player.questProgress.spring-quest
+        value: complete
+transitions:
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      op: gte
+      path: time.day
+      value: 3
+  - from: active
+    to: complete
+    when:
+      op: gte
+      path: player.inventory.parsnip
+      value: 5

+ 9 - 0
content/time.yaml

@@ -0,0 +1,9 @@
+# Time configuration (D15: TimeDef). Day/season cadence for the simulation.
+id: default
+dayLengthTicks: 480
+seasons:
+  - { name: spring, dayCount: 28 }
+  - { name: summer, dayCount: 28 }
+  - { name: autumn, dayCount: 28 }
+  - { name: winter, dayCount: 28 }
+start: { day: 1, season: spring }

+ 34 - 0
content/weather.yaml

@@ -0,0 +1,34 @@
+# Weather probability table (D19: "roll from a probability table" is a
+# mechanism). On day-start the engine rolls a weighted pick from the current
+# season's table; weights are relative probabilities.
+id: default
+defaults:
+  sunny: { name: Sunny }
+  rain: { name: Rain }
+  wind: { name: Wind }
+  storm: { name: Storm }
+seasons:
+  - season: spring
+    weathers:
+      - { id: sunny, weight: 50 }
+      - { id: rain, weight: 30 }
+      - { id: wind, weight: 15 }
+      - { id: storm, weight: 5 }
+  - season: summer
+    weathers:
+      - { id: sunny, weight: 70 }
+      - { id: rain, weight: 15 }
+      - { id: wind, weight: 10 }
+      - { id: storm, weight: 5 }
+  - season: autumn
+    weathers:
+      - { id: sunny, weight: 40 }
+      - { id: rain, weight: 35 }
+      - { id: wind, weight: 20 }
+      - { id: storm, weight: 5 }
+  - season: winter
+    weathers:
+      - { id: sunny, weight: 20 }
+      - { id: rain, weight: 25 }
+      - { id: wind, weight: 40 }
+      - { id: storm, weight: 15 }

Dosya farkı çok büyük olduğundan ihmal edildi
+ 61 - 0
docs/api.md


+ 239 - 0
docs/architecture.md

@@ -0,0 +1,239 @@
+# Architecture
+
+Top-level architecture for the farming engine. See `decisions.md` for the WHY behind every choice here; `mcp.md` is the interface contract (MCP-first); `api.md` maps engine internals to that contract.
+
+## System Topology
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│                    Headless Node Core (authority)             │
+│                                                              │
+│   Content (YAML)  ──►  Loader ──►  Normalize ──►  zod        │
+│                                                              │
+│                        ┌──────────────┐                      │
+│   World state ◄──────  │  Systems     │  (pure functions     │
+│                        │  (light ECS) │   over entity data)  │
+│                        └──────────────┘                      │
+│            ▲             ▲        ▲                          │
+│            │  Hooks      │        │  Mod registry            │
+│        ┌───┴────┐   ┌────┴───┐  ┌─┴────────┐                │
+│        │ Mods   │   │ MCP    │  │ Content  │                │
+│        │ (Hook) │   │ Gateway│  │ Registry │                │
+│        └────────┘   └────┬───┘  └──────────┘                │
+└──────────────────────────┼───────────────────────────────────┘
+                           │ stdio / streamable HTTP
+              ┌────────────┴─────────────┐
+              │                          │
+   AI agents / dev tools          Browser viewport (PixiJS)
+   (creation + debug)             (render-only, WebSocket)
+```
+
+**Authority flow**: content data defines the world → core simulates it → MCP exposes it to agents, WebSocket syncs it to the browser. The browser never owns state; it renders snapshots.
+
+## Package Boundaries (monorepo)
+
+| Package | Responsibility | DOM deps | Runtime |
+|---|---|---|---|
+| `@nengine/core` | World simulation, content loader, systems, entity store, Mod loader, hook registry | none | Node / browser (headless) |
+| `@nengine/render` | PixiJS adapter: world → sprites (spriteRef drawing + shape fallback, D27), Web Audio playback, viewport sync + input over WebSocket | yes | Browser |
+| `@nengine/mcp` | Central MCP gateway, namespace registration layer, transport (stdio + HTTP) | none | Node |
+| `apps/dev` | Vite dev app: boots core + render + MCP server together | yes | Browser + Node |
+| `apps/mcp-server` | Standalone stdio entry: headless core + MCP gateway + optional WS world-sync channel, no render | none | Node |
+| `apps/pack` | **Packaging CLI (`nengine-pack`)**: compiles creator content (YAML) into a content bundle (`bundle.json`) + a self-contained static site (D30) | none | Node (build-time) |
+| `content/` | **Example/authoring content**: YAML data files, not code | — | — |
+
+Tooling: pnpm workspaces, TypeScript strict, vitest. Zod for schema validation.
+
+## Directory Layout
+
+```
+Engine/
+├── AGENTS.md
+├── docs/
+│   ├── decisions.md          # WHY (source of truth)
+│   ├── architecture.md       # this file
+│   ├── mcp.md                # MCP interface contract
+│   └── api.md                # engine API reference (living)
+├── content/                  # YAML content (authoring surface)
+│   ├── items/
+│   ├── crops/
+│   ├── maps/
+│   └── ...
+├── packages/
+│   ├── core/
+│   │   ├── src/
+│   │   │   ├── engine.ts         # Engine facade
+│   │   │   ├── world/            # entity store, world state
+│   │   │   ├── content/          # YAML loader → normalize → validate
+│   │   │   ├── systems/          # Time, Planting, Inventory, ...
+│   │   │   ├── mods/             # Mod loader, hook registry, ctx types
+│   │   │   ├── schema/           # zod schemas (normalized JSON shape)
+│   │   │   └── registry/         # per-subsystem registration layer
+│   │   └── test/
+│   ├── render/
+│   │   └── src/
+│   │       ├── viewport.ts       # PixiJS app bootstrap
+│   │       ├── sync/             # WebSocket state sync
+│   │       └── sprites/          # entity → sprite adapters
+│   └── mcp/
+│       ├── src/
+│       │   ├── gateway.ts        # central gateway
+│       │   ├── namespace.ts      # registration contract
+│       │   ├── transport/        # stdio, streamable HTTP
+│       │   └── namespaces/       # one dir per subsystem namespace
+│       └── test/
+└── apps/
+    ├── dev/                      # Vite: core + render + MCP
+    └── mcp-server/               # stdio entry, headless
+```
+
+## Data Flow
+
+1. **Author**: creator writes YAML content (`content/crops/parsnip.yaml`).
+2. **Load**: core's content loader reads YAML, normalizes to internal JSON shape.
+3. **Validate**: zod schema validates the normalized JSON (strict; content cannot smuggle code).
+4. **Register**: validated content lands in the Content Registry, keyed by id.
+5. **Use**: systems read registry entries + entity data; Mods hook system boundaries.
+6. **Hot reload**: YAML change → re-load → re-validate → re-inject; world state preserved.
+
+The MCP surface **only ever sees normalized JSON** (D18). YAML is purely the authoring surface.
+
+## Presentation Data Flow (D26/D31)
+
+The presentation pipeline is split at the headless/render boundary (D1/D8): the **engine owns presentation data and events**; the render layer owns drawing and playback (round 2 — **complete, D27**). The flow:
+
+```
+Author (YAML) ──► Loader ──► zod ──► Registry (sprite.* / sound.* content kinds)
+                                          │
+   Author (content/assets/*.png|ogg) ─────┼─► asset table (registry.assets, D31:
+                                          │    id = filename w/o ext; base64 bytes
+                                          │    (fs/inline) OR metadata {size, file}
+                                          │    (split bundles, D37))
+                                          ▼
+   systems set entity spriteRef ────────► snapshot {world, sprites, sounds, assetsIndex}
+   play-sound action ──► sound-play {soundId}   +  event frames (input, player-intent, ...)
+                                          │
+   client input {"type":"input"} ─────────┘  key:Arrow* → IntentSystem.move (adapter)
+                                          ▼
+              Browser viewport (render layer, D27/D31/D37):
+              draw spriteRef frames from the catalog (sprite-draw.ts,
+              weighted 10 fps animation clock, building footprint scaling);
+              image layers draw via PixiJS textures (data: base64 URLs, or
+              fetched from the assetsBase for split entries — D37; cache by
+              asset id, async load + redraw-on-ready);
+              map tiles draw from map.tiles → image assets (palette fallback);
+              play sound defs on sound-play (synth OR src → decodeAudioData,
+              buffers cached by id); arrow-key keyboard input via sync.input
+```
+
+The contract between the two halves is: **entity `spriteRef` fields** (which sprite a crop stage / building / animal / the player is), the **`sprites`/`sounds` catalogs on the snapshot**, the **`assetsIndex` on the snapshot** (D37 — a metadata-only `{kind, mime, size}` index; the viewport requests the base64 bytes it needs via a WS `assets-request` and receives them in a `{type:"assets"}` frame, and in split-bundle mode fetches them from the assets base — bytes are a transport concern, not snapshot payload), the **`sound-play`** event (playback trigger), and the **`input`** bus event (raw input, broadcast-only unless the built-in arrow mapping applies). A missing `spriteRef` def or a missing image asset means the renderer falls back to its existing shape drawing — the pipeline degrades gracefully.
+
+## Core API Overview (facade)
+
+Engine internals are designed to *serve* the MCP surface (MCP-first, see `mcp.md`). The facade:
+
+```ts
+const engine = createEngine({ contentDir: "content" });
+
+await engine.start();          // load content, validate, boot systems
+engine.world;                  // World: entity store + queries
+engine.registry;               // Content Registry: item/crop/map lookups
+engine.hooks;                  // Hook Registry: onDayStart(ctx), ...
+engine.mcp;                    // MCP gateway handle (register namespaces)
+engine.systems;                // TimeSystem, PlantingSystem, ...
+await engine.stop();           // flush, persist if policy says so
+```
+
+Key interfaces (full signatures in `api.md`):
+
+- `System` — `init(ctx)`, `step(dt)`, optional `on(event, payload)`; pure functions over entity data.
+- `Entity` — flat schema-validated object: `{ id, type, ...componentFields }`.
+- `HookContext` — the ONLY object Mods touch; exposes a curated, read-mostly surface (world queries, event emit). Mods never import engine internals (D6).
+- `McpNamespace` — the registration contract every subsystem must fulfill (D9): `{ name, tools, resources, prompts }`.
+
+## Subsystem Template
+
+Every new subsystem is a plugin (D12):
+
+1. **Add schema** — zod schema for its content + entity component fields (`packages/core/src/schema/`).
+2. **Add system** — pure-function system operating on entity data (`packages/core/src/systems/`).
+3. **Register MCP namespace** — tools/resources/prompts under its namespace (`packages/mcp/src/namespaces/`). **Write `mcp.md` section first.**
+4. **Declare hook points** — typed hooks Mods can register against.
+
+## Tier 1 Subsystems
+
+| Subsystem | Content schema | Core system | MCP namespace | Hook points (initial) |
+|---|---|---|---|---|
+| Map/terrain | `map` YAML (tiles, spawn) | World/terrain access | `map.*` | — |
+| Time | `time` YAML (day length, seasons) | `TimeSystem` | `time.*` | `onDayStart`, `onSeasonChange` |
+| Planting | `crop` YAML (stages, water needs) | `PlantingSystem`, `GrowthSystem` | `crop.*` | `onCropHarvest`, `onCropGrow` |
+| Items/inventory | `item` YAML (tools, seeds) | `InventorySystem` | `item.*` | `onItemAcquire`, `onItemUse` |
+| Economy | `shop` YAML (buy/sell prices) | `EconomySystem` | `shop.*` | `onTransaction` |
+| Persistence | (runtime snapshot) | save/load + migrations | `save.*` | — |
+| Behavior language | embedded conditions/triggers | `TriggerSystem` | `behavior.*` | `onTriggerFire` |
+| Mods | `mod` manifest | Mod loader | `mod.*` (introspection only) | — |
+
+> `mod.*` MCP namespace is **introspection only** (list/status). No Mod↔MCP bridge (D10): MCP never invokes Mod-registered behavior, Mods never register MCP tools.
+
+## Transport & Deployment
+
+- **Dev**: `apps/dev` — Vite serves the browser viewport; a sidecar Node process hosts core + MCP gateway (stdio for the agent toolchain, HTTP for runtime debug).
+- **Headless**: `apps/mcp-server` — `node` entry, core + MCP, no render. Used for CI, tests, agent-driven content creation.
+- **Packaged game (D30/D32)**: `apps/pack` `nengine-pack --content <dir> [--out <dir>] [--desktop] [--name <name>]` compiles a game's content (YAML) into a self-contained static site — `out/index.html` + `out/assets/` + `out/bundle.json` — that runs in the browser via the engine's bundle mode (`createEngine({bundle})` + LocalSync). The bundle is the single source of truth for packaged content (no YAML ships; YAML parsing happens only at compile time). Mods are NOT bundled (server/authoring feature; browser module-import restrictions) — documented limitation. **Desktop (D32)**: with `--desktop`, the same command writes an **Electron shell** into `out/desktop/` (`main.cjs` + `package.json` + `README-desktop.md`) — a local HTTP server (127.0.0.1, ephemeral port) serving the out dir + a 960×640 `BrowserWindow` loading `index.html` (`cd out/desktop && npm i && npm start`). Electron was chosen over Tauri (Tauri needs `webkit2gtk-4.1` system deps; Electron is npm-only). No installer/electron-builder in v1; a sidecar headless core (Mods + multiplayer via the D24 WS channel) is a documented future path.
+- **Desktop sidecar core** (later, optional): headless core packaged alongside the shell, talking to the viewport over WebSocket (D7/D8/D24) — reserved for Mods + multiplayer in packaged output.
+
+### Packaging Pipeline (D30)
+
+```
+content/ (YAML) ──► nengine-pack ──► out/
+                                    ├── bundle.json   # {format:"nengine-bundle",version:1,content:{kind:[defs]}, assets|assetsIndex}
+                                    ├── index.html    # the built apps/dev app, defaults to the bundle (no URL params)
+                                    ├── assets/       # JS (pixi + engine + dev app)
+                                    ├── nengine-assets/  # D37: --split-assets → one file per asset (no base64 in bundle.json)
+                                    └── desktop/      # D32: Electron shell (main.cjs + package.json + README)
+                                          │  served statically (npx serve / python http.server)
+                                          ▼
+                                   browser: fetch bundle.json → createEngine({bundle})
+                                   → loadBundle (re-validate per kind) → LocalSync → full playability
+
+                                   desktop/ (D32): main.cjs starts a node:http server (127.0.0.1,
+                                   ephemeral port) on the out dir and opens it in a BrowserWindow
+                                   (960×640, contextIsolation on, nodeIntegration off) — the same
+                                   bundle-mode page, zero Node access from the page. npm i && npm start.
+```
+
+`nengine-pack` steps: (a) fs-load + validate the content dir via the core `ContentLoader` (per-item zod; any error → per-kind/per-id messages, exit 1); (b) shape `bundle.json`; (c) programmatic vite build of the `apps/dev` app with the baked bundle default (`__NENGINE_BUNDLE_DEFAULT__`) and `base: "./"` into the out dir; (d) write `bundle.json`; (e) with `--desktop`, `generateDesktop` writes the Electron shell into `out/desktop/`. The dev app reuses the SAME UI in `?bundle=` mode (fetch the bundle over HTTP, fs shim bypassed). The packaged game is created-content-only — the engine provides the mechanism, the game is just data; no gameplay logic in the pack tool.
+
+### `apps/mcp-server` entry options
+
+The stdio entry serves **any** content/mods pair, not just the monorepo's own `content/`/`mods/`. Roots resolve in precedence order CLI > env > default; an optional WebSocket world-sync channel (D8/D24) starts in the same process:
+
+| Source | Flag / var | Default |
+|---|---|---|
+| CLI | `--content-dir <path>` / `--mods-dir <path>` / `--ws-port <port>` | — |
+| Env | `NENGINE_CONTENT_DIR` / `NENGINE_MODS_DIR` / `NENGINE_WS_PORT` | — |
+| Default | — | `content/`, `mods/` at the monorepo root; WS channel off |
+
+Relative CLI/env paths resolve against `process.cwd()`. Zero-arg invocation behaves exactly as before (serves the monorepo's own content, no WS channel). Example — serve the sample game with a viewport channel:
+
+```sh
+node apps/mcp-server/dist/index.js \
+  --content-dir examples/sample-game/content \
+  --mods-dir examples/sample-game/mods \
+  --ws-port 8787
+```
+
+The wiring (`resolveDirs(argv, env, cwd)` → `createEngine` → `createNengineMcpServer` + `startWsBroadcast`) is exported from `apps/mcp-server/src/index.ts` and auto-runs on import only when the module is executed directly, so tests can drive it in-process.
+
+## Sync Layer (D8/D24/D25)
+
+Headless core authoritative; browsers are render viewports synced over WebSocket. A single `apps/mcp-server` process serves BOTH surfaces: the MCP stdio gateway (agents create/debug content) and, when `--ws-port` is set, a WebSocket broadcast server (`apps/mcp-server/src/ws-broadcast.ts`) that subscribes to the engine's event bus (via the facade's `engine.events`).
+
+- **Snapshot**: on connect (and on a `{"type":"sync"}` client message) the server sends `{"type":"snapshot","map":MapDef,"world":Entity[],"time":{day,season,tickInDay},"sprites":SpriteDef[],"sounds":SoundDef[],"assetsIndex":...}` — the map def included because viewports need it to build tiles; world entities alone lack it; the `time` field (from `TimeSystem.get()` at broadcast time, D25) lets viewport HUDs show day/season immediately instead of waiting for an event frame; the `sprites`/`sounds` catalogs (from `registry.list` at snapshot time, D26) carry the programmatic draw/audio instructions the viewport needs to present the world in-band (no second request); the **`assetsIndex`** (D37) is metadata-only `{kind, mime, size}` — bytes travel on demand via a `{"type":"assets-request", ids}` message answered with a `{"type":"assets"}` frame (see `docs/mcp.md`).
+- **Events**: every `WorldEventBus` emission is broadcast to ALL connected clients as `{"type":"event","event":string,"payload":unknown}`. Payloads are JSON-serialized defensively (non-serializable payloads are skipped with a log, never a crash).
+- **Input (D25/D26)**: a client may send `{"type":"intent","action":"move"|"interact","args":{tile:{x,y}}}`. The server parses it, calls `IntentSystem.handle(action, args)` (the same mechanism MCP `player.intent` and `connectLocal.intent` use), and the resulting `player-intent` bus emission is broadcast as an event frame. Malformed input never crashes the server — a synthesized `player-intent` error frame is sent instead. A client may also send `{"type":"input","input":string}` (D26) — a **raw input string** (`"key:ArrowUp"`, `"key:Space"`, `"pointer:click:<x>:<y>"`): the server emits an **`input`** bus event `{input}` broadcast as an event frame (behavior-language-matchable `on:"input"`), and maps `key:ArrowUp`/`ArrowDown`/`ArrowLeft`/`ArrowRight` to `IntentSystem.handle("move", {tile})` for the tile one step from the player's **current** position (player-centered; the mapping is adapter-level — the `handle` mechanism is untouched, D25). Unknown keys/prefixes are broadcast-only; a non-string/empty `input` never crashes (a synthesized `input` error frame is broadcast instead).
+- **Client**: `packages/render/src/sync/ws.ts` `connectWs({url, viewport, onEvent?, onSnapshot?, sound?})` — browser-native `WebSocket`, same handle shape as `LocalSyncHandle` plus `intent(action, args)` (sends the intent message) and `input(input)` (sends the raw `{"type":"input",input}` message), auto-reconnect, time label derived from `snapshot.time` AND `day-start`/`time-tick`-style event payloads, fresh-snapshot re-sync after each event frame. The optional `sound` player (D27) is fed the `sounds` catalog from every snapshot and receives `sound-play` frames directly.
+- **Drawing (D27)**: `viewport.render(snapshot?)` draws ground + spawn marker from the map def, then per entity: a resolvable `spriteRef` draws the referenced sprite frame (see `sprite-draw.ts` — pure frame/draw-command mapping; plots keep their shape base with the sprite on top, other sprite entities replace their shape); otherwise the `shapes.ts` shape fallback (plots tilled/watered/crop-stage, buildings as footprint blocks, npc/player/animal dots) — absent snapshot → map-only, the walking-skeleton behavior. Animated sprites (>1 frame) advance on a renderer-side `requestAnimationFrame` clock where `durationTicks` weights each frame against a documented default 10 fps; buildings draw their sprite scaled to the entity's `w`×`h` tile footprint. `viewport.tileAt(clientX, clientY)` converts screen coordinates to a tile (accounting for the centered map offset + 32px tile size; `null` outside the map) for canvas-click input.
+- **Sound (D27)**: `sound.ts` `createSoundPlayer` synthesizes `sound` defs on `sound-play {soundId}` frames (Web Audio `OscillatorNode`; wave/freq/durationMs/gain + `slideTo` ramp). The `AudioContext` is created lazily on the first play or `prime()` call from a user gesture (browser autoplay policy); playback is headless-silent and errors are swallowed.
+- **Local sync**: `connectLocal(engine, viewport, {sound?})` renders a full snapshot (map + entities + both catalogs) from the engine on refresh, delegates `intent(action, args)` straight to the engine's `IntentSystem`, maps `input(input)` arrow keys client-side (`arrowDirection` → one tile from the player → `intent("move", ...)`), and feeds the sound player from the engine bus — so the dev app is playable in BOTH local and ws modes with one UI.
+- **Dev app**: `apps/dev` is parameterized via URL query — `?contentDir=`/`?modsDir=` (default repo `content/`/`mods/` + LocalSync), or `?sync=ws&url=ws://localhost:8787` which connects to a headless core instead of booting one in the browser. An Interact/Move toolbar selects the tool; clicking the canvas sends `tileAt → sync.intent`; arrow keys send `sync.input("key:Arrow<…>")` (moves the player in both modes); `pointerdown`/keydown prime the audio context; the HUD shows day/season, seed counts + gold (from the player entity), and auto-clearing intent feedback (from `player-intent` event detail).

+ 82 - 0
docs/boundaries.md

@@ -0,0 +1,82 @@
+# Engine / Game Boundary
+
+Where the engine ends and the game begins. Produced by a grilling session over the question: *"What should a game engine do, and what should be left to the game itself?"*
+
+> **Source of truth for WHY a feature is (or is not) engine-built.** Complements `decisions.md` (recorded as D19). Changes to this file must be reflected in `decisions.md` in the same change (D-op2).
+
+## The Criterion
+
+A feature is built into the engine **iff** all three of these hold simultaneously:
+
+1. **Dataization value** — the feature is meaningfully expressible as content data (YAML), so creators can author it without code.
+2. **Mod hook demand** — gameplay-relevant subsystems need typed hook points Mods can register against.
+3. **MCP manipulability** — agents/creators need tools to introspect or drive the feature.
+
+**Failure of any one → push the feature down** (to fields + behavior language, or to the render layer / Mods entirely).
+
+## Ownership Model
+
+| Layer | Contents | Owner |
+|---|---|---|
+| **L0 Infrastructure** | Content pipeline, world/entity store, system host, event bus, Mod loader, MCP gateway, save serialization | Engine (built) |
+| **L1 Simulation mechanisms** | General mechanisms passable by the criterion — dialogue, weather, placement, movement primitives | Engine (judged case-by-case) |
+| **L2 Expression layer** | Behavior data language (conditions v1, state machines v2) — the escape hatch for most game logic | Engine (built) |
+| **L3 Game strategy** | What to build, when to save, what prices to charge, what quests exist, minigames | Creators (100%) |
+
+## Feature Verdicts
+
+| Feature | Verdict | Where it lives | Rationale |
+|---|---|---|---|
+| NPC | Movement primitive **in engine**; schedules → behavior language v2 | `npc` entity + `moveTo(x,y)`; schedules = time-triggered state machines | Schedules are state machines; a dedicated schedule system re-invents the state machine |
+| Behavior language v2 (state machines) | **Engine, Tier 2 lead item** | v2 = v1 triggers + state machine syntax + `onTime`/`onSchedule` primitives | Critical path for NPC schedules, quests, events |
+| Dialogue/story | **Engine system** | `dialogue` content (nodes/options/conditions), `DialogueSystem`, MCP `dialogue.*`, hooks `onDialogueStart`/`onDialogueEnd` | Dialogue is a content document, not a state machine; AI-authored dialogue is a core creation scenario (all three criteria met) |
+| Friendship/affection | **Field-level, data layer** | `npc.affection` entity field; read/write via behavior language | Deterministic numeric rule — zero new engine code |
+| Quests/events | **Behavior language v2** | State = quest stage; transitions = events/conditions; actions = rewards (`give-item`); progress in `player.questProgress.<id>`; authoring via `behavior.*` MCP | Quests are isomorphic to state machines; a `QuestSystem` re-invents the state machine |
+| Weather | **Lightweight engine system** | `world.weather` field, `content/weather.yaml` (season→weather probability table), roll on day-start, `weather-change` event, hook `onWeatherChange` | "Roll a probability table" is a mechanism; the behavior language has no randomness |
+| Stamina/fatigue | **Field-level BUILT + demo'd (D29)** | `player.stamina` field **seeded by the engine** (`seedWorld`: `stamina: 100`); rules via behavior language + Mods — the sample-game's `daily-stamina` machine restores stamina on day-start (`set-state`) and alerts on a **condition** (`player.stamina lte 30`, matching the fishing Mod's drain); the fishing Mod deducts stamina per cast | "Insufficient → block" is strategy; mechanism-izing it is brittle. No `StaminaSystem` |
+| Buildings/placement | **Placement primitive in engine** | `PlacementSystem`: grid occupancy (footprint, overlap checks, buildability read from map data), place/remove `type:"building"` entities; building definitions (footprint/appearance/function) are data | Grid occupancy + overlap validation is geometric mechanism the behavior language can't express |
+| Animals | **Reuse existing mechanisms + data** | `type:"animal"` entity + `animal` schema (growth cycle/output table/feed needs); GrowthSystem generalized to `age` field; output/feed rules via behavior language | Animals are isomorphic to crops — no new system |
+| UI/HUD | Engine exposes **UI logic state**, render layer draws | Engine tracks current dialogue node / inventory / shop prices / quest progress, exposed via MCP + WebSocket; render layer draws freely | Zero UI-framework code in engine |
+| Presentation data (sprites/sounds) | **BUILT (D26 data/protocol + D27 render half + D31 asset pipeline)** | `sprite` content kind (`content/sprites/`, programmatic draw instructions — rect/circle layers + the D31 `image` layer `{src: assetId, x?, y?, w?, h?}` referencing `content/assets/` binaries) + `sound` content kind (`content/sounds/`, synth specs + D31 `src` audio asset references) + MCP `sprite.*`/`sound.*` + optional `spriteRef` appearance hints on crop/animal stages, buildings, and the player; **binary assets (`content/assets/`) are NOT a content kind — a plain file table (`registry.assets`/`engine.assets`, base64) that YAML references by id** (D31); both catalogs + the asset table ride the WS snapshot; **render layer consumes them (D27/D31)** — `packages/render` draws `spriteRef` frames (pure `sprite-draw.ts`: `durationTicks`-weighted animation clock at a documented default 10 fps, building footprint scaling, `image` commands with source-crop), draws `map.tiles` image textures (palette `tileColor` fallback), and plays `sound` defs on `sound-play` (synth via Web Audio OR `src` via `decodeAudioData`, lazy AudioContext) | Presentation is content-as-data (D4); the render layer consumes the draw instructions / synth specs / asset table / `sound-play` events and plays. Assets are filesystem content referenced by id — no schema, no MCP namespace (D31) |
+| Audio | **Event mechanism BUILT (D26) + playback BUILT (D27/D31)** | `play-sound` action emits the **`sound-play`** bus event `{soundId}` (behavior-language-matchable, broadcast as an event frame); `sound` defs (`content/sounds/`) carry the synth spec and/or a D31 `src` audio asset id; the render layer's `createSoundPlayer` (`packages/render/src/sound.ts`) synthesizes the `synth` spec (OscillatorNode wave/freq/durationMs/gain/`slideTo` ramp) or decodes the `src` asset (base64 → `decodeAudioData` → `AudioBufferSourceNode`, buffers cached by id, `src` takes precedence over `synth`; AudioContext created lazily on the first user gesture per the autoplay policy) | Zero audio-library code in engine; playback is render-layer freedom (D1) |
+| Multiplayer | **Multi-viewport mechanism BUILT (D8/D24)**; multiplayer gameplay blank | N WebSocket clients subscribe to world state (`render/sync` BUILT 2026-08-12: `apps/mcp-server --ws-port` broadcast channel + `connectWs` browser client + snapshot-driven viewport drawing); multiplayer gameplay (multiple player entities, trading, permissions) left to creators | Multi-viewport sync is a mechanism; multiplayer *rules* are game content |
+| Skills/proficiency | **Field-level BUILT + demo'd (D29)** | `player.skills.{mining,farming,...}` fields **seeded by the engine** (`skills: {}`); accumulation via `set-state`/Mod mutation + `gte` threshold unlocks — the sample-game's fishing Mod increments `player.skills.fishing` per catch | Same shape as stamina/affection; the D29 fishing Mod is the accumulation demonstration |
+| Movement primitives | **Grid movement + blocking check** | `moveTo` tile-to-tile, simple blocking (non-walkable tiles, occupying entities), `npc-move` event with from/to | Pathfinding (A*/BFS) and collision systems stay out for now |
+| Player input | **Intent primitive BUILT (D25) + keyboard BUILT (D27) + `onPlayerIntent` hook (D29)** | `IntentSystem` (id `"intent"`): `handle(action, args)` — `move` (player to a walkable tile, shared map-grid/placement helpers) and `interact` (smart plot action: harvest mature → water planted → sow first seed → till → error), `player-intent` bus event per call (success AND failure). Channels: MCP `player.intent`, WS `{"type":"intent"}`, `connectLocal.intent` — all thin adapters over the same mechanism. **Hook (D29)**: `player-intent` → `onPlayerIntent` (16th hook; reverses D25.4's bus-only stance — Mods can't subscribe to raw bus events, and the intent payload is transient, so the "Mod hook demand" arm holds; the fishing Mod is the consumer). **Keyboard (D27)**: the dev app's arrow-key `keydown` → `sync.input("key:Arrow<…>")` — ws mode sends the D26 raw `input` message (server maps arrows → `IntentSystem.move`), local mode maps the same `arrowDirection` delta client-side | Interact priority order is the v1 engine default (content-izable later); a seed picker is future UI; the browser toolbar UI is render-layer freedom (D24) |
+| Math / randomness | **Seeded RNG mechanism BUILT (D28)** | `RandomSource` (`packages/core/src/random.ts`, mulberry32-style mirroring LÖVE's `love.math.RandomGenerator`): `seedRandom(seed)` / `randomSeed()`; engine-owned `engine.rng` (seeded via `createEngine({seed?})`), injected into systems via `SystemInitCtx.rng` — WeatherSystem day-start rolls and PlantingSystem harvest drop chances draw from it; MCP `rng.*` debug surface (`rng.get`/`rng.set`/`rng://state`); `save.*` carries `rngState {seed, calls}` and restore re-anchors the seed | Determinism is a mechanism (replay/tests/Mods); "draw from the engine RNG" replaces scattered `Math.random` calls. Declined LÖVE gaps (D28 adjudication): text rendering / particles → render-layer freedom; **image-atlas + audio files → ADOPTED (D31)** — the asset pipeline builds them as `content/assets/` binaries referenced by id (sprite `image` layer, `map.tiles`, `sound.src`); physics/threading/compression/window/video/sensor/joystick → not applicable; Tauri → deferred |
+| Minigames (fishing/mining/festivals) | **Mod / render layer — fishing demo'd (D29)** | Engine guarantees trigger conditions + reward settlement (`give-item`) + progress (entity fields) via data + language; the minigame **body lives in the Mod** — the sample-game's fishing Mod hooks `onPlayerIntent`, settles the catch on water tiles (fish grant, stamina cost, `skills.fishing`, `mod:fish-caught`/`mod:fish-too-tired` events the language matches), and the fishing-treat machine turns `mod:fish-caught` into a reward | No built-in minigame framework; D29 is the escape-hatch demonstration (D5/D19) |
+
+## Pattern Cheat-Sheet
+
+| Feature kind | Destination |
+|---|---|
+| Content document (dialogue, shops, crops) | Engine system + content schema |
+| Random mechanism (weather rolls, drop chances) | Engine system + **shared seeded RNG (D28)** — `engine.rng` via `SystemInitCtx.rng` |
+| Geometry/spatial (placement, movement) | Engine primitive |
+| Temporal state (schedules, quests) | Behavior language v2 (state machines) |
+| Deterministic numeric + rules (affection, stamina, skills) | Entity fields + behavior language |
+
+## Tier 2 Build List (derived from this session)
+
+1. ~~**Behavior language v2** — state machine syntax + `onTime`/`onSchedule` triggers (lead item; critical path for NPC schedules/quests/events)~~ **BUILT (2026-08-12)**: `statemachine` content kind + `behavior.statemachine.*` MCP tools + `onSchedule` time primitive + `onStateChange` hook. See `decisions.md` D20, `mcp.md` §behavior, `api.md` §4/§7.
+2. ~~**Dialogue system** — `dialogue` schema + `DialogueSystem` + MCP `dialogue.*` + `onDialogueStart`/`onDialogueEnd` hooks~~ **BUILT (2026-08-12)**: `dialogue` content kind (`content/dialogues/<id>.yaml`) + `DialogueSystem` (active state on the `dialogue:active` world entity) + MCP `dialogue.*` (7 tools incl. debug `start`/`choose` + 2 resources + 1 prompt) + `onDialogueStart`/`onDialogueEnd` hooks. See `decisions.md` D21, `mcp.md` §dialogue, `api.md` §4/§7.
+3. ~~**WeatherSystem** — `weather.yaml` + `weather-change` event + `onWeatherChange` hook~~ **BUILT (2026-08-12)**: `weather` single-file content kind (`content/weather.yaml`) + `WeatherSystem` (weighted roll on day-start, injectable RNG, state on the `weather` world entity) + `weather-change` event + `onWeatherChange` hook + MCP `weather.*` (2 debug tools + 1 resource). See `decisions.md` D21, `mcp.md` §weather, `api.md` §4/§7.
+4. ~~**PlacementSystem** — grid occupancy (footprint/overlap/buildability)~~ **BUILT (2026-08-12)**: `building` content kind (`content/buildings/<id>.yaml`) + `PlacementSystem` (grid footprint/overlap/buildability-from-map-data, place/remove `type:"building"` entities) + `placement-change` event + `onPlacementChange` hook + MCP `placement.*` (5 building lifecycle tools + 4 debug tools + 3 resources + 1 prompt). See `decisions.md` D22, `mcp.md` §placement, `api.md` §4/§7.
+5. ~~**NPC grid-movement primitive** — `moveTo` + blocking check + `npc-move` event~~ **BUILT (2026-08-12)**: `MovementSystem` (id `movement`) — `moveTo` tile-to-tile with a simple blocking check (unwalkable "water" tiles, `blocksMovement` buildings via `PlacementSystem.occupies`) + `npc-move` event with from/to + MCP `npc.*` (5 debug tools + 1 resource, no prompts). No pathfinding (A*/BFS), no collision system, no NPC schedules (those are behavior language v2 content). See `decisions.md` D22, `mcp.md` §npc, `api.md` §4.
+6. ~~Reuse/generalization: GrowthSystem generalized to animal `age`~~ **BUILT (2026-08-12)**: `animal` content kind (`content/animals/<id>.yaml` — growth cycle + produce table, data only) + GrowthSystem generalized to advance BOTH `type:"plot"` (CropDef) and `type:"animal"` entities (AnimalDef) — animal entities carry `{animalId, stageIndex?, daysInStage?, fed?}`, `feedRequired` mirrors `waterRequired`, `feed` mirrors `water`; feed/produce RULES are behavior-language content hooking the `animal-grow`/`animal-produce`/`animal-fed` bus events; hooks `onAnimalGrow`/`onAnimalProduce`; MCP `animal.*` (9 tools + 2 resources + 1 prompt). See `decisions.md` D23, `mcp.md` §animal, `api.md` §4/§7. **Tier 2 core is now complete** (items 1–6 with the tick loop from Task A).
+7. ~~**Multi-viewport sync (`render/sync`)** — optional render-layer extension~~ **BUILT (2026-08-12, D24)**: the final Tier 2 item. `apps/mcp-server` gains an optional `--ws-port <port>`/`FARM_WS_PORT` WebSocket broadcast channel in the same process (snapshot on connect + on `{"type":"sync"}`, event frames per bus emission, defensive JSON serialization); `packages/render/src/sync/ws.ts` `connectWs` (browser-native `WebSocket`, same handle shape as `connectLocal`); `viewport.render(snapshot?)` draws map + basic entity shapes (pure `shapes.ts` geometry — plots/buildings/dots, no art); `apps/dev` parameterized (`?contentDir=`/`?modsDir=`/`?sync=ws&url=ws://...`). MCP surface unchanged. See `decisions.md` D24, `mcp.md` transport section, `api.md` §1. **Tier 2 is now fully complete.**
+
+## Change Log
+
+| Date | Change | Files |
+|---|---|---|
+| 2026-08-13 | **Asset pipeline BUILT (D31) — the D28 "image-atlas + audio files" gap is adopted, closing the D26-reserved atlas/audio-file capability.** Presentation data row: `sprite` gains the `image` layer (`src` = asset id), `sound.src` becomes a real audio asset id, `map` gains `tiles` (tile-id → asset id); **binary assets (`content/assets/`, `.png`/`.ogg`) are NOT a content kind** — the loader builds an asset table (`registry.assets`/`engine.assets`, base64) that YAML references by id; the table rides the bundle and the WS snapshot. Render: image layers draw via PixiJS textures (data: URLs, cache by id, async + redraw-on-ready), map tiles draw from `map.tiles`, `sound.src` plays via `decodeAudioData` (buffers cached). Audio row: src playback added (`src` takes precedence over `synth`). Math/randomness row: the D28 declined "image-atlas + audio files" verdict flips to **ADOPTED (D31)** — no atlas format (single-tile assets by id, sprite-sheet atlas documented as future). MCP surface unchanged — no `assets.*` namespace (D31.8). | `boundaries.md`, `decisions.md` (D31), `mcp.md`, `api.md`, `AGENTS.md`, `examples/sample-game/README.md` |
+| 2026-08-12 | **Tier 3 content round (D29): stamina/skills field-level rows marked BUILT + demo'd, minigame row demo'd, `onPlayerIntent` hook adjudicated.** Stamina/fatigue and Skills/proficiency verdict rows flip from "Field-level, data layer" to **Field-level BUILT + demo'd (D29)**: the engine seeds `player.stamina: 100` and `player.skills: {}` (data, not systems); the sample-game's `daily-stamina` machine demonstrates both a **condition** on `player.stamina` (low-stamina alert) and a day-start restore (`set-state`), and the fishing Mod accumulates `player.skills.fishing`. Minigames row flips to **Mod / render layer — fishing demo'd (D29)**: the fishing Mod hooks the new `onPlayerIntent` (16th hook — see the player-input row) and settles catches on water tiles (fish grant, stamina cost, `mod:fish-caught`/`mod:fish-too-tired` custom events the behavior language matches via `fishing-treat`). Player-input row gains the D29 hook adjudication. | `boundaries.md`, `decisions.md` (D29), `mcp.md`, `api.md`, `AGENTS.md`, `examples/sample-game/README.md` |
+| 2026-08-12 | **Deterministic seeded RNG BUILT (D28) — the ONE gap adopted from the engine-vs-LÖVE grill; all other LÖVE gaps consciously declined and recorded.** New "Math / randomness" verdict row (mechanism BUILT): `RandomSource` (`packages/core/src/random.ts`, mulberry32-style mirroring `love.math.RandomGenerator` — `seedRandom(seed)`/`randomSeed()`, `next()`/`nextInt(max)`/`setSeed`/`state(): {seed, calls}`); engine-owned `engine.rng` (`createEngine({seed?})`) injected into systems via `SystemInitCtx.rng` — WeatherSystem day-start rolls and PlantingSystem harvest drop chances now draw from it (the only two randomness consumers); MCP `rng.*` debug namespace (18th: `rng.get`/`rng.set`/`rng://state`, no prompts); `save.*` snapshot carries `rngState {seed, calls}` and restore re-anchors the seed (`calls` reset to 0, draw replay not built). The cheat-sheet's random-mechanism row now points at the shared seeded RNG. Declined gaps (adjudicated in D28, NOT built): text rendering + particles → render-layer freedom (D1/D6); image/atlas + audio files → reserved Tier 3 (D26 maintained); physics → grid-blocking covers collision; threading/compression/window/video/sensors/joystick → not applicable; Tauri → deferred. | `boundaries.md`, `decisions.md` (D28), `mcp.md`, `api.md`, `AGENTS.md` |
+| 2026-08-12 | **Player input mechanism BUILT (D25)** — the engine's first direct player-input primitive (previously input was MCP-debug-only per D3's "runtime MCP is debug-only"; D25 adds the *mechanism* for human play while keeping MCP as one debug adapter). `IntentSystem` (id `"intent"`, auto-booted): `handle(action, args)` for `move {tile}` (walkability + blocking via the shared map-grid/placement helpers, NOT `MovementSystem.moveTo` — npc-move untouched) and `interact {tile}` (v1 priority order harvest → water → sow-first-seed → till → error, delegating to `PlantingSystem`); every call emits `player-intent` (success AND failure). Three thin channels over the same mechanism: MCP `player.intent` + `player://state` (15th namespace), WS `{"type":"intent"}` message + snapshot gains `time`, `connectLocal.intent`. Sync handles are now uniform (`intent`/`setTimeLabel`/`refresh`/`destroy`); `viewport.tileAt` + an Interact/Move toolbar make the dev app playable in both local and ws modes. The interact priority order is the v1 hardcoded engine default (content-izable later); the browser toolbar/HUD is render-layer freedom (D24 — the engine exposes the intent result + world state, the render layer draws). | `boundaries.md`, `decisions.md` (D25), `mcp.md`, `api.md`, `architecture.md`, `AGENTS.md` |
+| 2026-08-12 | **Tier 2 item 7 built: multi-viewport sync (`render/sync`)** — Tier 2 fully complete. Item 7 (previously "optional render-layer extension") is now BUILT per D24: `apps/mcp-server` hosts an optional `--ws-port <port>`/`FARM_WS_PORT` WebSocket broadcast channel in the same process (snapshot on connect/`sync` request; event frames broadcast to all clients per bus emission; defensive JSON serialization — non-serializable payloads skipped, never a crash); `packages/render/src/sync/ws.ts` `connectWs` implements the `LocalSyncHandle` shape client-side (browser-native `WebSocket`, auto-reconnect, fresh-snapshot re-sync per event frame, time label from `day-start`/`time-tick`-style payloads); `viewport.render(snapshot?)` now draws map + basic entity shapes (pure `shapes.ts` geometry: plots tilled/watered/crop-stage, buildings as footprint blocks, npc/player/animal dots — no art); `apps/dev` parameterized via `?contentDir=`/`?modsDir=`/`?sync=ws&url=ws://...`. The engine event bus is exposed as `engine.events` (readonly) for the broadcast subscriber — the only core change. MCP surface unchanged (85 tools / 22 resources / 10 prompts / 14 namespaces); the WS channel is NOT a namespace. Multiplayer gameplay (multiple players, trading, permissions) remains creator content. | `boundaries.md`, `decisions.md` (D24), `mcp.md`, `api.md`, `architecture.md`, `AGENTS.md` |
+| 2026-08-12 | **Tier 2 items 5 & 6 (tick loop + animal age) built** — Tier 2 core complete. Item 5's "fine-grained intra-day timing awaits a tick loop" limitation is now resolved: `engine.tick(dt)` (dt in ticks) is the authoritative explicit driver (D8 — no render loop), driving TimeSystem (boundary events + new `time-tick` `{day, season, tickInDay}` per step) and every system's `step` in registration order (GrowthSystem accumulates day-fractions); `onSchedule` gains `at: {tickInDay: n}` evaluated on `time-tick`; optional auto-tick via `createEngine({tickIntervalMs})` (OFF by default). Item 6's animal generalization: `animal` content kind + GrowthSystem advancing `type:"animal"` entities through AnimalDef stages with a `fed` flag mirroring `watered`; feed/produce rules remain behavior-language content (decision #9 — engine emits `animal-grow`/`animal-produce`/`animal-fed`, grants nothing); MCP `animal.*` namespace + hooks `onAnimalGrow`/`onAnimalProduce`. The last remaining Tier 2 item is the optional multi-viewport sync (`render/sync`), which is a render-layer concern. | `boundaries.md`, `decisions.md` (D23), `mcp.md`, `api.md` |
+| 2026-08-12 | Initial draft (grilling session: engine vs game boundary) | `boundaries.md`, `decisions.md` (D19), `AGENTS.md` |
+| 2026-08-12 | **Tier 2 item 1 built: behavior language v2** (state machine syntax + `onSchedule` time primitive). The "temporal state → behavior language v2" row of the pattern cheat-sheet now has a concrete mechanism: schedules/quests/events are content state machines. NPC schedules and quests remain creator content built on this language (no dedicated schedule/quest system — per D19, a dedicated system would re-invent the state machine). Fine-grained intra-day timing still awaits a tick loop (documented limitation in `mcp.md`/`api.md`). | `boundaries.md`, `decisions.md` (D20), `mcp.md`, `api.md` |
+| 2026-08-12 | **Tier 2 items 2 & 3 built: dialogue + weather.** Dialogue is a content-document system (nodes/choices/conditions — a document, not a state machine, per D19), confirming the "content document → engine system" cheat-sheet row; weather is a lightweight probability-roll system ("roll from a probability table" is a mechanism; the behavior language has no randomness). Both snapshot via world entities (`dialogue:active`, `weather`). | `boundaries.md`, `decisions.md` (D21), `mcp.md`, `api.md` |
+| 2026-08-12 | **Tier 2 items 4 & 5 built: placement + NPC grid movement.** Both confirm the "geometry/spatial → engine primitive" cheat-sheet row — they are the two final Tier 2 engine subsystems. Placement: `building` content kind (footprint/`buildable`/`blocksMovement` are data), `PlacementSystem` (grid footprint, overlap, buildability from map data, place/remove building entities), `placement-change` event + `onPlacementChange` hook. Movement: `MovementSystem` `moveTo` + simple blocking (unwalkable tiles + `blocksMovement` buildings) + `npc-move` event. No pathfinding/collision systems, no NPC schedules (state-machine content). Tier 2 remains open only for item 6 (GrowthSystem→animal `age` generalization + multi-viewport sync). | `boundaries.md`, `decisions.md` (D22), `mcp.md`, `api.md` |

+ 590 - 0
docs/decisions.md

@@ -0,0 +1,590 @@
+# Architecture Decisions
+
+Source of truth for **WHY** the engine is designed this way. Every decision records its context, candidates, rationale, and — where the decision-maker corrected the proposal — the correction.
+
+Process: decisions were reached through a grilling interview (`/grill-me`) over the four stated requirements:
+
+1. Every feature exposes an MCP server interface.
+2. Engine-level Mod support.
+3. Enforced data/logic separation ("data-driven creation").
+
+Observed meta-fact: three of the four requirements are architecture/tooling concerns (MCP, Mods, data separation); none is a rendering/performance concern. The engine's core value is **controllability by external tools/agents, extensibility, and content-as-data** — not raw rendering throughput.
+
+---
+
+## D1. Engine Boundary — Build on a Rendering Library
+
+**Decision**: Build the engine layer on top of a mature rendering library (PixiJS). Own the architecture: ECS-style world, data pipeline, Mod loader, MCP layer.
+
+**Candidates**:
+- A: From scratch (engine + renderer + everything)
+- B: Rendering library + self-built engine layer ← **chosen**
+- C: Full framework (Godot/Bevy)
+
+**Rationale**: All three hard requirements (MCP, Mods, data separation) live in the layer we control. Rendering is commodity; architecture is the product.
+
+---
+
+## D2. Technology Stack — TypeScript + PixiJS
+
+**Decision**: TypeScript + PixiJS.
+
+**Candidates**: Rust+macroquad, Lua+LÖVE (installed), Python+pygame, C#+MonoGame, TS+PixiJS ← chosen.
+
+**Rationale**:
+- MCP's official reference implementation is TypeScript → lowest per-feature MCP cost.
+- Mods = JS modules, no embedded VM needed.
+- JSON + TS types fit the data-separation model.
+- A farming game never touches performance ceilings.
+- Free web export.
+
+---
+
+## D3. MCP Purpose — Creation-First, Runtime Debug Second
+
+**Decision**: MCP servers are primarily a **creation bridge**: AI agents build game content through MCP. Runtime MCP is for **debugging only** (state introspection, event triggering) — it does not serve players.
+
+**Correction**: The interviewer initially recommended "runtime control as the core". The decision-maker corrected this: runtime MCP is debug-only.
+
+---
+
+## D4. Data Layer — Forced Data-Driven Creation
+
+**Decision**: The engine **enforces** data-driven creation. Game creation happens only through data (items/crops/maps/dialogue defined in content files). Content files contain no code; engine logic hardcodes no content.
+
+**Clarification (verbatim intent)**: "我的意思是用这个引擎创作游戏时强制数据驱动" — *when creating a game with this engine, data-driven is mandatory.*
+
+Confirmed restatement: the engine provides logic systems; creators provide only data.
+
+---
+
+## D5. New Mechanics — Declarative Behavior Language + Mod Escape Hatch
+
+**Decision**: A + C combination:
+- Built-in **declarative behavior data language** for routine creation (conditions/state machines/triggers expressed in data).
+- **Mods as the code escape hatch** for long-tail complex mechanics.
+
+---
+
+## D6. Mod API Boundary — Pure Hook Interface
+
+**Decision**: Code Mods register to **typed hook points** (`onDayStart(ctx)`, `onCropHarvest(ctx)`). Mods touch only context objects; they can never `import` engine internals.
+
+**Honest cost**: UI/rendering-level Mods are impossible. Data Mods (adding content, overriding values) have zero barrier.
+
+---
+
+## D7. Runtime Shape — Core/Render Separation, Browser-First
+
+**Decision**: 
+- The engine core is a pure TS library with **zero DOM dependencies** — world simulation, data layer, Mod loading, MCP all run headless.
+- Rendering is a PixiJS adapter layer. Dev runs in browser (Vite); desktop via Tauri packaging.
+- Headless core execution = MCP dev server, tests, CI with no window.
+
+---
+
+## D8. Authority — Headless Node Core Is the Authority
+
+**Decision**: The Node headless core is the source of truth; browsers are render viewports synchronized over WebSocket. Runtime MCP debug connects directly to the Node core process. Browser can disconnect/reconnect; multi-client is native. Architecture resembles "game server + client viewport".
+
+---
+
+## D9. MCP Server Shape — Central Gateway + Namespace Registration
+
+**Decision**: One central MCP gateway with per-subsystem **namespace registration**. Every subsystem (items/crops/maps/dialogue/save/Mods…) must register its own tools/resources/prompts namespace (`item.create`, `item.validate`, `map.get`, `save.snapshot`). One external server; stdio + streamable HTTP dual transport (stdio for dev bring-up, HTTP for runtime debug into the core process). Subsystems can be enabled/disabled independently.
+
+"Every feature has MCP" = an architectural constraint enforced by the registration layer, not a process count.
+
+**Implementation note (2026-08-12):** the enable/disable mechanism is surfaced as MCP tools `server.enable_subsystem {name}` / `server.disable_subsystem {name}` (spec'd since the walking skeleton, now implemented). **Guard rail decision:** `server.disable_subsystem` rejects disabling the `server` namespace itself (`cannot disable the server subsystem`) — that namespace hosts the very tool used to re-enable it; the alternative (allowing the disable, forcing a process restart to recover) was rejected. Enable/disable state is **per-gateway in-memory** only (D9) — not persisted, no engine-system impact.
+
+---
+
+## D10. Mod–MCP Bridge — Forbidden
+
+**Decision**: **No Mod↔MCP bridge.** The MCP surface covers engine-native capabilities only. Trust boundary: Mods serve gameplay only; MCP serves engine-native capabilities only; external agents never see Mod-registered tools.
+
+**Correction**: The interviewer proposed (A) "Mods register `mod.<id>.*` MCP tools"; the decision-maker rejected it (B).
+
+---
+
+## D11. Entity Architecture — Data Objects + Separated Systems (Light ECS)
+
+**Decision**: 
+- Entities = flat, schema-validated data objects (`id` + `type` + component fields).
+- Logic = pure-function systems operating on entity data (`GrowthSystem.step(dt)`).
+- Mod data extension = extra fields (schema override). MCP introspection = uniform query.
+
+**Rationale**: Full ECS is overkill for a few hundred farming entities; OOP violates every established principle.
+
+---
+
+## D12. MVP Scope — Tier 1, Architecture Reserves All Tiers
+
+**Decision**:
+- **Tier 1** (v1): tile maps/terrain, time system (day/season), planting (till → sow → water → grow → harvest), inventory/items/tools, economy (shop buy/sell), persistence.
+- **Tier 2**: NPCs + schedules, dialogue/story, friendship, buildings, animals (the behavior data language primarily serves this tier).
+- **Tier 3**: quests/events, weather, stamina, skills, minigames, multiplayer.
+
+Architecture principle: every new subsystem = add schema + add system + register MCP namespace + declare hook points, like adding a plugin.
+
+---
+
+## D13. Hot Reload — Data Hot-Reload + Code-Mod Restart
+
+**Decision**: 
+- Content data file changes → immediate hot reload (re-read, re-validate, inject; world state preserved) — covers 90% of creation work.
+- Code Mod changes → restart the headless core (sub-second in Node, save kept).
+
+**Rejected**: full code hot-reload (stateful-hook abyss: lost closure state, duplicate event registration).
+
+---
+
+## D14. Persistence Layer — Generic Serialization + Policy Left Blank
+
+**Decision**: The engine provides:
+- World-state snapshot **JSON serialization**
+- **Schema versioning + migration hooks**
+- save/load API
+- MCP `save.*` namespace
+
+**Left to creators** (data declarations or Mods): when to save, how many slots, autosave, save UI.
+
+**Rationale**: mechanism vs. strategy. Serialization/versioning/validation are generic mechanisms every game needs (engine's job); save policy is game content (not the engine's job). Save policy can itself be declared in data (`data/save.json`).
+
+---
+
+## D15. Behavior Language v1 — Unified Core + Subset
+
+**Decision**: v1 establishes the unified language core — **condition expressions** (comparison/logic composition) + **triggers** (`onEvent → actions`) — but implements only the Tier 1 subset (growth/watering/tool/time triggers). **State machine syntax deferred to v2** (needed only from Tier 2).
+
+**Rationale**: the language core is a differentiator — creators build the "when condition → action" mental model from day one. Field-driven (A) accumulates migration debt; full language (B) front-loads state machines v1 doesn't need. One condition/trigger engine = one MCP namespace (`behavior.trigger.create`) + one Mod-hook bridge point.
+
+---
+
+## D16. Audience — Self-Use with Complete, Rich Documentation
+
+**Decision (verbatim)**: "自用,但是要完整且丰富文档" — *self-use, but with complete and rich documentation.*
+
+- No semver / API-stability promises; versioning at will.
+- Documentation is written to the highest standard anyway — MCP interfaces, Mod API, and data schemas are **documented interfaces by nature**: without docs these capabilities are unusable.
+
+---
+
+## D17. Sequencing — Walking Skeleton Vertical Slice
+
+**Decision**: First a full-stack vertical slice: first YAML content → core loads world → minimal render shows map → MCP reads world state → empty Mod registers a Hook. All four capability chains (data/render/MCP/Mod) end-to-end. Then add horizontal subsystems (time → planting → inventory → economy → persistence).
+
+**Rationale**: the four capabilities are interdependent; the slice exposes integration issues (how MCP reaches core state, how Mods register into core) in week one, not week ten. After the slice, every subsystem is a template operation: add schema + add system + register MCP namespace + declare hooks.
+
+---
+
+## D18. Content Data Format — YAML Authored, JSON Internal
+
+**Decision (verbatim)**: "C: YAML 手写+JSON 内部" — creators author content in **YAML**; the engine normalizes to internal **JSON**, validates with zod, and registers. The MCP surface only ever sees JSON.
+
+**Correction**: the interviewer recommended JSONC (A: JSON + comments); the decision-maker chose YAML authoring instead, accepting the conversion layer — readable content wins over toolchain friction for the human-facing authoring surface.
+
+Consequences:
+- Engine gets a content loader/compiler stage: `YAML → normalize → zod validate → register`.
+- Hot reload watches YAML files.
+- MCP tools/resources/prompts and engine internals consume only normalized JSON.
+
+---
+
+## D19. Engine/Game Boundary — Criterion + Ownership Model
+
+**Decision**: A feature is built into the engine **iff** all three hold: ① dataization value (expressible as content data), ② Mod hook demand, ③ MCP manipulability. Ownership model: L0 infrastructure (engine, built) → L1 simulation mechanisms (judged by criterion) → L2 expression layer (behavior language) → L3 game strategy (100% creators).
+
+**Verdicts (15 domains, all confirmed by decision-maker)**:
+- **Engine**: dialogue/story (content-document system), weather (probability-roll mechanism), placement (grid geometry primitive), NPC grid-movement primitive (`moveTo` + blocking + `npc-move` event), behavior language v2 (state machines + `onTime`/`onSchedule`).
+- **Behavior language / data layer**: NPC schedules (time-triggered state machines), quests/events (state machines, progress in `player.questProgress.<id>`), friendship (`npc.affection` field), stamina (`player.stamina` field), skills (`player.skills.*` fields) — zero new engine code.
+- **Render/Mod layer**: UI/HUD (engine exposes UI *logic state*; render draws), audio (engine emits events; render plays), minigames (engine guarantees triggers + rewards; body lives in render/Mods).
+- **Multiplayer**: engine provides multi-viewport sync mechanism (D8 extension); multiplayer *gameplay* is creator content.
+- **Animals**: reuse GrowthSystem generalized to `age` field + `animal` schema + behavior language (no new system).
+
+**Pattern cheat-sheet**: content document → engine system; random mechanism → engine system; geometry/spatial → engine primitive; temporal state → behavior language v2; deterministic numeric + rules → fields + language.
+
+Full detail in `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D20. Behavior Language v2 — State Machines + Time Triggers (Tier 2 lead item)
+
+**Decision**: v2 = v1 triggers + **state machine syntax** + **`onSchedule` time primitive**. Concretized by the v2 implementation (2026-08-12) as:
+
+1. **Separate content kind `statemachine`** (`content/statemachines/<id>.yaml`) with `behavior.statemachine.*` MCP tools under the existing `behavior` namespace — NOT a discriminated union inside the `behavior` kind. Rationale: the existing `behavior.trigger.*` tool names stay truthful (they are trigger tools, not machine tools), content dirs stay 1:1 per kind, and `behavior://triggers` keeps a clean meaning. A separate MCP *namespace* (`statemachine.*`) was rejected: the behavior language is one subsystem; splitting it would fragment `server.info.subsystems` for no gain.
+2. **`onSchedule` over a tick loop**: schedules hook the existing `day-start`/`season-change` bus events, so no new game loop. The engine has no auto-driven tick (GrowthSystem.step has no driver), so fine-grained intra-day timing is documented as a limitation awaiting a tick loop (Tier 2 later or the dev loop). `at` is `"day-start"`/`"season-change"` or `{day?, season?}` matched against the event payload; entries apply from any state and are evaluated after `transitions`.
+3. **TriggerSystem extended in place** (id stays `"trigger"`) rather than a sibling `StateMachineSystem`. One `match` pass drives triggers first, then machines, under the SAME `matching` re-entrancy guard — machine actions emitting events during a pass are never re-matched in that pass (cascades/infinite loops stay impossible). A sibling system would need its own guard and would allow machine-triggered events to re-drive triggers mid-pass.
+4. **Machine state lives on a world entity** `{id: "sm:<machineId>", type: "statemachine", machineId, state}` — not an in-memory system Map — so `save.snapshot`/`save.restore` persist it for free and MCP can inspect it like any entity. Instances are created **lazily on first transition** (never at boot) so pre-existing worlds/snapshots and existing tests are unaffected.
+5. **No new expression language**: conditions reuse v1 `ConditionExpr` exactly (zod-validated, no arbitrary code). New hook `onStateChange` (fires per transition via the `state-change` bus event) gives quest-tracking Mods their machinery; `onTriggerFire` stays for triggers.
+
+**Rejected candidates**: (a) `behavior`-kind discriminated union (muddies trigger tool names + resource semantics); (b) tick-based time eval (needs a game loop the engine doesn't have); (c) sibling StateMachineSystem (two re-entrancy guards, cascade risk); (d) in-memory machine state (not snapshot-able).
+
+Full detail in `docs/mcp.md` (§behavior v2), `docs/api.md` (§4/§7), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D21. Dialogue + Weather (Tier 2 items 2 & 3) — content-document system + lightweight probability-roll system
+
+**Decision**: Dialogue and weather are the next two engine subsystems (boundaries.md verdicts: dialogue = content-document system; weather = probability-roll mechanism). Concretized by the implementation (2026-08-12) as:
+
+**Dialogue (`DialogueSystem`, content kind `dialogue`):**
+1. **Active dialogue state lives on a world entity** `{id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?}` (created lazily on `start`), not a player field and not an in-memory system Map — `save.snapshot`/`save.restore` persist it for free and MCP inspects it like any entity. Mirrors the statemachine decision (D20.4).
+2. **`to`-only nodes surface a synthesized `Continue` choice**: the headless debug driver has one uniform advance action (`choose(index)`), so linear chains are drivable; the node's text is still presented before advancing. A node with `choices` and no `to` branches; a node with neither is terminal.
+3. **Available choices are filtered by `when` at read time** (dialogue ctx: `time.*`/`player.*`/`world.<id>.*`/`payload.*` from the `start` ctx — same convention as triggers), and `choose` indexes into that filtered list; a gated-out choice is hidden, not erroring.
+4. **Terminal / all-gated nodes auto-end the dialogue** (deliver the text, emit `dialogue-end`, remove the entity). Without this a headless dialogue with no outbound path would soft-lock — there is no `dialogue.end` tool (the engine UI logic state is read from the entity instead).
+5. **Dialogue reuses the behavior language's condition/action runtime** via a shared `behavior-runtime.ts` module (condition evaluation, path resolution, action execution extracted from `TriggerSystem` — one source of truth; `TriggerSystem` refactored to import it with zero behavioral change).
+6. **`dialogue.start`/`dialogue.choose` are debug tools** (D3) — dialogue is authored content; the runtime drive surface is for headless testing.
+
+**Weather (`WeatherSystem`, content kind `weather`):**
+1. **Weather state lives on a world entity** `{id: "weather", type: "weather", weatherId}` created **at boot** when a weather def exists, so `weather.get` is always defined; snapshot-able via `save.*`. Games without `content/weather.yaml` boot cleanly (the system degrades: get/set/roll throw clear errors).
+2. **Injectable RNG** (`new WeatherSystem(rng?)`, default `Math.random`) — the roll is a weighted pick over the current season's probability table (weights as positive ints), made deterministic in tests.
+3. **Initial weather = current season's first table entry** (no RNG at boot; deterministic).
+4. **Single-file content kind** (`content/weather.yaml`, like `time.yaml`) → **no MCP creation tools and no prompt**: per-id creation tools would be meaningless for a one-file table; the table is authored directly in YAML.
+5. **Season read from `TimeSystem.get().season`** at roll time (the time def owns season names); when the current season is uncovered by the table, the first season's table is used as fallback.
+6. **`weather-change` `{from, to}` emitted only on actual change** (set to the current weather is a no-op) → hook `onWeatherChange`.
+
+**Rejected candidates**: (a) dialogue state as a `player` field (pollutes the player entity with transient UI state; awkward save semantics); (b) in-memory dialogue/weather state (not snapshot-able); (c) a `dialogue.end`/`dialogue.next` tool (soft-lock avoided by auto-end instead — one less debug tool); (d) weather creation tools (single-file kind); (e) a `WeatherDef` that duplicates the time def's season list (the weather table keys seasons by name, matching `time.yaml`, without re-declaring day counts).
+
+Full detail in `docs/mcp.md` (§dialogue/§weather), `docs/api.md` (§4/§7), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D22. Placement + NPC Grid Movement (Tier 2 items 4 & 5) — geometry/spatial engine primitives
+
+**Decision**: Grid placement and NPC grid movement are the final two Tier 2 engine subsystems (boundaries.md verdicts; the "geometry/spatial → engine primitive" cheat-sheet row). Concretized by the implementation (2026-08-12) as:
+
+**Placement (`PlacementSystem`, content kind `building`):**
+1. **`BuildingDef` is data** (`content/buildings/<id>.yaml`): `{ id, name?, footprint: {w, h}, buildable: "map" | {tiles: string[]} | "any", blocksMovement, description? }`. Footprint is the grid size in cells; placed instances are axis-aligned rectangles anchored at the placement origin. `blocksMovement` matters for the NPC primitive (consumed via `occupies`).
+2. **`buildable` is a three-form union, not a boolean flag**: the map's tile ids ARE the terrain vocabulary, so restricting by tile id list is the natural authoring form; `"map"` (any tile on the map) and `"any"` (tile check skipped entirely) behave identically in v1 — `"map"` signals the def was authored against the map's terrain and is the explicit form reserved for a future map schema with unwalkable tile flags. `{tiles}` requires every footprint cell's tile id to be in the list.
+3. **Placed buildings are world entities** `{id: "building:<buildingId>:<x>:<y>", type: "building", buildingId, x, y, w, h, blocksMovement}` — `save.snapshot`/`save.restore` persist them for free; the system stores nothing. Mirrors the statemachine/weather/dialogue entity-state decisions (D20.4/D21).
+4. **Tile resolution convention**: the map entity's terrain layer is the layer named `"ground"`, else the first layer; the tile id is `terrainLayer.data[y][x]`. Shared map-grid helper (`systems/map-grid.ts`) so PlacementSystem and MovementSystem read tiles identically.
+5. **v1 occupancy: buildings block buildings**; the player and non-building entities are transient and do not occupy cells (NPC blocking is MovementSystem's concern). `placement-change` `{type: "place"|"remove", buildingId, x, y, w, h}` bus event → hook `onPlacementChange` — per the tri-criterion: placement outcomes are spatial (not behavior-language-expressible), build/removal Mods (build-quests, achievements) are plausible, and the debug tools already exist.
+6. **Creation tools named `placement.building.*`** — buildings are the subsystem's content kind, so lifecycle tools live under the namespace like `item.*`/`crop.*`; `placement.building.list` (defs) vs `placement.list` (placed instances) stay distinct.
+
+**Movement (`MovementSystem`, system id `"movement"`, MCP namespace `npc.*`):**
+1. **No new schema** — NPCs are world entities `{id, type: "npc", x, y, ...}`; the movement API is a system (not a free module) so it can resolve `PlacementSystem` from the system host (auto-booted before it) and keep one consistent blocking source.
+2. **Walkability is a tile-id convention**: the map schema has no per-tile walkability flag, so the engine treats the tile id `"water"` as unwalkable (exported `UNWALKABLE_TILES`; the walking-skeleton map's water tiles are the demonstration). A future map schema gaining a `walkable`/terrain-cost flag replaces this documented convention.
+3. **Blocking: buildings block, NPCs do not** (v1). Building occupancy is stable geometry — a perfect blocking check (via `PlacementSystem.occupies`, only when the building's `blocksMovement` is true). NPC-vs-NPC overlap would need a resolution policy (who yields?) — a simulation concern deferred out of the primitive.
+4. **No `onNpcMove` hook**: the `npc-move` bus event is already matchable by the behavior language (`on: "npc-move"` in a state machine transition), so the tri-criterion's "Mod hook demand" arm fails — Mods get the machinery via content, not a hook. The event's primary consumer is the render/sync layer. (Contrast with `onPlacementChange`, where the event also enables build-quest content but placement is strategic enough to justify the hook directly.)
+5. **`npc.teleport` exists** for headless testing/QA — reposition an NPC past blockers without writing a valid path; still emits `npc-move` so render sync stays consistent. `npc.*` has no prompts and no creation tools — NPCs are runtime entities, not authored content (a creator gives an NPC its schedule via `statemachine` content).
+
+**Rejected candidates**: (a) buildability as a plain boolean (`placeable: true`) — loses the terrain-tile vocabulary the map already provides; (b) a per-entity `walkable` flag on the map — the map schema is frozen for this slice; (c) NPC-vs-NPC collision in the primitive (deferred policy); (d) an `onNpcMove` hook (see 4); (e) an in-memory placement map — buildings as entities get save.* for free.
+
+Full detail in `docs/mcp.md` (§placement/§npc), `docs/api.md` (§4/§7), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D23. Tick Loop + Animal Age Generalization (Tier 2 items 5 & 6 — final Tier 2 build)
+
+**Decision**: Close out Tier 2 with (A) an explicit simulation **tick loop** that drives `GrowthSystem.step` and enables fine-grained intra-day scheduling, and (B) the **GrowthSystem generalization to animal `age`** (boundaries.md item 6, D19 verdict: "reuse existing mechanisms + data — no new animal system"). Concretized by the implementation (2026-08-12) as:
+
+**Tick loop (A):**
+1. **`engine.tick(dt)` is the authoritative explicit driver** (D8 — headless core has no render loop; MCP/debug/tests need deterministic stepping). `dt` is in **ticks** — 1 tick = 1 engine time unit, `dayLengthTicks` from the `time` def — for full determinism (no wall-clock dependence). `tick` advances `TimeSystem`, then calls every system's `step(dt)` through `SystemsHost` in registration order (TimeSystem first, GrowthSystem second — a system gains a `step` by implementing the existing `System.step?` slot; no new host machinery).
+2. **`TimeSystem.step(dt)` = `advance(dt)` + emit `time-tick`**. Each tick emits the **`time-tick`** bus event `{day, season, tickInDay}` (the post-advance state), which the engine forwards to systems' `on` like any event; `TriggerSystem.match` evaluates intra-day `onSchedule` entries on it under the same re-entrancy guard. Emitting from `TimeSystem.step` (rather than a special-case in `engine.tick`) keeps the SystemsHost pattern uniform and the ordering explicit: boundaries first (day-start/season-change from `advance`), then the intra-day tick event, then GrowthSystem's day accumulation.
+3. **`time.advance` stays the raw clock primitive** — no systems step, no `time-tick`. `engine.tick` and `time.advance` are therefore semantically different (simulation driver vs clock mover); both remain useful and tests using `advance` are unchanged (backwards compatible).
+4. **Optional auto-tick**: `createEngine({ tickIntervalMs?, tickDeltaTicks? })` starts a `node:timers` interval (default `tickDeltaTicks` = 1 tick per interval; `unref`'d so it never holds the process open; cleared in `engine.stop()`). **OFF by default** so tests and headless MCP stay deterministic. This is the dev-loop convenience; explicit `tick` is authoritative.
+5. **Intra-day scheduling form**: `at: { tickInDay: n }` (n ∈ [0, dayLengthTicks)) is added to the v2 `onSchedule` union, matched against the `time-tick` payload's `tickInDay` (plus optional `day`/`season`). The existing `"day-start"`/`"season-change"`/`{day?, season?}` forms are untouched and remain evaluated only on those boundary events. The `time-tick` event fires once per `tick` with the final state (a multi-tick `tick(480)` emits one event), so the caller controls step granularity — this bounds per-tick evaluation to O(entries) and keeps the re-entrancy guard intact.
+6. **MCP exposure**: one debug tool `time.tick {ticks?}` (default 1) → the post-step `{day, season, tickInDay}`. It exists because `advance` (clock jump) and `tick` (simulation step) are semantically different and both are useful from MCP: `advance` for boundary testing, `tick` for driving growth/intra-day state headlessly. `time.tick` is debug-only (D3) — no player-facing surface.
+
+**Animal age generalization (B):**
+1. **No new system** — GrowthSystem (id `growth`) is generalized in place: `step(dt)`'s `advanceDay` iterates BOTH `type:"plot"` entities (CropDef, zero behavioral change) and `type:"animal"` entities (AnimalDef) in one pass. Animals are isomorphic to crops (stages, days-per-stage, a per-day gate), so the only new mechanism is a `fed` flag mirroring the plot's `watered`.
+2. **Animal entity** `{id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?}` — the counter `n` (one past the highest existing suffix) keeps multi-instances and post-restore spawns collision-free; entities are world entities so `save.snapshot`/`save.restore` persist them for free (D20.4/D21 pattern).
+3. **`AnimalDef` schema** (content kind `animal`, `content/animals/<id>.yaml`): `{ id, stages: [{name, days, feedRequired}], produces: [{itemId, qty, chance?}] }` — the growth cycle + produce table, mirroring `CropDef`'s stage/drop model with `feedRequired` replacing `waterRequired`. **Feed rules (what food, when) and produce/harvest rules are behavior-language content** (decision #9 / D19): the schema holds only the mechanism-shaped data; the engine grants nothing.
+4. **`feed` is the `water` analogue**: `GrowthSystem.feed(animalId)` sets `fed: true` and emits the `animal-fed` bus event (behavior-language-matchable, e.g. `on: "animal-fed"`); the flag is consumed per day (reset on stage advance AND after a mature animal produces), exactly like `watered` resets per stage. A `feedRequired` stage with `fed: false` skips the day (no growth, no produce).
+5. **Events**: `animal-grow` `{animalId, animalDefId, stageIndex, stage}` on every stage advance (crop-grow analogue) → hook `onAnimalGrow`; `animal-produce` `{animalId, animalDefId, stageIndex, stage}` emitted per eligible day while mature (final stage) → hook `onAnimalProduce`. **No `animal-mature` event and no `onAnimalFed` hook**: `animal-grow` already covers reaching maturity, `animal-produce` covers ongoing production, and the `animal-fed` event is already behavior-language-matchable — the "Mod hook demand" arm of the tri-criterion fails for feed (same reasoning as D22.4's `npc-move`). The produce table itself is granted by language content hooking `animal-produce` (e.g. a state machine transition `on: "animal-produce"` → `give-item egg`).
+6. **MCP `animal.*` namespace** (subsystems 13 → 14): creation-first definition lifecycle (`validate`/`create`/`update`/`list`/`get`, mirroring `crop.*` — animals have per-id authored content, unlike the single-file weather kind) + debug tools `spawn`/`feed`/`stage`/`entities` (D3) + resources `animal://catalog`/`animal://{id}` + prompt `animal.create-prompt`. `animal.list` lists **definitions** (consistent with `item.list`/`crop.list`); placed instances are `animal.entities` (mirroring `placement.building.list` vs `placement.list`).
+
+**Rejected candidates**: (a) a `StepSystem`/game-loop module separate from the engine facade — the loop is one method on the engine and a `step` slot systems already have; a module would duplicate the host; (b) auto-tick ON by default — breaks determinism for tests/MCP; (c) a wall-clock-based dt (`tickMs`) — wall-clock coupling breaks determinism; ticks are the engine's time unit (D8); (d) driving `onSchedule` intra-day from GrowthSystem's day accumulation instead of a `time-tick` event — the boundary-based `match` machinery is the existing, tested path and keeps triggers/machines/guard behavior uniform; (e) an `AnimalSystem` — D19 verdict is explicit: reuse GrowthSystem; a new system would duplicate stage progression; (f) an engine-side `produce()`/`harvest()` API that grants items — granting is strategy (what/when/how many is creator content), the mechanism is the event; (g) a separate `animal-mature` event — redundant with `animal-grow` (which fires on the advance INTO the final stage); (h) a third hook `onAnimalFed` — the bus event suffices (D22.4 reasoning).
+
+Full detail in `docs/mcp.md` (§time/§animal), `docs/api.md` (§1/§4/§7), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D24. Multi-Viewport Sync — WebSocket Broadcast Channel (final Tier 2 item)
+
+**Decision**: Deliver D8's multi-viewport promise as an optional WebSocket broadcast channel in the SAME process as the MCP stdio entry (`apps/mcp-server --ws-port <port>` / `FARM_WS_PORT`), a browser-native client in `packages/render/src/sync/ws.ts` (`connectWs`, handle shape identical to `LocalSyncHandle`), snapshot-driven viewport entity drawing, and a parameterized dev app (`?sync=ws&url=ws://...`). Concretized by the implementation (2026-08-12) as:
+
+1. **WS is a separate channel, NOT an MCP namespace** (D9-consistent): the broadcast server adds zero tools/resources/prompts. The MCP surface (85 tools / 22 resources / 10 prompts / 14 namespaces) is untouched — the channel serves a different client kind (render viewports, not agent tooling).
+2. **Protocol — push with an optional pull**: on connect the server sends `{"type":"snapshot","map":MapDef,"world":Entity[]}` (the map def is included because world entities alone cannot build a viewport), then broadcasts `{"type":"event","event":string,"payload":unknown}` per bus emission to ALL connected clients. A client may send `{"type":"sync"}` to request a fresh snapshot; the browser client does this after each event frame so it converges to latest state. Full-world event frames were rejected — the event is a change notification, state is pulled on demand (smaller frames, stable event contract).
+3. **Engine event exposure**: the WorldEventBus moves from a private closure to `engine.events` (readonly property, the `world`/`registry`/`hooks`/`systems` precedent). The broadcast server subscribes via `onAny`; the engine's existing systems-forwarding subscription is untouched. No hook changes, no new events, no tick/game-loop features added (D8: the core stays headless; the channel only mirrors what the core emits).
+4. **Defensive serialization**: every frame is JSON-serialized in try/catch — a non-serializable payload (circular ref, function) is skipped with a log line, never a server crash.
+5. **Entity drawing is data-driven and canvas-free**: `packages/render/src/shapes.ts` is a pure function from `Entity[]` to draw shapes (plots with tilled/watered/crop-stage colors, buildings as footprint blocks, npc/player/animal dots, neutral dot for unknown positioned kinds) — unit-testable in node without PixiJS; the viewport only maps shapes to `Graphics` calls. Simple shapes, no art — v1's render contract (D1: rendering is commodity; art is creator content).
+6. **Dev app parameterization**: `apps/dev` reads `?contentDir=`/`?modsDir=` (default repo roots + LocalSync) and `?sync=ws&url=ws://...` (no engine in the browser — connect, render on snapshot, label time from event payloads like `day-start`). Paths resolve against the repo root through the existing `/__farm_fs` middleware.
+
+**Rejected candidates**: (a) an MCP `sync.*`/`render.*` namespace (WS is not agent-facing tooling; a namespace would bloat the 85/22/10 surface for no gain); (b) full-world snapshots as event frames (state pulled on demand instead — smaller frames, stable contract); (c) SSE/HTTP transport (D8 names WebSocket; WS is bidirectional, browser-native, and connection-stateful for reconnect); (d) any core tick/loop/render-loop features (the channel only mirrors core emissions — D8 authority holds).
+
+Full detail in `docs/mcp.md` (transport section), `docs/api.md` (§1), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D25. Player Intent — One Input Mechanism, Three Thin Channels
+
+**Decision**: Add a single engine mechanism for human/agent player input — `IntentSystem` (id `"intent"`, auto-booted) exposing `handle(action, args) → IntentResult {action, tile, ok, error?, detail?}` with two primitives: `move {tile:{x,y}}` (relocate the player entity to a walkable tile) and `interact {tile:{x,y}}` (a smart context action on a plot tile). Every input channel is a thin adapter over the same `handle`: MCP `player.intent` (agents can play, D3 debug-tool precedent), the WS `{"type":"intent"}` client→server message (browser viewports), and `connectLocal.intent` (the local dev sync handle). Every `handle` call emits a **`player-intent`** bus event `{action, tile, ok, error?, detail?}` — for successes AND failures — so WS viewports auto-refresh and HUDs can show feedback. Concretized by the implementation (2026-08-12) as:
+
+1. **Intent primitives are the engine mechanism; input channel adapters are thin**: the engine's core value is controllability (D16) — the same `handle` that serves a browser canvas click also serves an MCP agent call, with identical validation and events. The WS broadcast channel and the MCP tool add no behavior; they parse arguments, call `handle`, and relay the result (WS relies on the bus emission for the event frame; malformed input produces a synthesized error frame, never a crash).
+2. **Interact priority order is the v1 hardcoded engine default interaction primitive** — explicitly a default, not a policy layer: mature crop → harvest; planted & not watered → water; tilled & empty & player has a seed → sow with the FIRST seed-kind inventory item (first key whose item def `kind === "seed"`; no seed → `no seed in inventory`); not tilled → till; otherwise an error result (e.g. `crop <id> is not ready to harvest`). The sow seed choice is documented as v1 (a seed picker is future UI). Making the priority order content-izable (a declarative interaction table) is a plausible follow-up but deliberately NOT built now — the ordering is engine-default content-able later per D19.
+3. **Player move reuses the shared map-grid/placement helpers instead of `MovementSystem.moveTo`**: walkability (`UNWALKABLE_TILES={"water"}` via `map-grid.tileAt`) and building blocking (`PlacementSystem.occupies` where `blocksMovement`) are the exact checks `moveTo` performs, but `moveTo` is npc-typed (requires `type: "npc"`). Reimplementing the same validation chain inside IntentSystem (via the shared helpers) is the least invasive option — `MovementSystem` and its `npc-move` behavior/events/tests are untouched. The event for player movement is `player-intent` (uniform for both intents), not `npc-move`.
+4. **`player-intent` is bus-event-only — no `onPlayerIntent` hook** (D22.4's `npc-move` reasoning): the event is behavior-language-matchable (`on: "player-intent"`), so Mods get the machinery via content, and the tri-criterion's "Mod hook demand" arm fails. Its primary consumer is the render/sync/HUD layer.
+5. **`player://state` resource + `player.intent` tool**: the namespace is a runtime-entity debug/input surface (no creation tools, no prompts — the player is not authored content, mirroring `npc.*`). The WS snapshot frame also gains `time: TimeState` (from `TimeSystem.get()` at broadcast time) so viewport HUDs show day/season immediately on connect instead of stalling on "booting…" until the first event frame; the client's `applyTime` reads both event payloads and snapshot.time.
+6. **Uniform sync handle interface**: both `LocalSyncHandle` and `WsSyncHandle` expose `intent(action, args)` (plus the existing `setTimeLabel`/`refresh`/`destroy`) — the dev app's interactive UI (Interact/Move toolbar + canvas clicks via `viewport.tileAt` + HUD feedback/stats) is identical in local and ws modes.
+
+**Rejected candidates**: (a) a separate input/controller module outside the systems — an auto-booted System fits the plugin template (D12) and can resolve PlantingSystem/PlacementSystem from the host like MovementSystem does; (b) routing player move through `MovementSystem.moveTo` — would need npc-type loosening or a parallel generic API, touching tested npc behavior for no gain; (c) an `onPlayerIntent` hook — the bus event already suffices (D22.4); (d) content-izing the interact priority order NOW — the default-interaction ordering is engine mechanism for v1, content-able later (D19's "deterministic rules → fields + language" row); (e) a WS-only input path (no MCP/local) — the same mechanism must serve every channel (decision #1).
+
+Full detail in `docs/mcp.md` (§player + transport), `docs/api.md` (§4), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D26. Presentation Pipeline — Programmatic Sprites + Synth Sounds + Raw Input (data/protocol half)
+
+**Decision**: Add the **data/protocol half** of the rendering presentation pipeline (D1/D8: the engine is headless and data-driven; render viewports present the world): two new content kinds `sprite` (`content/sprites/<id>.yaml`) and `sound` (`content/sounds/<id>.yaml`) carrying **programmatic draw instructions** and **synthesizer instructions** respectively (v1: no atlas/image assets, no audio files — both reserved Tier 3); optional `spriteRef` render hooks on crop stages / building defs / animal stages (and the player seed); full MCP namespaces `sprite.*`/`sound.*`; the WS snapshot carrying both catalogs; a raw `{"type":"input","input":string}` WS message → `input` bus event with a built-in arrow-key → `IntentSystem.move` mapping; and `play-sound` upgraded from a headless no-op to the documented audio-event emitter. The render/audio/keyboard *playback* half is round 2 (render layer). Concretized by the implementation (2026-08-12) as:
+
+1. **Sprites are content data (programmatic v1)**: `SpriteDef` `{id, frames: [{name?, durationTicks?, layers: [{shape:"rect"|"circle", x, y, w?/h? or r?, fill:"#rrggbb"}]}]}` (zod **strict**). Primitive draw instructions keep content data-driven, testable, and dependency-free — the engine already renders shapes without art (D24's `shapes.ts`). Image/atlas loading is documented as **reserved Tier 3** (a future `image`/`atlas` reference field; not built).
+2. **`spriteRef` is an optional appearance hint**: on `CropDef.stages[]`, `BuildingDef`, `AnimalDef.stages[]`, and the seeded player entity (default `"player"`). Missing → the renderer falls back to the existing shape drawing, so existing content and tests are untouched. Systems maintain the entity-side field so the snapshot always carries the current appearance: `PlantingSystem.sow` → crop stage 0; `GrowthSystem` on crop/animal stage advance (and `spawnAnimal` → stage 0); `PlacementSystem.place` copies the def's ref; harvest clears the plot's ref with the crop fields.
+3. **Sounds are content data (synth v1)**: `SoundDef` `{id, synth?: {wave:"sine"|"square"|"triangle"|"sawtooth", freq, durationMs, gain?, slideTo?}, src?}` (zod **strict**, at least one of `synth`/`src`). The `src` field is **reserved** (Tier 3 audio-file loading). The behavior-language `play-sound {sound}` action now emits the **`sound-play`** bus event `{soundId}` — the engine's audio-event mechanism per boundaries.md's "Audio | engine emits events, render layer plays" row (previously a no-op in headless v1); behavior-language-matchable (`on: "sound-play"`) and broadcast as an event frame.
+4. **Both catalogs ride the WS snapshot**: the snapshot frame gains `sprites: SpriteDef[]` and `sounds: SoundDef[]` (from `registry.list` at snapshot time — small bounded sets) so render viewports get the draw/audio instructions **in-band** with the world state they present (no second request, D24's pull model stays intact).
+5. **Raw input message is an engine mechanism (D25 extension)**: the WS client→server `{"type":"input","input":string}` message (e.g. `"key:ArrowUp"`, `"key:Space"`, `"pointer:click:<x>:<y>"`) emits an **`input`** bus event `{input}` broadcast as an event frame — behavior-language-matchable via `on:"input"`. **Built-in mapping**: `key:ArrowUp`/`ArrowDown`/`ArrowLeft`/`ArrowRight` additionally call `IntentSystem.handle("move", {tile})` for the tile one step from the player's **current** position (the mapping is player-centered and lives in the broadcast server adapter, NOT in the intent mechanism — `player.intent` semantics are unchanged, D25). Unknown keys/prefixes are broadcast-only; malformed input never crashes (a synthesized `input` error frame is broadcast instead). Pointer clicks are broadcast-only in this slice (a future click → interact mapping is render/game-layer freedom).
+6. **No new systems and no new hooks**: sprites/sounds are inert content kinds (the plugin template's "add system" arm is satisfied by the existing systems maintaining the `spriteRef` field; the input mapping reuses `IntentSystem`). The tri-criterion's "Mod hook demand" arm fails for both (the `sound-play`/`input` bus events are behavior-language-matchable — D22.4/D25.4 reasoning).
+
+**Rejected candidates**: (a) atlas/image assets or audio files for v1 — dependency-heavy and un-testable in a headless core; programmatic draw/synth instructions are the data-driven minimum that round 2's render layer can present immediately; (b) a new `InputSystem`/`SpriteSystem` — the input mapping is a thin adapter over the existing `IntentSystem` (least invasive, D25), and sprites/sounds need no runtime logic; (c) a dedicated `sync`/`presentation` MCP namespace — sprite.*/sound.* are content-kind namespaces mirroring `item.*`, consistent with D9; (d) hardcoding entity appearances in the render layer — violates D4 (content-as-data); the `spriteRef` field + sprite catalog keep appearance data-driven; (e) putting the arrow mapping inside `IntentSystem.handle` — the mechanism must stay intent-shaped (`move`/`interact`); raw-input-to-intent translation is a channel concern (D25's thin-adapters rule).
+
+Full detail in `docs/mcp.md` (§sprite/§sound + transport), `docs/api.md` (§1/§4/§7), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D27. Presentation Pipeline — Render Half (sprite drawing + Web Audio + keyboard)
+
+**Decision**: Complete the D26 pipeline with its **render half** (round 2): `packages/render` draws entities by their `spriteRef` against the snapshot's `sprites` catalog, synthesizes `sound` defs on `sound-play` event frames, and both sync handles expose raw keyboard input. No engine-core/MCP changes — this is purely the render-layer playback the D26 contract reserved. Concretized by the implementation (2026-08-12) as:
+
+1. **Sprite rendering is data-driven geometry (unchanged shapes fallback)**: the viewport resolves each entity's `spriteRef` in the snapshot catalog and draws the referenced frame's layers (rect/circle, `#rrggbb`) translated to the entity's tile pixels. A plot with a resolvable `spriteRef` keeps its shape base (tilled/watered rects) with the sprite layered on top; all other sprite entities draw the sprite *instead of* their shape. Missing `spriteRef` or unknown sprite id → the existing `shapes.ts` fallback, byte-for-byte unchanged. The pure `sprite-draw.ts` module holds frame selection + draw-command mapping so it is node-testable like `shapes.ts`.
+2. **Animation clock v1 is a renderer-side rAF timer**: each sprite frame's `durationTicks` is a **relative weight** against a documented **default frame rate of 10 fps** (`DEFAULT_ANIMATION_FPS`); a frame with absent/zero ticks weighs 1. The cycle length is `totalWeight / fps`; `frameAtTime(sprite, elapsedMs)` picks the frame whose weighted slice contains the elapsed time. Single-frame sprites are static (index 0). One viewport-global clock advances on `requestAnimationFrame`; the viewport redraws only when an animated sprite's frame index changed (the clock runs continuously but idle redraws are skipped). v1 is deliberately simple — no per-entity clocks, no blend/direction frames.
+3. **Building footprint sprite scaling**: a sprite def is authored in 0..32 tile space; an entity carrying `w`/`h` (tile footprint, e.g. the 3×3 coop) scales the whole frame by `w`/`h`, so the sprite covers the building's footprint box. `circle` layers under a non-uniform footprint draw as ellipses (Pixi `ellipse`); everything else draws at 1×1 tile.
+4. **Web Audio playback with the autoplay policy in the renderer**: `sound.ts` maps `SoundDef.synth` to oscillator parameters (wave/freq/durationMs/gain/slideTo — `linearRampToValueAtTime` for both the `slideTo` frequency sweep and a gain fade-out) and creates the `AudioContext` **lazily on the first play attempt or `prime()` call from a user gesture** (browsers gate audio behind gestures). No def / no context / no synth → silently skipped; playback errors never crash the viewport. The dev app calls `prime()` from its keydown/pointerdown handlers.
+5. **Keyboard input stays on the thin-channel model (D25/D26)**: the dev app's `keydown` listener maps arrow keys to a raw `"key:Arrow<…>"` input and calls `sync.input(input)` — ws mode sends the D26 `{"type":"input"}` message (the server maps `Arrow*` → `IntentSystem.move`, unchanged), local mode maps the same `arrowDirection` delta client-side (one tile from the player's current position) into `intent("move", ...)`. The shared `keys.ts` `arrowDirection` keeps both mappings identical. Only arrows are wired; key-repeat on hold is fine for v1.
+6. **The sound player rides the existing sync handles**: `connectWs` gains an optional `sound` option (catalog fed from each snapshot, `sound-play` frames forwarded); `connectLocal` gains the same option fed from the local registry plus `engine.events.on("sound-play")`. Both handles additionally expose `input(input)` so the dev keyboard handler is mode-agnostic.
+
+**Rejected candidates**: (a) per-entity animation clocks / blend frames — the weighted global clock is the v1 minimum and keeps `frameAtTime` pure and testable; (b) managing AudioContext in the engine — playback is render-layer freedom (D1/D8) and the headless core must stay audio-free; (c) a new input namespace/MCP tool for keyboard — raw input is already a D26 mechanism; the keyboard is just another thin channel; (d) atlas/image loading or audio files — reserved Tier 3, unchanged (D26).
+
+Full detail in `docs/api.md` (§8), `docs/architecture.md`, `docs/boundaries.md` (D26 rows updated), and `examples/sample-game/README.md`.
+
+---
+
+## D28. Deterministic Seeded RNG — the ONE gap adopted from the LÖVE comparison (full adjudication)
+
+**Decision**: Build a deterministic seeded RNG as an engine mechanism, mirroring LÖVE's `love.math.RandomGenerator`, and record the complete adjudication of the engine-vs-LÖVE grill session. The grill compared the engine against LÖVE (the reference farming-game framework) and surfaced several gaps; **exactly ONE was adopted — seeded randomness**. All other gaps were consciously declined for documented reasons and are NOT built. Concretized by the implementation (2026-08-12) as:
+
+1. **A core RNG module is the single randomness mechanism** (`packages/core/src/random.ts`, zero deps): `RandomSource` = `{ next(): number /* [0,1) */, nextInt(max): number /* [0,max) */, setSeed(seed): void, state(): {seed, calls} }`. `seedRandom(seed)` returns a mulberry32-style pure PRNG — the same seed always produces the identical `next()` sequence (replay/tests/Mods). `randomSeed()` (from `Math.random` + `Date.now`) is the "no seed provided" default. Seeds are normalized to a uint32; `state().seed` is the anchor (the value most recently passed to `setSeed`/`seedRandom`), `calls` counts draws since that seed.
+2. **The engine owns one RNG and injects it into systems**: `createEngine({ seed?, ... })` (seed optional) exposes `readonly rng: RandomSource` on the facade; `SystemInitCtx` gains optional `rng?: RandomSource` and `createEngine` passes `engine.rng` at init. **WeatherSystem's default RNG switches to `ctx.rng` when available** (the injectable `WeatherRng` constructor override stays — test-injected deterministic sequences keep working); **PlantingSystem's harvest drop chance** (previously bare `Math.random`) switches to `ctx.rng.next()`. No other system changes — the engine's only two randomness consumers now share one seeded source.
+3. **MCP debug surface (18th namespace `rng.*`)**: `rng.get` → `{seed, calls}` (current state); `rng.set {seed}` (finite number required) → resets the RNG → `{seed, calls: 0}` (subsequent world randomness deterministic from that seed); resource `rng://state` → same shape. No prompts (debug surface, D3 pattern like `weather.*`/`time.*`). Reads `engine.rng` directly — no system needed. MCP is a development/debug tool, never a runtime player service (D3).
+4. **Save carries the RNG anchor**: `WorldSnapshot` gains optional top-level `rngState?: {seed, calls}` — `SaveSystem.snapshot()` includes it from `ctx.rng`, `restore()` restores it. **Restore semantics (honest minimal)**: restore calls `setSeed(rngState.seed)` — the sequence re-anchors to the original seed and `calls` resets to 0; calls are NOT replayed (documented). Old snapshots without `rngState` remain valid (optional field, migration-free, `CURRENT_SAVE_VERSION` unchanged).
+5. **Determinism is now testable end-to-end**: same seed → identical `next()` sequence; `nextInt` within `[0,max)`; `setSeed` resets; weather day-start rolls deterministic across same-seed engines; harvest drop rolls deterministic across same-seed engines (drawn from `ctx.rng`); save roundtrip preserves `rngState`; MCP `rng.*` wired through the gateway.
+
+**The full LÖVE-gap adjudication** (the grill decisions, recorded faithfully):
+
+| Gap | Verdict | Reason |
+|---|---|---|
+| **Seeded RNG (love.math.RandomGenerator)** | **ADOPTED — BUILT (this decision)** | Determinism is an engine mechanism: replay of world sims, deterministic tests, and a shared seeded source for Mod-visible randomness. `rng.*` MCP + save `rngState` complete the mechanism (debug surface + persistence) |
+| Text rendering (love.graphics.print) | Declined | Render-layer freedom (D1/D6): no render Mods, presentation lives in the render layer, D6's no-render-Mods boundary stands |
+| Image/atlas loading + audio-file decoding (love.graphics.newImage / love.audio.newSource) | **ADOPTED — BUILT (D31, this round)** | Superseded by D31's asset pipeline: `content/assets/` binary files become engine content (base64 in the bundle + WS snapshot), `sprite` gains an `image` layer (`src` = asset id), `map` gains a `tiles` tile-id→asset-id mapping, and `sound.src` is now a real audio asset id (rendered via `decodeAudioData`). The engine still never loads atlas files directly — single-tile assets by id, no atlas format — and rendering/decoding stays render-layer freedom (D1/D8) |
+| Particle systems (love.graphics.particles) | Declined | Render-layer freedom; triggered via the engine's `emit-event` bus mechanism, rendered by the render layer |
+| Physics (love.physics) | Declined | Grid-blocking covers the engine's collision needs (MovementSystem + PlacementSystem); no physics engine |
+| Threading (love.thread / workers) | Declined | Not applicable — headless core is single-threaded; no perf need |
+| Data compression (love.data) | Declined | Not applicable — no large-data pipeline in the engine |
+| Window/OS integration (love.window/love.system) | Declined | Not applicable — headless core, browsers are render viewports (D8) |
+| Video / sensors / joystick | Declined | Not applicable — no video pipeline, no device input surface in the engine |
+| Tauri desktop packaging | Deferred | Architecture reserved; not an engine mechanism — deferred to a future packaging layer |
+
+**Rejected candidates**: (a) per-system private RNG instances — a single engine-owned source keeps draws orderable/replayable and gives `rng.*` one true surface; (b) restoring `calls` by replaying draws on `save.restore` — draw replay is trivial per-module but requires every consumer to opt in, so the honest minimal (seed restore + calls reset) is documented instead; (c) a `rng.*` creation tool / prompt — the RNG is not authored content and has no authoring guidance to offer (D3 debug-surface precedent).
+
+Full detail in `docs/mcp.md` (§rng), `docs/api.md` (§1/§4/§7), and `docs/boundaries.md` (must be kept in sync — D-op2).
+
+---
+
+## D29. Tier 3 Content — Stamina/Skills as Fields, onPlayerIntent Hook, Multi-Quest Chain, Fishing Minigame Mod
+
+**Decision**: Close the first Tier 3 content round with the D19 verdicts realized as **data/behavior/Mod content, not engine systems**: (A) stamina/skills as seeded player **fields** owned by the behavior language + a Mod; (B) the **`onPlayerIntent` hook** (the only engine change, reversing D25.4's "no hook" stance — see 2 below); (C) a **multi-quest chain** as three chained state machines (starter-quest exists; carrot-quest + mayor-talk-quest added); (D) a **fishing minigame Mod** as the D19 "minigames live in Mods" escape-hatch demo. Concretized by the implementation (2026-08-12) as:
+
+1. **Stamina/skills are seeded player fields, zero new systems** (D19's "stamina (`player.stamina` field), skills (`player.skills.*` fields) — zero new engine code" verdict, held): `seedWorld` now seeds the player as `{id:"player", type:"player", x, y, spriteRef:"player", stamina: 100, skills: {}}`. No `StaminaSystem`/`SkillSystem` — the rules are content. Sample-game demonstrates both directions of the field+language model: (a) a **condition on `player.stamina`** — the `daily-stamina` state machine alerts (`low` state, `on: "player-intent"` transition `when: player.stamina lte 30`) when the fishing Mod drains stamina below 30, and its day-start schedule entry restores stamina via a `set-state player.stamina 100` onEnter (the machine's `initial` state deliberately differs from the day-start target — scheduling a machine INTO its current state is a no-op that skips `onEnter`); (b) **skills incremented by the Mod** — the fishing Mod writes `player.skills.fishing`.
+2. **`onPlayerIntent` is the one engine hook addition (16 hooks), reversing D25.4/D22.4's "bus-event-only" stance**: the `player-intent` bus event was deliberately hook-free (D25.4) because it is behavior-language-matchable and its primary consumer is the render/sync/HUD layer. D29 reverses that for the **Mod hook demand** arm of the tri-criterion: a minigame Mod (fishing) is gameplay simulation that the declarative language *cannot* express — it needs imperative settlement (stamina arithmetic, skill accumulation, inventory mutation, a custom reward event) driven by a **transient** payload (`{action, tile, ok, error?, detail?}`) that is NOT readable from world state (unlike `day-start`/`crop-harvest`, whose hooks read world state via `ctx.world`). Mods can only register to typed hooks (D6/D13) — they cannot subscribe to raw bus events — so the hook is the only Mod-visible surface for the intent payload. This is exactly the D5/D19 escape-hatch split: content matches `player-intent` via the bus event; Mods interpose via the hook. Wiring: `wireEvents` gains `events.on("player-intent", (payload) => hooks.fire("onPlayerIntent", makeCtx(payload)))`; `HookContext` gains optional `payload?: unknown` (other hooks keep a payload-less context — only the intent hook threads its event payload because it is transient). Fires for every `IntentSystem.handle` call, success AND failure.
+3. **Multi-quest chain as chained state machines** (D19's "quests/events (state machines, progress in `player.questProgress.<id>`)" verdict, held): `carrot-quest` activates on the day-start when `player.questProgress.starter-quest` is `complete` and completes on a `crop-harvest` with `payload.cropItemId === "carrot"` (reward: carrot-seed ×2 + `questProgress.carrot-quest`); `mayor-talk-quest` activates on the day-start when `carrot-quest` is complete and completes on `dialogue-end` with `payload.dialogueId === "mayor"` (reward: egg ×1 + gold + `questProgress.mayor-talk-quest`). The mayor dialogue gains a quest-gated branch node (`when: exists player.questProgress.mayor-talk-quest`) offering a tip + egg. **Known language limitation surfaced by the gold reward**: the behavior language's `set-state` is absolute (D20) — there is no increment action, so the "gold +N" reward is expressed as a **flat grant** (`set-state player.gold 100`). An additive action is deferred to the language; this content documents the gap rather than inventing an engine mechanism (D19: "deterministic numeric + rules → fields + language" — the engine does not grow an `add` opcode in this round).
+4. **Fishing minigame Mod is the D19 escape-hatch demo** (boundaries.md "Minigames | Mod/render layer" row): the sample-game Mod gains `onPlayerIntent` — when an `interact` intent lands on a water tile (map terrain read via `ctx.world` — `tileAt`-style lookup of the ground layer, the same `"water"` tile-id convention as `UNWALKABLE_TILES`), it settles the catch: fish ×1 (player inventory + `item-acquire`), stamina −5 (gate: stamina < 5 → **"too tired"** → emit `mod:fish-too-tired` and skip — a simple documented gate, no item, no drain, no skill), `player.skills.fishing` +1, and emits **`mod:fish-caught {fish, size, x, y, stamina, catches, day}`** — a custom bus event the behavior language CAN match (`on: "mod:fish-caught"`), demonstrated by the `fishing-treat` machine (every third catch pays a free egg). The Mod also removes the plot entity the engine's default till-on-interact created on the pond — the Mod normalizes engine-default behavior, the escape-hatch working as intended. Size is a deterministic pseudo-size (`1 + (catches % 3)`) — Mods have no RNG access (the seeded RNG is engine/world randomness, D28; Mod-visible randomness is not in scope).
+
+**Rejected candidates**: (a) a `StaminaSystem`/`SkillSystem`/`QuestSystem` — D19's verdicts are explicit (fields + language; quests are isomorphic to state machines); (b) keeping `player-intent` hook-free and having the fishing Mod read the last intent from world state — intents are transient and never persisted, so there is nothing to read; (c) an `add` action for gold — a language extension, deferred (see 3); (d) exposing the engine RNG to Mods for fish size — Mods get deterministic content-friendly rules; a Mod-facing random surface is a separate decision; (e) a fishing minigame in the engine (system/schema/MCP namespace) — D19's "no built-in minigame framework" verdict holds, and this round demonstrates the opposite (minigame body in a Mod).
+
+Full detail in `docs/api.md` (§1/§4/§7), `docs/boundaries.md` (stamina/skills/minigame rows marked demo'd), and `docs/mcp.md` (change log — the MCP surface is untouched; hooks are not MCP surface, D-op2), `examples/sample-game/README.md`.
+
+---
+
+## D30. Game Packaging — Content Bundle + Self-Contained Static Site (v1)
+
+**Decision**: Deliver game packaging as an engine capability: the `farm-pack` CLI (`apps/pack`, package `@farm/pack`) compiles creator content (YAML) into a **content bundle** (`bundle.json`, format `"farm-bundle"` v1 — kind → array of validated defs) plus a **self-contained static site** (index.html + JS assets) that runs in the browser through the engine's existing LocalSync mode with an in-memory content source. The packaged game ships with NO YAML files (the bundle is the single source of truth for packaged content), NO dev server, NO vite fs middleware, and NO headless core. Concretized by the implementation (2026-08-12) as:
+
+1. **`ContentBundle` is a core type** (`packages/core/src/content/bundle.ts`): `{ format: "farm-bundle", version: 1, content: Record<string, unknown[]> }` with a structural zod schema (`contentBundleSchema`). `ContentLoader.loadBundle(bundle) → LoadReport` **re-validates every def** against the per-kind `contentSchemas` via `safeParse` (defense in depth — "content is always normalized + validated at the boundary" holds for bundles too), reports per-item exactly like `load()` (same `LoadItemResult` shape), and registers into the same registry. Structural invalidity (wrong format/version/shape) throws a clear error. The existing fs `load()`/`watch()` path is byte-for-byte unchanged (zero regression — the existing `content.test.ts` load tests guard it).
+2. **`createEngine` gains bundle mode**: `EngineOptions` is now `(EngineContentSourceOptions | EngineBundleSourceOptions) & shared` — exactly one content source is required: a filesystem `contentDir` (dev/authoring/headless) or an in-memory `bundle` (packaged). In bundle mode `start()` calls `loadBundle` instead of the fs loader and the engine touches NO filesystem (browser-safe; `engine.contentDir` is `string | undefined`, `undefined` in bundle mode). The MCP creation namespaces' write helpers guard on `contentDir === undefined` with a clear error (bundle-mode engines are read-only — they never run MCP in practice; the guard is defensive typing).
+3. **`farm-pack` is an offline build-time tool, NOT an MCP namespace**: packaging has no runtime surface — it consumes content once, at build time, and the output is static. An MCP namespace would be a debug/creation surface (D3) for a step that has no runtime engine counterpart; the engine mechanism is the bundle loader + bundle-mode facade, both of which ARE exposed. Documented reasoning; no `pack.*` MCP tools.
+4. **Mods are NOT in the v1 packaged output**: Mods are a server/authoring feature (D6 — code modules with hook registration, loaded by `ModLoader` in the headless core). Browser `file://`/static module imports are restricted, and the packaged site runs entirely client-side. Documented limitation; bundling Mods into packaged output is a future path.
+5. **Desktop (Tauri) is documented, not built**: per the D28 adjudication table ("Tauri desktop packaging | Deferred"), v1 packaging is the static site; the documented future path is a Tauri shell around the packaged static site (the same LocalSync bundle mode in a webview). No Tauri/Electron code ships in this round.
+6. **The dev app gains `?bundle=` mode** (packaged builds reuse the SAME apps/dev UI — no parallel fork): when `?bundle=<path>` is present (repo-relative in dev) or a baked `__FARM_BUNDLE_DEFAULT__` define is set (the pack build bakes `"../bundle.json"`), `main.ts` fetches the bundle JSON over HTTP (the fs shim middleware is bypassed — the bundle is fetched, not fs-read), boots `createEngine({bundle})`, and runs the identical LocalSync UI (toolbar Interact/Move + keyboard + HUD + sound + sprites). The packaged index.html needs NO URL params — it defaults to the baked bundle.
+7. **Validation at both boundaries**: `farm-pack` fails loudly with per-kind/per-id error messages on any invalid def (exit 1); `loadBundle` re-validates at boot (cheap, defense in depth). The packaged game is created-content-only: the engine provides the mechanism (bundle format + loader + static shell), the game is just data — no gameplay logic in the pack tool.
+
+**Rejected candidates**: (a) shipping the YAML files in the packaged output (the bundle is the single source of truth — no YAML parsing in the browser); (b) an MCP `pack.*` namespace (build-time tool, not a runtime capability — no MCP surface for it); (c) bundling Mods into the output (browser module-import restrictions + D6 boundary; documented future path); (d) Tauri/Electron desktop packaging now (D28 deferred verdict; the static site is the v1 distributable and a Tauri shell is the documented future path); (e) a parallel packaged UI (reuses the dev app's `?bundle=` mode instead); (f) trusting pre-validated bundle defs without re-validation (the "always validated at the boundary" invariant is worth the cheap re-validate).
+
+Full detail in `docs/api.md` (§1/§2), `docs/architecture.md` (packaging pipeline), `docs/mcp.md` (change log — no surface change), `examples/sample-game/README.md`, and `docs/engine-guide-zh.md` (packaging chapter).
+
+---
+
+## D31. Asset Pipeline — Binary Assets as Data-Driven Engine Content (image layers + tile textures + audio-file sounds)
+
+**Decision**: Close the D26/D27-reserved atlas/audio-file gap (and the D28 "image-atlas + audio files" adjudication row) with a **binary-asset pipeline**: files in `content/assets/` (`.png` images, `.ogg` audio) become first-class engine content that YAML references **by asset id** — `sprite` gains an `image` layer (`{shape:"image", src: <assetId>, x?, y?, w?, h?}`), `map` gains an optional `tiles` mapping (`tile-id → assetId` for image tile drawing), and `sound.src` is now a real audio asset id (previously reserved, D26). Assets travel as **base64** in the content bundle and the WS snapshot (bounded — "keep assets small" is the documented constraint), so packaged games and multi-viewport sync stay self-contained single files. **NOT a content kind**: no schema registration, no `contentSchemas` entry, no MCP namespace — assets are filesystem content, and MCP-crafted YAML references them by id (MCP surface byte-identical). Concretized by the implementation (2026-08-13) as:
+
+1. **The asset table lives on the ContentRegistry and the engine facade**: `registry.assets: Map<assetId, AssetEntry>` (and `registry.asset(id)`), surfaced as `engine.assets: ReadonlyMap<string, AssetEntry>`. `AssetEntry` is `{kind: "image"|"audio", mime, data}` where `data` is the file's base64. The **ContentLoader** scans `content/assets/` for `*.png`/`*.ogg` on `load()` (asset id = filename without extension; unreadable files skipped best-effort), collects `bundle.assets` into the same table on `loadBundle`, and `watch()` re-scans the table on asset-file changes (same debounce as content kinds). No assets dir = empty table (root `content/` loads unchanged).
+2. **The ContentBundle gains an OPTIONAL `assets?: Record<assetId, AssetEntry>`** — additive, `BUNDLE_VERSION` stays **1**, old bundles without `assets` load unchanged. `farm-pack` (`apps/pack`) collects `content/assets/` into `bundle.assets` at compile time; the packaged `bundle.json` stays pure JSON (base64-inlined binaries, self-contained single file).
+3. **The WS snapshot frame gains `assets`** (the full table, `Object.fromEntries(engine.assets)`); `RenderSnapshot` (client) and the sound player receive it the same way (`connectWs`/`connectLocal` feed `setAssets`). Base64-only for files in `content/assets/` — no other binary ever enters the bundle/snapshot.
+4. **`SpriteDef` layer union extends to three shapes** (zod discriminated union, still strict): `rect`/`circle` unchanged; **`image`** = `{shape:"image", src: assetId, x?, y?, w?, h?}` — `x`/`y` position the image's top-left in the sprite's tile space (0..32 convention, default 0,0); `w`/`h` are the image's size in tile-space units where the **crop never exceeds the source** (a `w`/`h` smaller than the asset's pixels crops that many from the source top-left, a larger one upscales the whole image); absent = the whole image at its natural pixel size. Crop origin fixed at the source top-left in v1 (single-tile assets — the atlas/sprite-sheet format remains a documented future path, NOT built). Existing rect/circle content is untouched.
+5. **`MapDef` gains optional `tiles?: Record<tileId, assetId>`** (zod, blank ids rejected): when present, the viewport draws that tile id from the image asset aligned to the tile pixel box; when absent (or the id unmapped) the existing `tileColor(id)` palette fallback applies — data-driven, no hardcoding.
+6. **Render half**: the pure `sprite-draw.ts` mapping emits an `image` command (`{kind:"image", assetId, x, y, w, h, sourceW?, sourceH?}`), resolving default dest sizes from asset pixel dimensions (fed back in as `ReadonlyMap<assetId, {width, height}>` from loaded textures; fallback `DEFAULT_IMAGE_SIZE = 32`); `viewport.ts` loads image assets as PixiJS textures from `data:<mime>;base64,<data>` (cache by assetId, async load + **redraw when the last pending texture resolves** so the first frame doesn't stay stuck on fallbacks; missing texture → shape/tileColor fallback), draws image commands as `Sprite`s (top-left crop via a sub-texture `frame` rect), and draws map tiles from `map.tiles`; `sound.ts`'s `SoundPlayer` plays `src` defs by base64-decoding the OGG → `decodeAudioData` → `AudioBufferSourceNode` (decoded buffers cached by assetId, async; **`src` takes precedence over `synth`**, a missing/undecodable asset is silently skipped; the synth path is unchanged). Texture and buffer caches are keyed by assetId.
+7. **Sample-game adoption proves the pipeline**: `examples/sample-game/content/assets/` ships 13 Kenney Tiny Farm tiles + 8 Kenney interface sounds (renamed to self-descriptive ids — `grass-1`, `water-1`, `tilled-1`, `plant-sprout`, `plant-grown`, `plant-mature`, `carrot-sprout`, `carrot-grown`, `carrot-mature`, `coop-1`, `coop-2`, `fence-1`, `grass-2`; `click-1`, `confirm-1`, `error-1`, `back-1`, `drop-1`, `pluck-1`, `switch-1`, `bong-1`). The farm map's `tiles` mapping binds grass/water/tilled to image assets; parsnip/carrot stage sprites draw image layers (plus programmatic root accents); the coop and fence draw barn/fence tiles; the four sound defs switch from synth to `src` asset ids (till→drop-1, water→pluck-1, harvest→confirm-1, coin→click-1); the player and chicken sprites stay programmatic (no suitable human/animal tiles in the pack — both paths demonstrably coexist).
+8. **MCP surface is byte-identical (18 namespaces / 100 tools / 28 resources / 12 prompts held)**. No new namespace (`assets.*` deliberately does NOT exist), no new tools, no schema-kind registration, no protocol-message changes — the snapshot frame only gains the additive `assets` field. Assets are filesystem content; MCP-crafted YAML references them by id (a `sprite.create` with `src: grass-1` validates against the sprite schema; the loader resolves the id at render time). Rationale: binary files are not MCP-creatable in a JSON-tool workflow (no base64 write surface, D3's "creation tools write YAML"), they are not introspection targets (the `assets` field IS the debug surface), and there is no runtime behavior to drive — so the tri-criterion's "MCP manipulability" arm fails; the mechanism the MCP surface serves is the id-reference + load-time resolution.
+
+**Rejected candidates**: (a) an `assets.*` MCP namespace — see 8; (b) a new content kind (`content/assets/` is a directory of binaries, not YAML defs — no schema to validate, no creation lifecycle, no catalog); (c) atlas/sprite-sheet support — the pack's 16x16 tile sheet exists, but the mechanism is per-asset id reference (creators copy tiles to individual files); a real atlas format with crop regions is a documented future path; (d) shipping raw binary files in the packaged output — base64-inlining keeps `bundle.json` the single self-contained source of truth (D30), and the browser can decode `data:` URLs without fetch/CORS concerns; (e) decoding/decoding in the engine — rendering and audio playback remain render-layer freedom (D1/D8), the headless core stores bytes and never decodes (browser-free); (f) auto-loading every asset into PixiJS at boot — textures are loaded lazily on first render (snapshot-driven) and cached by id; (g) a hard dependency on assets being present for every `src` reference — missing assets degrade to the existing shape/color/silent-skip fallbacks (the D26/D27 "degrades gracefully" invariant).
+
+Full detail in `docs/api.md` (§1/§2/§7/§8), `docs/architecture.md` (asset flow), `docs/boundaries.md` (atlas/adopted verdict row + presentation row), `examples/sample-game/README.md` (assets section), and `docs/engine-guide-zh.md`.
+
+---
+
+## D32. Desktop Packaging — Electron Shell over the Packaged Static Site
+
+**Decision**: `farm-pack` gains a `--desktop` flag that writes a **runnable Electron desktop app** into `out/desktop/` alongside the D30 static site. The desktop shell is a **thin Electron wrapper**: `main.cjs` starts a local HTTP server (`node:http`, 127.0.0.1, ephemeral port) serving the out directory and opens a `BrowserWindow` that loads `index.html` — the exact bundle-mode page already E2E-verified in the browser (`createEngine({bundle})` + LocalSync, zero Node fs access, LocalSync arrow-key/click input unchanged). **Tauri was evaluated and rejected for this environment**: Tauri v2 needs `webkit2gtk-4.1`/`javascriptcoregtk-4.1` system packages on Arch, which require `sudo` (unavailable) — Electron was chosen because it is npm-only (bundles Chromium, zero system dependencies) and the D30 static site is fully self-contained, so the shell has no packaging logic of its own. Concretized by the implementation (2026-08-13) as:
+
+1. **`generateDesktop(outDir, {productName?})`** (`apps/pack/src/desktop.ts`) writes into `out/desktop/` (the shell and the static site live together): `main.cjs` (the Electron main process), `package.json` (npm-safe `name` derived from the product name — `"Pinefield Valley"` → `pinefield-valley`; `"main": "main.cjs"`; `scripts.start = "electron ."`; `devDependencies.electron` written as a `^` major — the runner installs it), and `README-desktop.md` (run instructions: `npm i && npm start` inside `desktop/`).
+2. **`main.cjs` is generated plain CJS** (Electron main supports CJS natively): a `node:http` server on `127.0.0.1` with **port 0 (ephemeral)** serves the site directory with correct MIME types (`.html`/`.js`/`.json`/`.png`/`.ogg`/`.css`/…), **GET only**, a **path-traversal guard** (`resolve` within the served root, `SITE_ROOT + sep` prefix check), and **exact files only** (404 for missing paths — no client routing, so no index.html fallback). `SITE_ROOT = resolve(__dirname, "..")` — the app works from **any cwd** (all paths derive from `__dirname`). The `BrowserWindow` is 960×640, resizable, title = the product name, `webPreferences: {contextIsolation: true, nodeIntegration: false}` (the page needs nothing from Node); `page-title-updated` is prevented so the window title stays the product name (the page's own `<title>` would otherwise override it). `window.on('closed')` closes the HTTP server; `window-all-closed` → `app.quit()` **on all platforms** (a game, not a mac dock app).
+3. **CLI surface**: `farm-pack --content <dir> [--out <dir>] [--desktop] [--name <name>]` — `--desktop` (boolean) generates the shell after `packGame`; `--name` sets the product name (default `"Farm Game"`). `parsePackArgs`/`PackArgs`/`PackResult` gained the flag + optional `desktopDir`. `pnpm pack:example` stays static-site-only; `pnpm pack:example:desktop` packages the sample game with the shell. The sample game's window title is "Pinefield Valley".
+4. **Electron as a devDependency of `@farm/pack`** (for the E2E launch) + **`pnpm-workspace.yaml` `onlyBuiltDependencies: ["electron"]`** — pnpm 10 blocks postinstall scripts by default, and electron's postinstall is what downloads the Chromium binary (≈100 MB); without the allow-list a fresh `pnpm install` would silently skip it.
+5. **A sidecar headless core is reserved for v2**: v1 desktop runs the same client-side bundle (no Mods, no headless core — identical to the browser package). A future round could package the headless core as a sidecar for Mods + multiplayer gameplay (the multi-viewport WS sync mechanism, D24, is the transport). Documented future path, not built.
+6. **No installer / no `electron-builder` in v1**: v1 desktop = a runnable app (`npm i && npm start`); distributable installer packages (electron-builder/forge), custom icons, and auto-update are documented future paths. The default Electron icon is used.
+
+**Rejected candidates**: (a) Tauri v2 (system `webkit2gtk-4.1`/`javascriptcoregtk-4.1` deps require sudo on Arch — not installable here); (b) `electron-builder`/installers now (huge dependency tree for v1's "runnable app" bar); (c) bundling Mods into the desktop output (unchanged D30 boundary — server/authoring feature); (d) a `pack.*` MCP namespace (unchanged — packaging is an offline build-time tool with no runtime surface); (e) a parallel desktop UI (reuses the identical built dev app — zero render changes).
+
+Full detail in `docs/api.md` (§9), `docs/architecture.md` (desktop flow), `examples/sample-game/README.md` (desktop section), and `docs/engine-guide-zh.md`. MCP surface unchanged (18 namespaces / 100 tools / 28 resources / 12 prompts).
+
+---
+
+## D33. Core Mechanism Completions — A* Pathfinding, Data-Driven Walkability, `add` Action, Interact-Order Content, Save Covers Time
+
+**Decision**: Close five long-documented core gaps in one additive round, all "mechanisms, not policies" per the operating principles: (A) **A* grid pathfinding** as a pure query mechanism (`findPath` + `MovementSystem.findPath` + MCP debug `npc.path`), finally replacing the D22 "no pathfinding" note — stepwise NPC movement stays future work, pathfinding is a query today; (B) **map walkability becomes data-driven** — `MapDef.walkable?: Record<tileId, boolean>`, with a single `isWalkableTile` source of truth (declared value wins, undeclared falls back to the `UNWALKABLE_TILES` constant) — the D22 "future map schema gaining a walkable flag replaces this" note is now realized, with zero content change for existing maps (water still blocked via the fallback); (C) the **behavior language gains the `add` incremental action**, closing the D29-documented language gap (set-state is absolute, D20 — the mayor-talk-quest gold reward was a flat grant "an increment action is deferred"); (D) the **player interact priority order is content-ized** via a new single-file `interact` content kind — the D25 "content-izable later" note is realized; applicability conditions stay engine mechanisms, only the ORDER is content policy; (E) **`save.*` covers TimeSystem** — an optional additive `time` field in `WorldSnapshot`, closing the D14 "TimeSystem state is not serialized in v1" note (old snapshots remain valid, `CURRENT_SAVE_VERSION` stays 1). Concretized by the implementation (2026-08-13) as:
+
+1. **A* pathfinding is a pure, mutation-free engine mechanism** (`packages/core/src/systems/pathfinding.ts`): `findPath(map: MapDef | undefined, start, goal, isBlocked) → TilePoint[] | null` — 4-neighbor grid movement (no diagonals — grid semantics), Manhattan heuristic, and a simple **array-based open set** (maps are small, ~16×16; a binary heap was considered and rejected — the array is simpler, dependency-free, and fast enough at this scale; documented in the module). Returns the path **including start and goal**, or null when unreachable / out of bounds / the goal is not walkable or blocked. Walkability per node = `isWalkableTile(map, x, y)` AND NOT `isBlocked(x, y)`. `MovementSystem.findPath(npcId, to)` wraps it (blocks = `blocksMovement` building footprints via `PlacementSystem.occupies` + other npc/player grid entities — the pathing npc excluded), validates the npc/coords like `moveTo`, and **never moves the npc**. `moveTo`/`teleport` semantics are byte-identical — pathfinding is additive query surface; stepwise NPC movement along a path is documented future work. MCP debug tool **`npc.path`** (101st tool; npc.* 5→6 tools): `{npcId, to:{x,y}}` → `{npcId, from, to, path}` or `{npcId, path: null, reason: "unreachable"|"out of bounds"|"blocked"}` — the reason is derived by re-checking bounds/walkability/blocking so the tool can distinguish why the query failed.
+2. **Walkability is data-driven with a single source of truth**: `MapDef` gains optional `walkable?: Record<tileId, boolean>` (zod, `exactOptionalPropertyTypes`-safe). `isWalkableTile(map, x, y)` (map-grid, exported) resolves: out-of-bounds → false; `map.walkable[tileId]` declared → its boolean; undeclared → `!UNWALKABLE_TILES.has(tileId)` (the constant stays the documented default `{"water"}`). Consumers: `MovementSystem.moveTo`, `IntentSystem.move`, `PlacementSystem` `buildable: "map"`, and `findPath` all use the helper — the direct `UNWALKABLE_TILES` checks are replaced (the constant's default behavior is unchanged, so existing content behaves identically: water still blocked; a map declaring `walkable: {water: true}` now opens water). **`buildable: "map"` was upgraded** from "skip the tile check" (previously identical to `"any"`, D22) to "every footprint cell must be walkable" — the explicit terrain-authored form; no existing content places a `"map"` building on water, so no behavior breaks (verified against all placement/movement/intent/client-surface tests). The world map entity carries `walkable` (spread from the def in `seedWorld`), so runtime consumers and the `npc.path` reason helper read the same data.
+3. **The `add` action closes the D29 language gap**: `TriggerAction` gains `{ action: "add", path, value }` (`value: z.number().finite()`). Runtime (behavior-runtime, same target resolution as `set-state`): numeric field → `+= value` (negative works — decrement); missing field → treated as 0 then `+= value`; field present but not a number → throws `action add: field <path> is not a number` (same error convention as `set-state`'s missing-entity throw); missing entity → the shared `no entity: <id>` error (the target resolver now takes the action name so `set-state`'s messages stay byte-identical). **`set-state` semantics are unchanged** (absolute, D20) — `add` is the new incremental op. Works everywhere the 5-kind action set works (trigger fire, state machine onEnter/onExit, dialogue node/choice actions). The sample-game mayor-talk-quest gold reward switches from `set-state player.gold 100` to `add player.gold 100` (same end state — the test asserts gold === 100 either way); `player.questProgress.mayor-talk-quest` stays an absolute `set-state`.
+4. **Interact priority order is content policy (D25's "content-izable later" realized)**: new single-file content kind `interact` (`content/interact.yaml`, zod **strict**, `{ id, order: z.array(z.enum(["harvest","water","sow","till"])).min(1) }` — single-file like `time`/`weather`, so **no MCP authoring tools** and **no hooks**). `IntentSystem.interact` reads the def at call time (`registry.get("interact", "default")` else the first def of the kind) and iterates its `order` array executing the FIRST applicable action; absent def → the built-in `DEFAULT_INTERACT_ORDER = [harvest, water, sow, till]` (zero behavior change for content without `interact.yaml`). **Applicability conditions are engine mechanisms**: harvest when crop mature; water when planted & not watered; sow when tilled & empty & has seed (first seed-kind inventory item); till when not tilled. Non-applicable actions are skipped; if none apply, the existing errors fall through (`crop <cropId> is not ready to harvest` for a planted watered immature crop, else `no seed in inventory`). A custom order like `[water, harvest, sow, till]` waters a mature unwatered crop where the default harvests — order demonstrably matters.
+5. **Save covers TimeSystem (additive, migration-free)**: `WorldSnapshot` gains optional top-level `time?: { day, season, tickInDay }` (zod optional — old snapshots without `time` remain valid; `CURRENT_SAVE_VERSION` stays 1). `SaveSystem.snapshot` includes `TimeSystem.get()` when a time system exists; `restore` calls `time.set(day, season, tickInDay)` when the snapshot carries `time` (absent → current clock untouched — existing behavior). `TimeSystem.set` gains an optional third param `tickInDay = 0` (backward compatible). Weather state is already a world entity, so it round-trips through the existing world serialization — no extra work.
+
+**Rejected candidates**: (a) a binary heap for the A* open set — maps are ~16×16 and the array is simpler/dependency-free (documented in the module); (b) stepwise NPC movement now — pathfinding is a pure query mechanism; making NPCs walk a path is a simulation concern (who drives the steps? scheduling is behavior-language content) and would touch `moveTo` semantics — documented future work; (c) an `assets.*`-style MCP namespace or authoring tools for `interact` — single-file kinds (`time`/`weather`) have no authoring tools (D3), and there is no runtime state to debug-drive; (d) keeping `buildable: "map"` identical to `"any"` — the D22 note explicitly left "map" as "the explicit terrain-authored form", and the walkability resolution is exactly that form; (e) serializing time into the world entities array (a synthetic entity) — a top-level optional snapshot field is additive and migration-free, mirroring the `rngState` precedent (D28); (f) a `walkable`-driven terrain-cost/traversal system — costs are a policy question (what's slower?); walkability is a mechanism (can you stand there?), and cost modeling is future work.
+
+Full detail in `docs/api.md` (§1/§4/§7), `docs/mcp.md` (§npc/§player), `docs/boundaries.md` (unchanged), and `examples/sample-game/README.md`. MCP surface: **101 tools / 28 resources / 12 prompts / 18 namespaces** (one additive `npc.path` debug tool).
+
+---
+
+## D34. Render-Layer Completions — Particles, Built-In Filter Library, Sprite-Sheet Crops, Weather Visuals, Entity Labels
+
+**Decision**: Add five **rendering-layer mechanisms** in one additive round, all sitting on the established render-layer freedom (D1/D8/D26/D27/D31) and the "mechanisms over policies" principle: **(A) a `particle` content kind + event-triggered rendering** — the behavior language already emits arbitrary events with payloads, so content triggers particle bursts through the existing `emit-event` action; no engine system, no new hook; **(B) a `shader` content kind = a parameterized BUILT-IN filter library with NO GLSL in content** — content selects one of six engine-provided PixiJS filters (blur/tint/grayscale/sepia/hue/brightness) and supplies only numeric/color parameters; sprite defs can carry `filters: [shaderIds]`, and weather visuals can filter the whole map; **(C) sprite-sheet support via optional frame-crop rects on the sprite `image` layer** — a sprite-sheet is just one asset file with multiple sprite defs cropping different `frameX/frameY/frameW/frameH` rects into it; no asset-pipeline change; **(D) weather visual presentation as an engine-provided built-in mapping** (`weather.ts`: rain/storm → rain particles + rain-tint filter, wind → wind particles, storm → + overlay color, unknown/others → {}) — NOT content, because weather visuals are presentation *mechanism* (which weather has a rain tint is engine presentation policy, kept built-in and documented; content supplies the particle/shader *defs* it references); **(E) entity text labels via a plain `label` entity field** — no schema change (labels are arbitrary entity data creators already set via `set-state`/Mod spawns); the renderer draws a Pixi Text above the entity. Concretized by the implementation (2026-08-13) as:
+
+1. **`particle` content kind** (`content/particles/<id>.yaml`, zod **strict**, `packages/core/src/schema/particle.ts`): `{ id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?: ["#rrggbb",...], fade? }` — every field optional; the render layer applies documented engine defaults (emitRate 10, lifetime 500–1000 ms, speed 10–30 px/s, angle 0°, spread 360°, gravity 0 px/s², size 2–4 px, single `#ffffff`, fade true). **Trigger**: the render layer listens for the `particle` bus event `{event:"particle", payload:{particleId, x?, y?, entityId?}}` — x/y are tile coords; absent → resolve from the named world entity (`payload.entityId`) or the `player` entity. Content triggers particles via the existing behavior-language `emit-event` action (verified: `emit-event` already carries `payload` through `behavior-runtime.ts` — no language change needed). Example: the sample-game starter-gift trigger fires a `sparkle` burst at the player. **Rendering** (`packages/render/src/particles.ts`, pure/node-testable): `particleSpawn(def, xPx, yPx)` derives per-particle initial state (position/velocity/size/color/lifetime) with `Math.random` (pure visual — deliberately NOT the deterministic engine RNG, D28; visual randomness does not participate in world simulation); `particleTick(states, dtMs)` integrates velocity/gravity and expires particles; the viewport holds a `ParticleLayer` (Pixi Graphics redrawn from the active list, world-space px). **Fade choice (documented)**: a single Pixi Graphics cannot carry per-particle `globalAlpha`, so `fade` is approximated by shrinking each particle's radius toward 25% over its lifetime (`PARTICLE_FADE_SHRINK_TO`). No core system, no hooks.
+2. **`shader` content kind** (`content/shaders/<id>.yaml`, zod **strict**, `packages/core/src/schema/shader.ts`): `{ id, filter: "blur"|"tint"|"grayscale"|"sepia"|"hue"|"brightness", params?: {[k]: number|string} }`. **NO GLSL/shader code in content** — the implementations ship with the engine (`packages/render/src/filters.ts`, pure: `filterParams(def)` maps a def to numeric/color `FilterParams`; the viewport's thin `makeFilter` instantiates the concrete PixiJS `BlurFilter`/`ColorMatrixFilter`): blur `{strength}` (default 4), tint `{color:"#rrggbb"}` or `{r,g,b}` (default white), grayscale `{factor}` (default 1), sepia `{}`, hue `{degrees}` (default 0), brightness `{factor}` (default 1). **Sprite integration**: `SpriteDef` gains optional `filters?: string[]` (shader ids); the viewport draws each sprite entity into its own child container with the resolved filters applied (one container per entity, cached Filter instances per shader id). Missing/unknown shader id at use time → filter silently skipped (log nothing — a cosmetic miss never crashes the viewport). **Whole-map filter**: the weather visual applies a shader to the entire map container. Sample content: `rain-tint` (grayscale 0.7, used by rain/storm weather) and `sparkle-glow` (brightness 1.2, demoed on the parsnip-mature sprite).
+3. **Sprite-sheet support (mechanism, test-covered only)**: the sprite `image` layer gains optional `frameX?/frameY?/frameW?/frameH?` (source-pixel crop rect into the asset image; absent = whole image — D31 behavior byte-identical). `spriteDrawCommands` carries the frame rect through the `image` command (clamped to the remaining source pixels when asset dims are known); the viewport creates a **sub-texture** (`new Texture({source, frame})`) from the cached base texture, cached keyed by `assetId + frame-rect string`. A sprite-sheet is one asset file + multiple sprite defs cropping frames into it. The sample game's Kenney tiles are single-frame, so no example art is fabricated — the mechanism is documented and unit-tested (`sprite-draw.test.ts` frame-crop cases + the sub-texture keying is thin Pixi code).
+4. **Weather visual presentation** (`packages/render/src/weather.ts`, pure): `weatherEffects(weatherId)` → `{filterId?, particleId?, color?}` — built-in mapping: `sunny`/`snow`/unknown → `{}`; `rain` → `{particleId:"rain", filterId:"rain-tint"}`; `wind` → `{particleId:"wind"}`; `storm` → `{particleId:"rain", filterId:"rain-tint", color:"#2f3b4c"}` (overlay color = translucent dark storm rect). The viewport reads the weather entity (`{id:"weather", type:"weather", weatherId}`) from the snapshot world on each render, diffs against the previous weather id, and on change: clears the previous filter/particles/overlay, applies the new map filter (cached), and starts periodic particle bursts (every ~900 ms at a random tile within the map, driven from the existing rAF loop). Unknown weather id → `{}` (no crash). **This mapping is engine mechanism, not content**: whether rain desaturates the screen is presentation policy the engine ships; the *defs* it references (rain/wind particles, rain-tint shader) are sample-game content.
+5. **Entity text labels**: entities carrying a `label?: string` field draw a small Pixi v8 `Text` (12 px Arial, white with a black 3 px stroke) anchored above the sprite/shape. `packages/render/src/labels.ts` (pure): `labelPosition(entity, tileSize)` → `{x, y}` (tile-center x, offset `LABEL_ABOVE_OFFSET` above the tile top). **No core schema change** — `label` is a plain entity field (the `Entity` type is open), so creators set it via `set-state` (`world.<id>.label`) or a Mod spawn; the sample-game mayor Mod spawn carries `label: "Mayor"` (the only content change needed).
+
+**Scope discipline (what stayed render-layer-free)**: no new engine systems (particles/shader/weather-visuals/labels are pure presentation); no new hooks (16 stay — particles arrive via the existing bus event; weather state already exists); no engine logic hardcodes the *particle*/*shader* defs or the weather-visual *defs* — the engine only ships the filter *implementations* and the weather-id→effect *mapping* (presentation mechanism), while creators own the data (which particles, which filters, what the sample game's weather looks like). The `particle`/`shader` MCP namespaces are creation-first lifecycle surfaces (validate/create/update/list/get + catalog + `{id}` resources + create-prompt) mirroring `sprite.*`/`sound.*` (D26) — particle/shader defs are authored content with a JSON-creation surface.
+
+**Rejected candidates**: (a) GLSL/shader-code strings in content — rejected outright (the tri-criterion "content-as-data" pillar and the D26/D31 precedent: content parameterizes, the engine implements); the built-in filter library is closed and documented, custom shaders are future work; (b) a particle emitter "system" in the headless core — particles are pure visual presentation with no simulation consequence; the existing event bus is the engine-side mechanism, the renderer owns the lifecycle (no new systems, D-op2 "no systems for presentation"); (c) wiring weather visuals as *content* (a `weathervisual` kind) — weather visuals are presentation mechanism with a tiny, stable built-in mapping; making it a content kind would add authoring surface with no policy payoff (unlike `interact` D33, which changes *player-facing* behavior); (d) per-particle alpha fade in the Graphics layer — not possible in a single Pixi Graphics; size-shrink fade is the documented v1 approximation; (e) stepwise NPC movement along paths — still future work (D33); (f) fabricating multi-frame sprite-sheet art for the sample game — the Kenney assets are single-frame; the mechanism is documented + unit-tested instead.
+
+Full detail in `docs/api.md` (§7/§8) and `docs/mcp.md` (§particle/§shader/§change-log). MCP surface: **111 tools / 32 resources / 14 prompts / 20 namespaces** (two additive namespaces × 5 lifecycle tools + 2 resources + 1 prompt each).
+
+---
+
+## D35. In-Game UI + Player-Intent Action Extension (seed picker, shop panel, dialogue panel, inventory bar)
+
+**Decision**: Close the "no in-game UI" known-gap (the dev app was a bare toolbar + HUD; the engine's `interact` sow always used the first inventory seed, and there was no shop/dialogue/inventory UI) by (A) **extending the player-intent action set** — the single player-input mechanism (D25) — with five new actions that route every UI interaction through the exact same `IntentSystem.handle` → `player-intent` event path a human/MCP/WS drive: `buy`/`sell` (delegate to `EconomySystem`, the transaction is the result detail), `talk` (start an NPC's dialogue from the NPC entity's new `dialogue` field), `choose` (advance the active dialogue), and `select-seed` (set/clear `player.selectedSeed`); (B) **fixing the D25 sow gap** — `interact`'s sow now prefers `player.selectedSeed` when it names an **owned** seed item, falling back to the first inventory seed otherwise (the field survives a successful sow — clearing is creator policy); (C) **making shops discoverable from world state** — `BuildingDef` gains an optional `shop?: string` (a shop def id) copied onto placed building entities, and the WS snapshot frame gains an additive `shops?: ShopDef[]` catalog (local mode reads the registry); (D) **building the UI as dev-layer DOM panels** (`apps/dev`) wired exclusively through `sync.intent`, with the display logic extracted into **pure node-testable helpers** (`apps/dev/src/ui.ts`) — no new dependencies, both local and ws modes. Concretized by the implementation (2026-08-13) as:
+
+1. **Five new intent actions, one mechanism** (all keep the `player-intent` event on success AND failure, and the `IntentResult` shape — `detail` widened from `string` to `unknown` so the new actions carry structured results): `buy {shopId, itemId, qty?}` / `sell {shopId, itemId, qty?}` delegate to `EconomySystem.buy`/`sell` (default qty 1) with the transaction `{shopId, itemId, qty, total, gold}` as detail (errors: `player.intent: <action> requires itemId` / `requires shopId` / `qty must be a positive integer`, plus economy errors passed through); `talk {npcId?|tile?}` resolves the NPC (by id, or the npc entity ON the given tile — `npcId` wins), reads its `dialogue?: string` entity field, and calls `DialogueSystem.start(dialogueId)` with the `DialogueState` as detail (errors: `talk requires npcId or tile`, `unknown npc: <id>`, `no npc at tile`, `npc <id> has no dialogue`); `choose {index}` calls `DialogueSystem.choose(index)` with the next node state as detail (errors: `choose requires a non-negative integer index`, `no active dialogue`, out-of-range passthrough); `select-seed {seedItemId?}` sets `player.selectedSeed` to a registered `seed`-kind item id (absent/empty clears the field) with `{selectedSeed: string|null}` as detail (error: `<id> is not a seed item`).
+2. **`player.selectedSeed` is a plain player-entity field** — snapshot-able by `save.*`, Mod-readable, content-addressable via conditions (`player.selectedSeed` path) — with documented semantics: `interact` sow prefers it only when it names an **owned** seed-kind item (stale selections degrade to the first-seed fallback), and a successful sow **keeps** the field (creator policy clears it; the field is set through `select-seed`). This closes the D25 "sow always uses the first seed" note with a mechanism (a seed *selection* the creator/player controls), not a policy.
+3. **Shops are discoverable from world entities**: `BuildingDef.shop?: string` (optional, additive — existing building content untouched) is copied onto placed instances (`building.shop`) by `PlacementSystem.place`, so any UI can find "the shop at this building" from snapshot world state (placement is runtime — `placement.place` — so the sample game keeps buildings unplaced and the demo arc/player places them, matching the D22 no-starter-placements precedent). The **WS snapshot frame gains an additive `shops?: ShopDef[]` catalog** (mirroring the `sprites`/`sounds`/`particles`/`shaders`/`assets` catalogs, D26/D31/D34) so ws-mode viewports get shop defs in-band; local mode reads `engine.registry`. The server-side `{"type":"intent"}` gate accepts all seven actions (message shapes unchanged — the D35 protocol delta is the additive `shops` field only).
+4. **The UI is dev-layer DOM panels with a pure-helper testing strategy**: `apps/dev/index.html` gains a seed-picker row, an inventory bar (items + gold + stamina + current seed), an overlay dialogue panel (text + choice buttons), and an overlay shop panel (buy/sell rows with owned counts) — all styled with the existing minimal CSS. `apps/dev/src/ui.ts` holds the pure logic (`playerStatsFromSnapshot`, `seedListFromSnapshot`, `shopRowsFromDef`, `dialogueChoicesFromNode`, `buildingShopAt`, `dialogueActiveInWorld`) — node-unit-tested (`ui.test.ts`); `main.ts` is thin DOM wiring that routes every interaction through `sync.intent` (`select-seed`/`buy`/`sell`/`talk`/`choose`), driving both local and ws modes uniformly. Dialogue panel content is event-driven (the `player-intent` talk/choose result detail carries the node state — the snapshot carries no dialogue defs), visibility is world-driven (`dialogue:active` entity presence). **Documented ws-mode limitation**: the snapshot carries no item defs, so the seed picker offers every owned item in ws mode (the engine validates `select-seed`); local mode filters to `seed`-kind items via the registry.
+5. **MCP surface is byte-identical in count (20 namespaces / 111 tools / 32 resources / 14 prompts held)** — `player.intent`'s inputSchema extends only (action enum + optional arg fields), **no new tools/resources/prompts** (the panels drive the existing intent mechanism; shop/dialogue content is already MCP-authorable via `shop.*`/`dialogue.*`). Sample content: `content/buildings/shop.yaml` (1×1, `blocksMovement: false`, `shop: general-store`, `spriteRef: shop` — the shop sprite honestly reuses the Kenney coop tiles), and the mayor Mod spawn now carries `dialogue: "mayor"` so `talk` works out of the box. E2E: no browser/playwright harness is committed in this repo (prior rounds ran ad-hoc E2E, D27/D32) — the UI is covered by the pure `ui.test.ts` cases (9) + a full `pnpm -r build` + the existing sample-game/ws-broadcast integration suites; a browser E2E for the panels is documented future work.
+
+**Rejected candidates**: (a) UI state that bypasses the engine (a client-only `selectedSeed`) — breaks ws symmetry and MCP/debug testability; `select-seed` as an intent action keeps the mechanism uniform and content-observable; (b) new MCP tools for the panels (`player.buy` etc.) — the existing `player.intent` action enum extension keeps the surface at one tool and every channel (MCP/WS/local) uniform; (c) a new content kind or schema for shop discovery — `BuildingDef.shop` is a single optional field on an existing kind, additive and migration-free (the `spriteRef`/D26 precedent); (d) snapshot changes beyond `shops` (e.g. carrying `items`/`dialogues` defs for the ws UI) — the protocol delta must stay minimal (D35.4 documents the ws seed-picker/seed-verification limitation instead); (e) clearing `selectedSeed` on a successful sow — a stale selection already degrades to the first-seed fallback, so keeping the field is the least-surprise semantics (creator policy can clear it) and keeps `select-seed` idempotent; (f) a StaminaSystem/skill-driven UI — stamina/skills stay D29 seeded fields; the inventory bar merely *presents* them; (g) an in-engine UI layer — browsers are render viewports (D8); the DOM panels are dev-layer presentation driven by the engine's intent/state surface.
+
+Full detail in `docs/api.md` (§4/§7/§8), `docs/mcp.md` (§player/§transport), and `examples/sample-game/README.md`. MCP surface: **111 tools / 32 resources / 14 prompts / 20 namespaces** (held; `player.intent` schema extended only).
+
+---
+
+## D36. Mod Packaging + Browser Mod Execution + Installer Generation (closing the D30/D32 documented gaps)
+
+**Decision**: Close the three long-documented packaging gaps — "Mods NOT in packaged output" (D30.4/D30-c), "browser `file://`/static module-import restrictions" (D30.4), and "no installer / no `electron-builder` in v1" (D32.6) — in one additive round: **(A) Mods are packaged into the content bundle** as code strings (`ContentBundle.mods?: Record<modId, {manifest, code}>` — additive, `BUNDLE_VERSION` stays 1); **(B) the engine executes packaged Mod code in bundle mode** with no fs access, via a **base64 `data:text/javascript` dynamic import** — the SAME mechanism in Node ≥ 20 and browsers, so the identical bundle boots in the headless core, the browser, and the Electron shell; **(C) `farm-pack --installer` generates an electron-builder config** so the desktop shell can build real installers (Linux AppImage — **verified end-to-end in this repo**; Windows NSIS documented config). Concretized by the implementation (2026-08-13) as:
+
+1. **Mods as code strings in the bundle** — `bundledModSchema` (`{manifest: {id: min1, version: min1}, code: string}` strict); `farm-pack --mods <dir>` scans the mods dir with the **ModLoader conventions** (each subdir with an `index.mjs`/`index.js` entry, manifest from the module's default export `{manifest: {id, version}}`, entry source read as TEXT via the shared `entryFileOf`/`readModEntry` helpers). A Mod with no entry or no valid manifest fails the pack loudly (per-mod messages, mirroring content compile failures — no silently-skipped Mods in a shipped game). `bundle.mods` is keyed by the mod's manifest id. Additive: `BUNDLE_VERSION` stays 1 and old bundles (no `mods`) load unchanged. **Why code strings, not bundled files**: the bundle is the single self-contained source of truth for packaged output (D30) — no separate Mod files ship, no async fetch of `../mods/...` from a packaged site, and the bundle can travel as one JSON.
+2. **Data-URL ESM import for browser + Node parity** — `ModLoader.loadFromSource(source, id, version?)` dynamic-imports `data:text/javascript;base64,<code>`. No code-eval restrictions beyond `import()` itself (the engine already *is* the mod execution boundary — D6's hook-only contract is enforced by what the loaded module can *touch*: a `HookContext`, never engine internals). Validation and hook binding are **identical to file loading** (the shared `bindModule` step: default export must be an object, manifest `{id, version}` required from the mod's own export — **no version fallback** to the passed `version`, keeping file/source parity; non-function hooks and unknown hooks reported as `errors` while still loading; import/manifest failures → `loaded: false` + errors, never throw). Cache-busting for reload uses a **`#t=` fragment** on the data URL — a query string would become part of the base64 payload and corrupt it (found + fixed in this round's tests). `reloadFromSources(ModSource[])` re-loads a stored set all-at-once.
+3. **Bundle mode boots packaged Mods** — `engine.start()` in bundle mode loads `bundle.mods` via `loadFromSource` when no `modsDir` is configured; **`modsDir` wins when both are given** (fs mods are the authoring/dev path; bundled mods are the shipped-game path). `engine.reloadMods()` in bundle mode re-runs `reloadFromSources` over the stored bundle.mods (no id form — all-at-once; `engine.mods` reflects bundled Mods, so the `mod.*` MCP introspection surface works unchanged in bundle mode — surface byte-identical). `apps/dev ?bundle=` mode needs **no change**: `createEngine({bundle})` already loads bundled Mods, so the packaged site and the desktop shell get Mod behavior automatically.
+4. **Installer = electron-builder config generation** — `farm-pack --installer` (implies `--desktop`) extends `generateDesktop`: copies the site files (`index.html` + `assets/` + `bundle.json`) into `out/desktop/` (electron-builder packages the desktop dir as the app; `main.cjs` resolves the site as `..`, which IS the app dir once packaged — the same `SITE_ROOT` convention works both unpackaged and packaged), writes `electron-builder.yml` (`appId: com.farm.<slug>`, `productName`, `directories.output: dist`, `files: [main.cjs, package.json, index.html, assets/**, bundle.json]`, `linux: {target: [AppImage], category: Game}`, `win: {target: [nsis]}` + `nsis: {oneClick: true}`), and extends `package.json` with `electron-builder` (devDep `^26`) + `scripts.dist`/`dist:win`. **Only Linux is buildable in this environment** (electron-builder needs a Windows host for NSIS); the Windows target ships as documented config. `electron-builder` stays a devDependency of the **generated** desktop folder, not of `@farm/pack` — no new runtime dependency in the monorepo.
+5. **Real AppImage build verified** — `pack:example:full` (content + mods + desktop + installer) produced `Pinefield Valley-0.1.0.AppImage` (128 MB, valid ELF, `--appimage-version` runs). One environment note: this machine's TLS setup requires `NODE_OPTIONS=--use-system-ca` for electron-builder's AppImage-runtime download; normal machines need nothing.
+
+**Rejected candidates**: (a) bundling Mod FILES into the output and importing relative paths — packaged static sites have no stable relative-module URL from `bundle.json` consumers, and the bundle's "single source of truth" (D30) would be split; (b) `eval`/`new Function` for source execution — dynamic `import()` of a data URL is the standard ESM path with real module semantics (top-level await, strict mode, real module identity + caching) in both Node and browsers; (c) allowing a manifest-version fallback in `loadFromSource` — file/source parity demands the same validation (manifest required); a bundle's manifest is already the mod's own export, copied at pack time; (d) an MCP `pack.*` namespace or Mod-authoring tools — packaging is an offline build-time tool (D30) and Mods are code, not MCP-creatable content (D6/D10); (e) shipping electron-builder in the monorepo — the generated desktop folder installs it; (f) Windows installer verification — requires a Windows host, documented as config-only (honest).
+
+Full detail in `docs/api.md` (§2/§5/§9) and `docs/mcp.md` (change log — MCP surface unchanged: **111 tools / 32 resources / 14 prompts / 20 namespaces**).
+
+---
+
+## D37. Scalability — WS Snapshot Assets On-Demand + `--split-assets` Bundle Splitting
+
+**Decision**: Close the two documented scalability gaps of the D31 asset pipeline in one additive round. **(A) WS snapshots stop shipping full base64 assets**: the D31 `snapshot.assets` field (the entire binary table, base64, in EVERY snapshot frame) is replaced by a **metadata-only `assetsIndex`** (`Record<assetId, {kind, mime, size}>`); clients request the bytes they actually need via a new **`assets-request`** message and the server replies per-client with a **`{type:"assets"}`** frame. **(B) Bundle assets can be split out of bundle.json**: `farm-pack` gains **`--split-assets`**, writing each asset as a sibling `out/farm-assets/<id>.<ext>` file and shipping a **metadata-only** bundle.json (`assetsIndex` with `file` paths — no base64), while the default inline mode stays byte-identical. Both fixes rest on one invariant: **the engine asset table is a METADATA INDEX — bytes are a transport concern**, not engine state (`AssetEntry.data` becomes optional; split entries carry `size`/`file`). Concretized by the implementation (2026-08-13) as:
+
+1. **WS on-demand assets (protocol evolution, additive frames)**: the snapshot's `assets` field → **`assetsIndex`** (metadata only — `{kind, mime, size}`, size from `assetByteSize` = declared size or base64-derived byte count). New client→server `{"type":"assets-request","ids":string[]}` (bounded: the client's `AssetCoordinator` batches ≤ **64 ids** per request and the server truncates over the same cap; **unknown ids ignored gracefully**; empty request → `{type:"assets", assets:{}}`). The server replies to the REQUESTING client only (request/response, not broadcast) with `{type:"assets", assets}` — each entry the full `AssetEntry {kind, mime, data}`. **This is a versioned protocol change**: old clients reading `snapshot.assets` break (documented in mcp.md's change log) — the D31 "full table in every snapshot" tradeoff (latency win, payload bloat that grows with every asset added) is replaced by metadata + on-demand bytes (payload stays O(snapshot), bytes fetched once per asset per client, cached thereafter). Rationale: the sample game's ~106 KB is small, but assets scale with creator content and every snapshot duplicates them; the render pipeline already degrades gracefully to fallbacks until textures arrive (D31's redraw-on-ready), so late-arriving bytes need no new render path.
+2. **Client-side coordinator (pure, node-testable)**: new `AssetCoordinator` (`packages/render/src/sync/asset-coordinator.ts`) tracks `wanted` (ids the presentation needs), `loaded` (bytes arrived), and `pending` (in-flight request) — **request dedupe** (never re-requests while pending or loaded), bounded batches, new wanted ids from later snapshots requested after prior batches resolve. The wanted set is derived per snapshot by the pure `snapshotAssetRefs` (`map.tiles` values + sprite `image` layer `src`s → images; `sound.src` ids → audio). `connectWs` feeds `{type:"assets"}` frames into the viewport (re-render with the merged table — the existing redraw-on-ready path) and `sound.setAssets` (async decode, buffers cached). `connectLocal` is unchanged (engine has all assets locally).
+3. **Bundle splitting (`--split-assets`)**: `AssetEntry.data` becomes **optional**; new `AssetIndexEntry` `{kind, mime, size, file?}` + `assetIndexEntrySchema` + helpers `assetByteSize`/`assetFileExtension`. `ContentBundle` gains **`assetsIndex?: Record<assetId, AssetIndexEntry>`** — carried by BOTH inline and split bundles (**the index invariant**: bundle.json always carries the metadata index; inline mode ALSO carries `assets` with base64 `data`, split mode carries only the index with `file` paths). `ContentLoader.loadBundle` builds `registry.assets` from whichever is present (inline entries win when both name an id, augmented with the index's `size`/`file`). `engine.assets` in split mode holds **metadata-only entries** (`{id, kind, mime, size, file}` — no `data`). `compileBundle(contentDir, modsDir?, splitAssets?)` + exported `splitBundleAssets` produce the split form; `packGame` writes `out/farm-assets/<id>.<ext>` (bytes from the inline compile — one file per asset, ext from MIME) then the metadata-only bundle.json. **Inline (default) output is byte-identical plus the additive `assetsIndex`** — zero regression for existing static-site/desktop output.
+4. **Who fetches the bytes — the render layer with an assets base**: the headless engine never fetches (browser-free, zero-fs bundle mode — metadata only). The render layer gets an **`assetsBase`**: `createViewport({assetsBase?})` and `createSoundPlayer(factory?, assetsBase?)` fetch split entries (`entry.file` → `${assetsBase}/${file}` → blob/arrayBuffer → texture/decode, cached by id) via the pure `resolveAssetUrl`; inline entries decode from `data:` as before. The base is provided by the app: **packaged static site** — `packGame` bakes `__FARM_ASSETS_BASE__ = "./farm-assets"` (vite define, resolved against `document.baseURI`); **dev `?bundle=` mode** — derived from the fetched bundle's directory (`farm-assets/` lives next to bundle.json in `dist-pack/`). `connectLocal` needs no change (the viewport/sound hold the base).
+5. **Sample-game + packaging**: `pnpm pack:example` stays inline (regression path); new root script `pnpm pack:example:split` runs `--split-assets`; `generateDesktop`'s installer copy includes `farm-assets/` when present (and `electron-builder.yml`'s `files` gains `farm-assets/**`). Tests: ws-broadcast +4 (metadata-only snapshot, request→response roundtrip, unknown ids ignored, empty request → empty response, 64-cap truncation), render +11 (coordinator 5, refs 3, url 3), pack +5 (flag parsing, inline index, splitBundleAssets, compileBundle split, full split packGame asserting files exist + sizes match source + split bundle boots), core +2 (loadBundle split + inline-merge). Browser E2E of split-mode rendering is not committed (prior-rounds pattern — D34/D35); the fetch plumbing is covered by unit tests + pack-output assertions + documented.
+
+**Rejected candidates**: (a) keeping `snapshot.assets` in parallel with `assetsIndex` — the fix IS to stop shipping bytes per snapshot; dual fields would double the snapshot and confuse the contract (the versioned protocol change is documented instead); (b) an unlimited `assets-request` (no cap) — unbounded response frames reintroduce the payload problem the fix removes; 64 ids is the documented bounded batch; (c) fetching via a headless-engine API in bundle mode — the engine is browser-free and zero-fs (D30); the render layer owns byte transport (D1/D8); (d) `createEngine({bundle, assetsBase})` plumbing into core — assets are presentation data (D31); the base is a RENDER-LAYER concern, provided by the app, not core engine state; (e) splitting Mod files or content defs alongside assets — only BINARY bytes scale badly; YAML defs stay in bundle.json (D30's single-source-of-truth); (f) `out/assets/` for the split dir — collision with the vite build output (`assets/` holds index-*.js/css); `farm-assets/` is the collision-free sibling name; (g) a `pack.*` MCP namespace or protocol/tool changes — both fixes are offline/protocol, MCP surface stays 20/111/32/14.
+
+Full detail in `docs/api.md` (§1/§2/§8/§9) and `docs/mcp.md` (WS transport section + change log). MCP surface unchanged: **111 tools / 32 resources / 14 prompts / 20 namespaces**.
+
+---
+
+## D38. Animation State Machines (content kind) + Render-Layer Smooth Movement
+
+**Decision**: Close the documented "movement animation" gap (D27's frame-clock exists, but there is **no state machine** switching idle/walk/dance sprites and **no tweening** — movement is instant grid-jump, D25/D22) with two additive, authority-preserving mechanisms: **(A) an `animation` content kind** — a state machine over sprite defs (`{id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}]}`, zod **strict** with the statemachine-kind `superRefine` cross-checks), driven by an entity's plain **`anim?: string` field** (the behavior language already sets/clears it — `set-state player.anim "dance"` / `set-state player.anim null`; null is a supported clear because the `set-state` value schema already accepts it, so **no language change is needed**), with the render layer falling back to **motion inference** (`"walk"` while moving, `"idle"` when still, else `initial`) — no engine movement-semantics change; **(B) smooth movement as a render-layer tween** — consecutive known tile-coordinate changes (player/npc/animal entities) are interpolated over `MOVE_DURATION_MS` (250 ms, easeOutQuad) in the viewport's rAF loop, while **the engine stays the authoritative instant grid-jump** (`IntentSystem.move`/`MovementSystem.moveTo` untouched — every viewport interpolates the same coords, so multi-viewport sync is consistent by construction). Concretized by the implementation (2026-08-13) as:
+
+1. **`animation` content kind + `animation.*` MCP namespace**: `animationSchema` in `packages/core/src/schema/animation.ts` (`AnimationDef`/`AnimationState`/`AnimationTransition`; `superRefine`: duplicate state ids rejected, `initial` must be a declared state, transition `from`/`to` must be declared states — mirroring `stateMachineSchema`); registered in `contentSchemas`/`contentDirNames` (dir `content/animations/`); the MCP namespace `animation.*` mirrors `sprite.*`/`sound.*`/`particle.*`/`shader.*` exactly — `validate`/`create`/`update`/`list`/`get` + `animation://catalog`/`animation://{id}` + `animation.create-prompt` (5 tools + 2 resources + 1 prompt — surface 20→21 namespaces / 111→116 tools / 32→34 resources / 14→15 prompts). The WS snapshot frame gains an additive `animations: AnimationDef[]` catalog (registry list at snapshot time; render `WsSnapshotFrame`/`RenderSnapshot` optional) — same pattern as `particles`/`shaders`/`shops`.
+2. **State resolution is content-driven with render-layer inference** — the render layer (no engine system, no hook): an entity whose `spriteRef` matches an animation def id uses that def (the sample game's player `spriteRef: "player"` matches `content/animations/player.yaml` — no entity change needed); the state is (1) the entity's explicit `anim` field when it names a declared state, else (2) motion inference (`walk` while tweening / `idle` when settled), else (3) `initial`; the resolved state's `spriteRef` sprite draws at the state's `fps` (default `DEFAULT_ANIMATION_FPS` 10) and `loop` (default true; false plays once then holds the last frame — the new pure `frameAtTimeOrHold` in sprite-draw). A spriteRef matching NO animation def renders exactly as before (additive, zero regression). The behavior language can force a state any time (`set-state player.anim "dance"`) and clear it back to motion inference (`set-state player.anim null`).
+3. **Smooth movement is a render-layer tween over the existing rAF loop** — pure `packages/render/src/tween.ts` (`tweenPosition`/`tweenTile` easeOutQuad, `motionState`/`animState` motion inference, `resolveAnimationState`/`animationState` state helpers, `MOVE_DURATION_MS` = 250) + viewport wiring: a per-entity `prevPositions` map records last-known tile coords; a coordinate change starts a `{from, to, start}` tween (pruned on settle / entity removal); `animTick` advances and redraws while any tween is active; drawn entities use an interpolated fractional-tile copy (so sprite drawing and shape fallbacks both glide), and entity labels follow the tweened position. Buildings/plots/static entities are never tweened (they carry coords but no movement semantics). No protocol change — entity coords were already in the snapshot.
+4. **Sample-game content stays honest**: `content/animations/player.yaml` (idle→`player`, walk→`player-walk` fps 8, optional transitions idle↔walk) + a new **programmatic 2-frame placeholder walk sprite** `content/sprites/player-walk.yaml` (body/head/hat layers shifted 1 px per frame — placeholder art, documented). Chicken/mayor walk sprites are deliberately NOT fabricated (D34-e precedent: the mechanism is unit-tested + documented; single-frame art would be dishonest). No animation def for the mayor → it renders as today (tweened orange dot) — the walk/idle inference defaults are render-layer, never engine-hardcoded.
+5. **Tests**: core `animation-schema.test.ts` (10 — valid def, minimal def, bad initial / duplicate state ids / undeclared transition / non-positive fps / strict keys / loader load + per-item error, plus `set-state null` clearing via `runActions`), mcp `animation-namespace.test.ts` (9 — mkdtemp lifecycle create/validate/update/list/get, duplicate overwrite, resources, prompt), render `tween.test.ts` (15 — tweenPosition at 0/50%/100% + clamps, easeOutQuad math, tweenTile, motionState, animState explicit-vs-inferred + null-clear, resolveAnimationState/initial fallback, frameAtTimeOrHold loop clamp) + `sprite-draw.test.ts` untouched; client-surface counts 116/34/15/21 + subsystem/tool/resource/prompt sorted lists + an `animation.*` lifecycle case; ws-broadcast asserts the snapshot `animations` catalog; sample-game content assertions gain `animation: ["player"]` + `player-walk`. E2E (playwright mid-tween screenshots) is NOT committed — timing-flaky; smooth movement is verified by the pure math tests + `pnpm -r build` (prior-rounds pattern, D34/D35/D37).
+
+**Rejected candidates**: (a) an engine-side AnimationSystem in the headless core — animation is pure presentation (sprite selection + motion inference) with no simulation consequence; the render layer owns it (D1/D8/D34 "no systems for presentation"), and a core system could not help anyway (the engine has no frame loop — D8); (b) enforcing hard transitions at runtime — the state machine is content *freedom* (a content override via `anim` may jump anywhere; the `transitions` graph is an authoring aid only); enforcement would break content freedom and add a second source of truth (D20 precedent); (c) adding a new behavior-language action just to clear `anim` — `set-state player.anim null` already works (the `set-state` value schema accepts null — `requiredValueSchema` rejects only `undefined`), so no language change is needed; (d) tweening in the engine (smooth `moveTo` with duration) — breaks the D22/D25 authoritative-instant-grid-jump contract, breaks every existing movement test, and would race multi-viewport clients; render-layer interpolation of the same authoritative coords is deterministic and consistent; (e) tweening buildings/plots — they carry coords but no movement semantics (never move); (f) fabricating walk art for the chicken/mayor — placeholder single-frame walk is dishonest; the mechanism is demonstrated on the player only (D34-e precedent); (g) a `player.anim`-style schema field or hook — `anim` is a plain open-typed entity field (the `label`/`spriteRef` precedent), snapshot-able and save.*-covered automatically; no new hook (the behavior language and motion inference are the two drivers, both already in place).
+
+Full detail in `docs/api.md` (§7/§8) and `docs/mcp.md` (§animation/§change-log). MCP surface: **116 tools / 34 resources / 15 prompts / 21 namespaces** (+5 tools +2 resources +1 prompt); content kinds 16→**17**; tests 692→**727** (+35).
+
+---
+
+## D39. Engine Rename — "farm" Branding → "nEngine"
+
+**Decision**: The engine is renamed from the "farm" branding to **nEngine** across the entire repository. The rename covers the npm package names (`@farm/core` → `@nengine/core`, `@farm/mcp` → `@nengine/mcp`, `@farm/render` → `@nengine/render`, `@farm/pack` → `@nengine/pack`, and the app packages `@farm/dev`/`@farm/mcp-server` → `@nengine/dev`/`@nengine/mcp-server`), the root package name (`farm-engine` → `nengine`), the MCP server identity (`farm-mcp-server` → `nengine-mcp-server`, `server.info.engine: "farm-engine"` → `"nengine"`), the file-format identifiers, the CLI name, the Vite build-time defines, the runtime env vars, and the split-assets output directory. **Content data is NOT renamed**: the sample game's map id `"farm"`, the `"farm-tiles"` tileset ref, the game name **"Pinefield Valley"**, and all dialogue/asset content stay exactly as they are — those are creator content, not engine branding. Historical D1–D38 changelog text keeps its original "farm" references (append-only principle).
+
+**Rationale**: The user renamed the engine to "nEngine" ("此引擎改名叫nEngine"). The "farm" prefix was the engine's working brand since its inception; the rename is cosmetic (identity/branding) and touches no engine mechanism, system behavior, protocol frame, or MCP surface shape. It IS a breaking change for two on-disk format identifiers (below) and for tool/CLI/env names — documented here so downstream consumers can migrate.
+
+Concretized as:
+
+1. **Package/identity renames**: `@farm/*` → `@nengine/*` across root + all package `package.json` names/deps (pnpm workspaces resolve by name — a `pnpm install` re-links the symlinks), root script filters (`--filter @nengine/dev`, `--filter @nengine/mcp-server`), the MCP stdio server identity `{ name: "nengine-mcp-server", version: "0.1.0" }`, and the `server.info`/`server://status` engine id `engine: "nengine"` (shape `{engine, version, content, subsystems, time}` unchanged). `server.orient` + the creation-namespace prompts now address "the nEngine".
+2. **Format identifiers — BREAKING (v1 era, no real user data)**: the save format literal `"farm-save"` → **`"nengine-save"`** (`SAVE_FORMAT`, `WorldSnapshot.format`, the `save.*` authoring prompt) and the bundle format literal `"farm-bundle"` → **`"nengine-bundle"`** (`BUNDLE_FORMAT`, `ContentBundle.format`, `loadBundle`'s structural-error message). `CURRENT_SAVE_VERSION` stays **1** and `BUNDLE_VERSION` stays **1**. Old `farm-save`/`farm-bundle` files are **NOT accepted** — this is a deliberate breaking change, acceptable because the engine is pre-release (v1 era, no real user data in the wild).
+3. **CLI + build-time + env renames**: the packaging CLI bin `farm-pack` → **`nengine-pack`** (USAGE text + error prefixes follow); the Vite defines `__FARM_BUNDLE_DEFAULT__` → **`__NENGINE_BUNDLE_DEFAULT__`** and `__FARM_ASSETS_BASE__` → **`__NENGINE_ASSETS_BASE__`** (apps/dev `bundle-default.d.ts`, `main.ts`, apps/pack `buildViteSite`, `vitepacktest.mjs`); the runtime env vars `FARM_CONTENT_DIR`/`FARM_MODS_DIR`/`FARM_WS_PORT` → **`NENGINE_CONTENT_DIR`/`NENGINE_MODS_DIR`/`NENGINE_WS_PORT`** (CLI flags `--content-dir`/`--mods-dir`/`--ws-port` unchanged); the split-assets output dir `farm-assets` → **`nengine-assets`** (pack output, `__NENGINE_ASSETS_BASE__` default `"./nengine-assets"`, installer copy + `electron-builder.yml` `files` list, bundle `assetsIndex.file` paths, the `electron-builder` `appId` prefix `com.farm.` → `com.nengine.`).
+4. **What did NOT change**: content (map id `"farm"`, tileset `"farm-tiles"`, game name "Pinefield Valley", dialogue text, sample-game assets), the MCP surface (21 namespaces / 116 tools / 34 resources / 15 prompts), all system behavior, all protocol frames (WS/input/intent), the CLI flag names, and the historical D1–D38 changelog text (append-only). The default desktop product name stays `"Farm Game"` (a creator-facing default game title, not engine branding).
+
+---
+
+## Operating Principles (meta-decisions)
+
+1. **MCP-first interface design** (decision-maker's directive): when designing any interface, design the MCP surface first (tools/resources/prompts), then derive the engine API from it. Engine internals exist to serve the MCP surface.
+2. **Living API docs** (decision-maker's directive): "要及时更新引擎的api文档" — API docs update in the same change as the interface change. A change without its doc update is incomplete.
+3. Mechanisms over policies; declarative data language over code; Hooks over deep imports.
+4. Type safety: never suppress errors (`as any`, `@ts-ignore`, `@ts-expect-error`).

+ 1102 - 0
docs/engine-guide-zh.md

@@ -0,0 +1,1102 @@
+# nEngine 引擎指南(中文)
+
+> 本文档面向两类读者:**想用本引擎制作游戏的内容作者**(第 4、5 章),以及**想理解引擎本身的外包工具 / AI Agent / 维护者**(第 1、2、3、6 章)。
+>
+> 本文档基于仓库的 **最终真实状态** 编写(`pnpm -r test` 全绿 **528** 个测试、`pnpm -r build` tsc strict 通过)。所有数字均已对照代码核对:**MCP 面 = 18 个命名空间 / 100 个工具 / 28 个资源 / 12 个 prompt;Hook = 16 个;内容种类 = 13 种 + 存档快照(非种类)+ 二进制资产表(非种类,D31);自动启动系统 = 12 个**。凡文档与代码冲突之处,以代码为准,并在本文第 6.4 节列出修正说明。
+
+---
+
+## 1. 简介
+
+### 1.1 引擎定位:这是引擎,不是游戏
+
+`nEngine` 是一个**俯视角 2D 种田游戏的引擎**(top-down 2D farming game engine),而不是一个可玩的游戏。它的核心价值不在于渲染性能,而在于三件事:
+
+- **可控性(controllability)**:可以被外部工具和 AI Agent 通过 MCP(Model Context Protocol)完整操控——建内容、调试、游玩、读状态。
+- **可扩展性(extensibility)**:通过引擎级 Mod 系统,用钩子(hooks)注入游戏逻辑。
+- **内容即数据(content-as-data)**:游戏的"内容"全部以 YAML 数据文件形式存在,引擎逻辑中**不硬编码任何内容**。
+
+用一句话概括:**引擎提供逻辑系统(机制),创作者只提供数据(YAML 内容文件)**。
+
+技术栈:TypeScript + PixiJS(仅渲染层)。Monorepo(pnpm workspaces),核心为无 DOM 依赖的 Node 头less(headless)库。
+
+### 1.2 三大支柱(不可妥协,非谈判项)
+
+1. **每个子系统都暴露 MCP**(MCP-first):一个中央 MCP 网关 + 每子系统的命名空间(`item.*`、`crop.*`、`map.*`、`save.*` …)。"每个功能都有 MCP"是一个由注册层强制的架构约束,而不是一个进程数统计。
+2. **引擎级 Mod 支持**:Mod 注册到**类型化的钩子点**(`onDayStart(ctx)`、`onCropHarvest(ctx)`、`onPlayerIntent(ctx)`…)。Mod 只能接触上下文对象(`HookContext`),**永远不能 `import` 引擎内部模块**。
+3. **数据/逻辑分离,强制实施**:游戏创作只能通过数据完成。内容文件(YAML)不含代码;引擎逻辑不含硬编码内容。
+
+### 1.3 设计哲学(详见 `docs/decisions.md`)
+
+- **MCP-first**:设计任何接口时,先设计 MCP 面(tools/resources/prompts),再从 MCP 面推导引擎 API。引擎内部存在的目的就是服务 MCP 面。
+- **机制非策略(mechanisms not strategies)**:例如存档*格式*是机制(引擎负责序列化/版本迁移/校验),存档*策略*(何时存档、几个档位、自动存档)是创作者内容。
+- **声明式数据语言优于代码;钩子优于深层 import。**
+- **Mod 只服务玩法;MCP 只服务引擎原生能力;不存在 Mod↔MCP 桥。**
+- **无头 Node 核心是权威(authority);浏览器只是渲染视口(render viewports)。**
+
+### 1.4 当前状态(里程碑)
+
+- Walking skeleton + Tier 1 完成;**Tier 2 全部完成**(行为语言 v2、对话、天气、建筑放置、NPC 网格移动、tick 循环、动物年龄、多视口同步)。
+- **Tier 3 内容轮(D29)完成**:体力/技能作为玩家字段、`onPlayerIntent` 钩子、多任务链(starter-quest → carrot-quest → mayor-talk-quest)、钓鱼小游戏 Mod。
+- **游戏打包(D30)完成**:`apps/pack` `nengine-pack` 把内容(YAML)编译成 `bundle.json` + 自包含静态网站;`createEngine({bundle})` + `loadBundle` 是引擎机制;无 `pack.*` MCP(均已文档化)。
+- **桌面打包(D32)完成**:`nengine-pack --desktop` 在 `out/desktop/` 生成可运行的 **Electron** 桌面壳(`main.cjs` + `package.json` + `README-desktop.md`):本地 HTTP 服务 + 960×640 BrowserWindow 加载同一份 bundle 页面;`npm i && npm start` 即可运行。Tauri 因需要 `webkit2gtk-4.1` 系统依赖(本环境无 sudo)而弃用;旁路无头核心(服务端 Mod + 多人)保留 v2(均已文档化)。
+- **Mod 打包 + 浏览器 Mod 执行 + 安装器(D36)完成**:`nengine-pack --mods <dir>` 把 Mod 打包进 bundle(`ContentBundle.mods`,版本仍是 1);`ModLoader.loadFromSource` 通过 base64 `data:text/javascript` 动态导入执行打包的 Mod 源码(浏览器 + Node 同机制、零 fs),bundle 模式自动启动 `bundle.mods`(同时给了 `modsDir` 时 `modsDir` 优先);`nengine-pack --installer`(隐含 `--desktop`)生成 `electron-builder.yml` + `dist`/`dist:win` 脚本并把站点文件复制进 `desktop/`——**Linux AppImage 构建已端到端验证**(Windows NSIS 仅文档配置)。MCP 表面不变(20 命名空间 / 111 工具 / 32 资源 / 14 提示词)。
+- 多人游戏玩法(multiplayer gameplay)**保留未做**(多视口同步机制已建成 D24;玩法规则是创作者内容)。
+
+---
+
+## 2. 架构总览
+
+### 2.1 系统拓扑:无头核心权威 + 浏览器渲染视口
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│                    Headless Node Core(权威)                   │
+│                                                              │
+│   Content (YAML) ──► Loader ──► Normalize ──► zod             │
+│                                                              │
+│                        ┌──────────────┐                      │
+│   World state ◄──────  │  Systems     │(纯函数,操作实体数据) │
+│                        │  (light ECS) │                      │
+│                        └──────────────┘                      │
+│            ▲             ▲        ▲                          │
+│            │  Hooks      │        │  Mod registry            │
+│        ┌───┴────┐   ┌────┴───┐  ┌─┴────────┐                │
+│        │ Mods   │   │ MCP    │  │ Content  │                │
+│        │ (Hook) │   │ Gateway│  │ Registry │                │
+│        └────────┘   └────┬───┘  └──────────┘                │
+└──────────────────────────┼───────────────────────────────────┘
+                           │ stdio / streamable HTTP
+              ┌────────────┴─────────────┐
+              │                          │
+   AI agents / dev tools          Browser viewport (PixiJS)
+   (创作 + 调试)                   (只渲染,WebSocket 同步)
+```
+
+**权威流(authority flow)**:内容数据定义世界 → 核心模拟世界 → MCP 把世界暴露给 Agent,WebSocket 把世界同步到浏览器。浏览器**从不拥有状态**,只渲染快照。
+
+### 2.2 Monorepo 包结构
+
+| 包 / 目录 | 职责 | DOM 依赖 | 运行环境 |
+|---|---|---|---|
+| `packages/core`(`@nengine/core`) | 世界模拟、内容加载器、系统、实体存储、Mod 加载器、钩子注册表、引擎门面 + `random.ts` | 无 | Node / 浏览器(headless) |
+| `packages/mcp`(`@nengine/mcp`) | 中央 MCP 网关、命名空间注册层、传输(stdio + HTTP) | 无 | Node |
+| `packages/render`(`@nengine/render`) | PixiJS 适配:世界→精灵绘制(`spriteRef` 绘制 + 形状回退)、Web Audio 播放、视口同步 + 输入 | 有 | 浏览器 |
+| `apps/mcp-server` | 独立 stdio 入口:headless 核心 + MCP 网关 + **可选 `--ws-port` WebSocket 广播通道** + `--content-dir`/`--mods-dir` | 无 | Node |
+| `apps/dev` | Vite 浏览器游玩视口(开发 App),URL 参数 `?contentDir=`/`?modsDir=`/`?sync=ws&url=` | 有 | 浏览器 + Node |
+| `content/` | **仓库自带的示例内容**(YAML 数据文件,不是代码) | — | — |
+| `mods/` | 仓库自带的示例 Mod(`mods/example-mod/index.mjs`) | — | — |
+| `examples/sample-game/` | **完整可玩示例游戏「Pinefield Valley」**:content + mods + README | — | — |
+
+工具链:pnpm workspaces、TypeScript strict、vitest、zod(schema 校验)。
+
+### 2.3 数据流:YAML → 归一化 → zod → 注册 → 系统
+
+1. **作者**:创作者写 YAML 内容(`content/crops/parsnip.yaml`)。
+2. **加载**:核心的内容加载器读取 YAML,归一化为内部 JSON 形状。
+3. **校验**:zod schema 严格校验归一化 JSON(内容无法走私代码)。
+4. **注册**:校验通过的内容进入内容注册表(Content Registry),以 id 为键。
+5. **使用**:系统读取注册表条目 + 实体数据;Mod 在系统边界上钩。
+6. **热重载**:YAML 文件变化 → 重新读取 → 重新校验 → 重新注入,**世界状态保留**。
+
+**MCP 面只看到归一化 JSON(D18)**。YAML 纯粹是创作界面。
+
+### 2.4 双通道:MCP(stdio)+ WebSocket 视口同步
+
+- **MCP stdio(创作/调试通道)**:`apps/mcp-server` 通过 stdio(开发工具链)/ streamable HTTP(运行期调试)暴露 18 个命名空间。这是 AI Agent 建内容、调试状态的通道。
+- **WebSocket 广播(视口同步通道)**:`apps/mcp-server --ws-port <port>` 在同一进程启动 WS 广播服务器,向所有已连接的浏览器视口推送快照 + 事件帧。**WS 不是 MCP 命名空间**——它服务不同的客户端(渲染视口,不是 Agent 工具)。
+
+两个通道可以同时开:一个进程同时服务 MCP 网关和 WS 广播。
+
+### 2.5 子系统模板(插件模板)
+
+每个新子系统都按插件方式添加(D12):
+
+1. **加 schema** —— 为它的内容和实体组件字段写 zod schema(`packages/core/src/schema/`)。
+2. **加系统** —— 操作实体数据的纯函数系统(`packages/core/src/systems/`)。
+3. **注册 MCP 命名空间** —— 在其命名空间下提供 tools/resources/prompts(`packages/mcp/src/namespaces/`)。**先写 `docs/mcp.md` 小节。**
+4. **声明钩子点** —— 声明 Mod 可以注册的类型化钩子。
+
+---
+
+## 3. 能力清单
+
+### 3.1 内容种类(Content Kinds)
+
+引擎有 **13 个内容种类**(`packages/core/src/schema/index.ts` 的 `contentSchemas` 对象逐一列出),外加 **存档快照(save snapshot,不是内容种类)**。每个种类对应一个目录;`time` 和 `weather` 是单文件种类。
+
+| 种类 | 内容文件 | 内部类型(zod) | 关键字段 | 创作工具(MCP) |
+|---|---|---|---|---|
+| `map` | `content/maps/<id>.yaml` | `MapDef` | `{id, width, height, layers, spawn, tilesetRef, tiles?}`(`tiles?` 为 D31 的 tile-id→图像资产 id 映射) | **无**(见 6.4 修正) |
+| `time` | `content/time.yaml`(单文件) | `TimeDef` | `{id, dayLengthTicks, seasons: [{name, dayCount}], start}` | **无**(直接写 YAML) |
+| `item` | `content/items/<id>.yaml` | `ItemDef` | `{id, name, kind: tool\|seed\|material\|consumable\|decoration, stackable, price?, use?}` | `item.validate/create/update/list/get` |
+| `crop` | `content/crops/<id>.yaml` | `CropDef` | `{id, seedItem, stages: [{name, days, waterRequired, spriteRef?}], seasons, drops: [{itemId, qty, chance?}]}` | `crop.*` 5 个生命周期工具 |
+| `animal` | `content/animals/<id>.yaml` | `AnimalDef` | `{id, stages: [{name, days, feedRequired, spriteRef?}], produces: [{itemId, qty, chance?}]}` | `animal.*` 5 个生命周期工具 |
+| `shop` | `content/shops/<id>.yaml` | `ShopDef` | `{id, buys: [{itemId, price}], sells: [{itemId, price, stock?}]}` | `shop.*` 5 个生命周期工具 |
+| `behavior` | `content/behaviors/<id>.yaml` | `TriggerDef` | `{id, when: ConditionExpr, fire: Action[]}` | `behavior.trigger.*` 5 个 |
+| `statemachine` | `content/statemachines/<id>.yaml` | `StateMachineDef` | `{id, initial, states: [{id, onEnter?, onExit?}], transitions: [{from, to, when?, on?}], onSchedule?}` | `behavior.statemachine.*` 5 个 |
+| `dialogue` | `content/dialogues/<id>.yaml` | `DialogueDef` | `{id, startNode, nodes: [{id, text, choices?, to?, actions?}]}` | `dialogue.*` 5 个生命周期工具 |
+| `weather` | `content/weather.yaml`(单文件) | `WeatherDef` | `{id, seasons: [{season, weathers: [{id, weight}]}], defaults?}` | **无**(单文件种类,直接写 YAML) |
+| `building` | `content/buildings/<id>.yaml` | `BuildingDef` | `{id, name?, footprint: {w, h}, buildable: "map"\|{tiles}\|"any", blocksMovement, description?, spriteRef?}` | `placement.building.*` 5 个 |
+| `sprite` | `content/sprites/<id>.yaml` | `SpriteDef` | `{id, frames: [{name?, durationTicks?, layers: [{shape: rect\|circle\|image, ...}]}]}`(zod strict 判别联合;**D31 新增 `image` 层 `{shape: image, src: 资产id, x?, y?, w?, h?}`**,引用 `content/assets/` 二进制) | `sprite.*` 5 个 |
+| `sound` | `content/sounds/<id>.yaml` | `SoundDef` | `{id, synth?: {wave: sine\|square\|triangle\|sawtooth, freq, durationMs, gain?, slideTo?}, src?}`(至少一个 `synth`/`src`;**`src` 是音频资产 id(D31),播放时优先于 `synth`**) | `sound.*` 5 个 |
+| `save`(非种类) | 无内容文件 | `WorldSnapshot` | `{format: "nengine-save", version, savedAt, world: Entity[], rngState?: {seed, calls}}` | `save.*`(机制工具) |
+
+> 说明:**二进制资产(`content/assets/`,`.png`/`.ogg`)不是内容种类**(D31)——没有 schema 注册、没有 MCP 命名空间;加载器把文件读成 base64 资产表(`registry.assets`/`engine.assets`,id = 去掉扩展名的文件名),YAML 用 id 引用。仓库根目录的 `content/` 是早期示例(无 `sprites/`/`sounds/`/`assets/`);**`examples/sample-game/content/` 是完整的现代示例**,13 个种类的目录齐全 + `assets/` 资产表(13 图 + 8 音)。
+
+### 3.2 12 个自动启动系统
+
+引擎在 `createEngine` 时自动注册以下系统(`engine.ts` 中按序注册,缺哪个补哪个,也可用 `systems` 选项覆盖):
+
+| 系统(id) | 职责 | 关键 API |
+|---|---|---|
+| `TimeSystem`(`time`) | 时钟:天/季节/tickInDay | `get()`、`advance(ticks)`(纯时钟)、`set(day, season)`、`step(dt)`(advance + 发 `time-tick`) |
+| `InventorySystem`(`inventory`) | 玩家背包 | `give(itemId, qty)`、`take(itemId, qty)`、`count(itemId)`、`items()`;发 `item-acquire`/`item-use` |
+| `PlantingSystem`(`planting`) | 种植:犁地/播种/浇水/收获 | `till(x,y)`、`sow(x,y,itemId)`、`water(x,y)`、`harvest(x,y)`;发 `crop-harvest` |
+| `GrowthSystem`(`growth`) | 生长:作物阶段 + 动物年龄(同一遍 `advanceDay`) | `step(dt)`、`forceStage(plotId, i)`、`spawnAnimal(animalId, x, y)`、`feed(animalId)`、`forceAnimalStage(...)`;发 `crop-grow`/`animal-grow`/`animal-produce`/`animal-fed` |
+| `EconomySystem`(`economy`) | 经济:商店买卖 | `buy(shopId, itemId, qty)`、`sell(shopId, itemId, qty)`、`price(shopId, itemId)`;发 `transaction` |
+| `SaveSystem`(`save`) | 持久化(机制) | `snapshot()`、`restore(snapshot)`、`migrate(...)`、`validate(snapshot)`、`registerMigration(from, fn)` |
+| `WeatherSystem`(`weather`) | 天气:按季节概率表加权抽取 | `get()`、`set(weatherId)`、`roll()`、`table(season)`;`on("day-start")` 时掷;发 `weather-change` |
+| `TriggerSystem`(`trigger`) | **v2 行为引擎**:触发器和状态机共用一个 match 遍历 + 同一重入保护 | `evaluate(condition, ctx)`、`fire(triggerId)`、`match(event, payload)`、`machineStatus(id)`、`machineTransition(id, to)`;发 `trigger-fire`/`state-change` |
+| `DialogueSystem`(`dialogue`) | 对话:内容文档系统 | `start(dialogueId, ctx?)`、`current()`、`choose(index)`、`end()`;发 `dialogue-start`/`dialogue-end` |
+| `PlacementSystem`(`placement`) | 建筑放置:网格 footprint/重叠/可建性 | `place(buildingId, x, y)`、`remove(x, y)`、`list()`、`occupies(x, y)`;发 `placement-change` |
+| `MovementSystem`(`movement`) | NPC 网格移动 + 阻挡 | `moveTo(npcId, x, y)`、`teleport(npcId, x, y)`;发 `npc-move` |
+| `IntentSystem`(`intent`) | **玩家输入机制**(单一机制) | `handle(action, args)`(`move`/`interact`)→ `IntentResult`;每次调用发 `player-intent` |
+
+系统接口:
+
+```ts
+interface System {
+  id: string;
+  init(ctx: SystemInitCtx): Promise<void> | void;
+  step?(dt: number): void;                     // 模拟 tick
+  on?(event: string, payload: unknown): void;  // 世界事件
+}
+```
+
+模拟驱动:`engine.tick(dt)` 是**权威显式驱动**(dt 单位为 tick;1 tick = 引擎时间单位 = `dayLengthTicks` 分之一天)。每步先推进 TimeSystem(跨越边界时触发 `day-start`/`season-change`),随后按注册顺序驱动每个系统的 `step`;TimeSystem 的 `step` 会发 `time-tick` 事件。`time.advance` 是纯时钟原语(不驱动系统、不发 `time-tick`)。可选自动 tick:`createEngine({tickIntervalMs, tickDeltaTicks?})`,**默认关闭**(保持测试/headless 确定性)。
+
+### 3.3 16 个 Hook 点(Mod 注册点)
+
+`packages/core/src/mods/hooks.ts` 的 `HOOK_NAMES`:
+
+| Hook | 触发事件 | 说明 |
+|---|---|---|
+| `onDayStart` | `day-start` | 每天开始 |
+| `onSeasonChange` | `season-change` | 季节更替 |
+| `onItemAcquire` | `item-acquire` | 获得物品 |
+| `onItemUse` | `item-use` | 消耗物品 |
+| `onCropGrow` | `crop-grow` | 作物阶段推进 |
+| `onCropHarvest` | `crop-harvest` | 收获作物 |
+| `onTransaction` | `transaction` | 商店买卖成交 |
+| `onTriggerFire` | `trigger-fire` | 触发器被触发 |
+| `onStateChange` | `state-change` | 状态机每次转移 |
+| `onDialogueStart` | `dialogue-start` | 对话开始 |
+| `onDialogueEnd` | `dialogue-end` | 对话结束 |
+| `onWeatherChange` | `weather-change` | 天气变化 |
+| `onPlacementChange` | `placement-change` | 建筑放置/移除 |
+| `onAnimalGrow` | `animal-grow` | 动物阶段推进 |
+| `onAnimalProduce` | `animal-produce` | 成熟动物产蛋/产出 |
+| `onPlayerIntent` | `player-intent` | **每次 `IntentSystem.handle` 调用(成功与失败都触发);唯一一个 `HookContext.payload` 有值的钩子** |
+
+`HookContext` 是 Mod 唯一能接触的对象:`{world, registry, time, emit(event, payload), payload?}`。`npc-move` 和 `animal-fed` 事件**故意没有钩子**——它们可以被行为语言匹配(`on: "npc-move"` / `on: "animal-fed"`),所以"Mod 钩子需求"这一判据不成立。
+
+### 3.4 18 个 MCP 命名空间(100 工具 / 28 资源 / 12 prompt)
+
+| 命名空间 | 工具数 | 工具要点 | 资源 | Prompt |
+|---|---|---|---|---|
+| `server.*` | 4 | `info`、`status`、`enable_subsystem`、`disable_subsystem` | `server://status` | `orient` |
+| `item.*` | 7 | 生命周期 `validate/create/update/list/get` + 调试 `query_inventory`/`give` | `item://catalog`、`item://{id}` | `create-prompt` |
+| `crop.*` | 10 | 生命周期 5 + 调试 `till`/`sow`/`water`/`harvest`/`stage` | `crop://catalog`、`crop://{id}` | `create-prompt` |
+| `map.*` | 2 | 只读 `list`/`get`(**无创作工具**) | `map://{mapId}` | 无 |
+| `time.*` | 4 | `get`、`advance`(纯时钟)、`tick`(完整模拟步)、`set` | `time://state` | 无 |
+| `shop.*` | 7 | 生命周期 5 + 调试 `buy`/`sell` | `shop://catalog`、`shop://{shopId}` | `create-prompt` |
+| `save.*` | 4 | `snapshot`/`restore`/`migrate`/`validate`(机制) | `save://state` | `authoring-prompt` |
+| `behavior.*` | 14 | `trigger.*` 5 + `fire` + `condition.validate` + `statemachine.*` 5 + `statemachine.status`/`transition` | `behavior://triggers`、`behavior://statemachines` | `trigger-prompt`、`statemachine-prompt` |
+| `dialogue.*` | 7 | 生命周期 5 + 调试 `start`/`choose` | `dialogue://catalog`、`dialogue://{id}` | `create-prompt` |
+| `weather.*` | 2 | 调试 `get`/`set` | `weather://state` | 无 |
+| `mod.*` | 3 | 只读 `list`/`status` + `reload`(**仅内省,无 Mod↔MCP 桥**) | `mod://{id}` | 无 |
+| `placement.*` | 9 | `building.*` 5 + 调试 `place`/`remove`/`list`/`occupies` | `placement://buildings`、`placement://placed`、`placement://{id}` | `create-prompt` |
+| `npc.*` | 5 | 调试 `list`/`get`/`spawn`/`move`/`teleport` | `npc://{npcId}` | 无 |
+| `animal.*` | 9 | 生命周期 5 + 调试 `spawn`/`feed`/`stage`/`entities` | `animal://catalog`、`animal://{id}` | `create-prompt` |
+| `player.*` | 1 | `intent`(`move`/`interact`) | `player://state` | 无 |
+| `sprite.*` | 5 | 生命周期 5(程序化精灵创作) | `sprite://catalog`、`sprite://{id}` | `create-prompt` |
+| `sound.*` | 5 | 生命周期 5(合成音效创作) | `sound://catalog`、`sound://{id}` | `create-prompt` |
+| `rng.*` | 2 | 调试 `get`/`set`(确定性 RNG) | `rng://state` | 无 |
+
+关键语义要点:
+
+- **创作优先,调试其次(D3)**:写内容文件的工具(`*.create` 写 `content/<kind>/<id>.yaml` 并热注册)是主要面;运行时工具只为调试服务,不服务玩家。
+- **JSON only(D18)**:所有工具参数/资源都是归一化 JSON。YAML 只是创作界面。
+- **`server.enable_subsystem`/`disable_subsystem`**:运行时开关命名空间(per-gateway 内存态,D9);**不允许禁用 `server` 本身**。
+- **`mod.*` 只有内省**:`mod.list`/`mod.status`/`mod.reload`(重载 = 进程内重导入 + 重新绑定钩子,带 cache-busting;D13 权威路径仍是重启 headless 核心)。
+- **`weather.*`/`map.*`/`time.*`/`npc.*`/`player.*`/`rng.*` 无 prompt**:要么是单文件内容种类,要么是运行时实体,要么是调试面,没有可指导的创作对象。
+
+### 3.5 行为语言 v2(ConditionExpr + 动作 + 状态机 + onSchedule)
+
+**条件表达式(ConditionExpr)**——递归组合,zod 校验,不含任意代码:
+
+- 比较:`{op: "eq"|"gte"|"lte", path, value}`(`eq` 严格相等;`gte`/`lte` 要求两侧都是数字)
+- 存在:`{op: "exists", path}`(`resolved !== undefined`)
+- 组合:`{and: [...]}`、`{or: [...]}`、`{not: {...}}`
+
+**路径解析约定**(`trigger`/`dialogue` 共用,`behavior-runtime.ts` 一处真相):
+- `time.<field>` → `TimeSystem.get()`(如 `time.day`、`time.season`)
+- `player.<field>` → `world.player.<field>` 的简写
+- `world.<entityId>.<field>` → 按 id 取实体
+- `payload.<key>` → 事件负载(仅在事件匹配时存在;`fire` 时无)
+
+**动作集(5 种)**:
+
+| 动作 | 字段 | 效果 |
+|---|---|---|
+| `set-state` | `{action, path, value}` | 写实体字段(`player.<field>` 或 `world.<id>.<field>`);**绝对值赋值**(无增量动作) |
+| `emit-event` | `{action, event, payload?}` | 发总线事件 |
+| `give-item` | `{action, itemId, qty}` | 给玩家背包发物品(发 `item-acquire`) |
+| `play-sound` | `{action, sound}` | 发 `sound-play {soundId}` 总线事件(渲染层播放) |
+| `dialogue` | `{action, text}` | headless v1 中的保留 no-op |
+
+**状态机(StateMachineDef)**:
+
+```json
+{
+  "id": "quest-id",
+  "initial": "inactive",
+  "states": [
+    { "id": "inactive" },
+    { "id": "active", "onEnter": [{ "action": "give-item", "itemId": "parsnip-seed", "qty": 1 }] },
+    { "id": "complete", "onExit": [{ "action": "emit-event", "event": "quest:done" }] }
+  ],
+  "transitions": [
+    { "from": "inactive", "to": "active", "on": "day-start", "when": { "op": "gte", "path": "time.day", "value": 3 } }
+  ],
+  "onSchedule": [
+    { "at": "day-start", "to": "working" },
+    { "at": { "day": 1, "season": "summer" }, "to": "resting" },
+    { "at": { "tickInDay": 240 }, "to": "working" }
+  ]
+}
+```
+
+- `states[]`:至少一个状态;每个状态可带 `onEnter`/`onExit` 动作列表。状态 id 唯一。
+- `transitions[]`:`from`/`to` 必须引用已声明状态;`on` 匹配当前总线事件名,`when` 是条件表达式;**至少一个 `on`/`when` 必填**;声明顺序第一个匹配的生效;每个事件每个状态机最多一个转移。**转移到当前状态是 no-op**(不触发 onEnter/onExit、不发 `state-change`)。
+- `onSchedule[]`:时间原语。`at` 为 `"day-start"`/`"season-change"`(匹配那些边界事件)、`{day?, season?}`(至少一个,匹配事件负载)或 `{tickInDay, day?, season?}`(匹配 `time-tick` 事件,D23)。计划条目从任意状态生效、在 `transitions` **之后**求值。
+- **运行时语义**:TriggerSystem 在一次 `match` 遍历中先触发器后状态机,共用一个**重入保护**(本次遍历中动作发出的事件不会被重匹配——级联/死循环不可能)。状态机实例是**世界实体** `{id: "sm:<machineId>", type: "statemachine", machineId, state}`,懒创建(首次转移时),`save.*` 自动持久化。任务进度约定:写 `player.questProgress.<questId>`。
+
+### 3.6 WS 协议帧(D24/D25/D26)
+
+| 方向 | 帧 | 含义 |
+|---|---|---|
+| 服务器 → 客户端 | `{"type":"snapshot","map":MapDef,"world":Entity[],"time":{day,season,tickInDay},"sprites":SpriteDef[],"sounds":SoundDef[]}` | 连接时 / `{"type":"sync"}` 回复时发送:完整当前状态(地图定义 + 全部实体 + 当前时间 + 精灵/音效目录,供视口渲染) |
+| 服务器 → 客户端 | `{"type":"event","event":string,"payload":unknown}` | 每次总线发射广播给所有客户端(`day-start`、`time-tick`、`crop-grow`、`npc-move`、`player-intent`、`input`、`sound-play`…) |
+| 客户端 → 服务器 | `{"type":"sync"}` | 请求一份新快照(浏览器客户端在每个事件帧后重同步) |
+| 客户端 → 服务器 | `{"type":"intent","action":"move"\|"interact","args":{tile:{x,y}}}` | 玩家输入 → `IntentSystem.handle` → `player-intent` 事件帧广播(非法输入不会崩服务器,会广播合成错误帧) |
+| 客户端 → 服务器 | `{"type":"input","input":string}` | 原始输入串(`"key:ArrowUp"`、`"key:Space"`、`"pointer:click:<x>:<y>"`)→ 发 `input` 总线事件(行为语言可 `on:"input"` 匹配);`key:Arrow*` 额外映射为玩家移动一格(player-centered,适配器层) |
+
+防御性序列化:不可序列化的负载跳过并打日志,服务器永不崩溃。
+
+浏览器客户端:`packages/render/src/sync/ws.ts` 的 `connectWs({url, viewport, onEvent?, onSnapshot?, sound?})`,句柄形状与本地 `connectLocal` 完全一致(`setTimeLabel`/`refresh`/`intent`/`input`/`destroy`),自动重连(500ms)。
+
+### 3.7 渲染能力(D26 数据 / D27 渲染半)
+
+- **程序化精灵**:`sprite` 内容种类携带逐帧绘制指令(`rect`/`circle` 图层 + `#rrggbb` 填充)。实体带 `spriteRef` 字段(作物阶段/建筑定义/动物阶段/玩家),渲染器在快照的 `sprites` 目录中解析。**缺 `spriteRef` 或未知 id → 回退到纯函数 `shapes.ts` 的形状绘制**(plots 的 tilled/watered/作物阶段色、建筑 footprint 块、npc/player/animal 圆点)。
+- **动画时钟 v1**:每帧 `durationTicks` 是相对权重,按文档默认 **10 fps**(`DEFAULT_ANIMATION_FPS`)折算循环长度;`frameAtTime(sprite, elapsedMs, fps?)` 纯函数选帧;单帧精灵是静态的。渲染器侧 `requestAnimationFrame` 时钟,仅当动画帧切换时重绘。建筑 footprint 缩放:0..32 tile 空间按实体 `w`×`h` 缩放(非均匀 footprint 下 `circle` 变椭圆)。
+- **Web Audio 音效**:`sound.ts` 的 `soundToAudioParams(def)` 纯映射 + `createSoundPlayer` 合成器(`OscillatorNode`;`wave`/`freq`/`durationMs`/`gain` 默认 0.3/`slideTo` 频率扫掠 + 淡出)。**AudioContext 懒创建**(首次播放或 `prime()` 从用户手势调用——浏览器 autoplay 策略);无 def / 无 context / 无 synth 静默跳过;播放错误不崩视口。
+- **键盘输入**:`keys.ts` 的 `arrowDirection(input)` 纯函数把 `"key:Arrow*"` 映射为相邻格 delta;开发 App 的 `keydown` → `sync.input("key:Arrow<…>")`(ws 模式发原始消息由服务器映射;本地模式客户端同规则映射)。`viewport.tileAt(clientX, clientY)` 屏幕坐标→格子(`null` 表示地图外),供画布点击输入。
+- **开发 App**:Interact/Move 工具栏选择工具;点击画布发意图;HUD 显示 天/季节、种子数 + 金币、意图反馈。
+
+### 3.8 确定性种子 RNG(D28)
+
+`packages/core/src/random.ts`(零依赖,mulberry32 风格,镜像 LÖVE 的 `love.math.RandomGenerator`):
+
+```ts
+interface RandomSource {
+  next(): number;              // [0,1)
+  nextInt(max: number): number; // [0,max)
+  setSeed(seed: number): void;
+  state(): { seed: number; calls: number };
+}
+```
+
+- `seedRandom(seed)`:同样的种子永远产生同样的 `next()` 序列。种子归一化为 uint32。
+- `randomSeed()`:`Math.random` + `Date.now` 的"未指定种子"默认。
+- `createEngine({seed?})` 暴露 `engine.rng`(readonly),经 `SystemInitCtx.rng` 注入系统。**引擎仅有的两个随机消费者共享这一个有种子来源**:WeatherSystem 的 day-start 抽取、PlantingSystem 收获掉落概率。
+- `save.*` 快照携带可选 `rngState {seed, calls}`;restore 时 `setSeed(rngState.seed)` 重新锚定,`calls` 重置为 0(**不回放抽取**——已文档化)。旧快照无 `rngState` 仍有效(可选字段,免迁移,`CURRENT_SAVE_VERSION` 不变)。
+
+### 3.9 存档机制(D14)
+
+- `save.snapshot()` → `{format: "nengine-save", version: 1, savedAt, world: Entity[], rngState?}`。**只覆盖世界实体 + RNG 锚**——TimeSystem 状态 v1 不序列化。
+- `save.restore()`:校验 → 拒绝未来版本 → 经注册的迁移升到当前版本 → 替换全部世界实体。
+- `save.migrate()`:迁移机制(v1 迁移注册表为空,前向迁移原样返回)。
+- `save.validate()`:结构校验,`{ok}` 或 `{ok: false, errors: [...]}`(正常结果,不是错误结果)。
+- **存档策略(何时存、几个档、自动存档)是创作者内容/Mod**(D14)——引擎只提供机制。
+
+---
+
+## 4. 快速开始
+
+### 4.1 下载与安装本引擎
+
+**前置要求**:
+
+- **Node.js >= 20**(引擎与所有工具链都要求;`apps/mcp-server` 入口从 `dist` 交付,所以先构建再启动服务器)。
+- **pnpm >= 9**(monorepo 用 pnpm workspaces 管理;`pnpm-lock.yaml` 已提交)。
+
+**获取源码**:nEngine 是一个可自行克隆的 monorepo 仓库:
+
+```sh
+git clone <nEngine 仓库地址> nengine
+cd nengine
+```
+
+**安装依赖**:
+
+```sh
+pnpm install
+```
+
+(pnpm 会按 `pnpm-workspace.yaml` 链接工作区包;Electron 的 postinstall 下载 Chromium 二进制约 100 MB,已列入 `onlyBuiltDependencies`。)
+
+**构建全部包**(6 个包 tsc strict + vite 均须通过):
+
+```sh
+pnpm -r build
+```
+
+**验证安装**(全部测试应通过,当前 **727** 个):
+
+```sh
+pnpm -r test
+```
+
+### 4.2 构建
+
+```sh
+pnpm -r build
+```
+
+(`apps/mcp-server` 入口从 `dist` 交付,所以先构建再启动服务器。)
+
+### 4.3 跑测试
+
+```sh
+pnpm -r test
+```
+
+最终状态:**727 个测试全绿**(`packages/core/test` 26 个测试文件 + `packages/render/test` 10 个 + `packages/mcp/test` 21 个命名空间测试 + `apps/mcp-server/test` 4 个集成测试 + `apps/pack/test` 1 个 + `apps/dev/test` 4 个)。D36 新增 16 个(core `mods.test.ts` 的 `loadFromSource`/bundle 模式 8、core `bundle.test.ts` mods schema 1、pack `--mods`/`--installer` 7)。
+
+### 4.4 运行示例游戏(Pinefield Valley)
+
+**路径一:浏览器游玩(本地模式)**
+
+```sh
+pnpm dev
+```
+
+打开 `http://localhost:5173/apps/dev/?contentDir=examples/sample-game/content&modsDir=examples/sample-game/mods`。浏览器里直接启动引擎 + 渲染视口 + 本地同步句柄,可玩。
+
+**路径二:MCP 调试(headless 核心 + stdio)**
+
+```sh
+node apps/mcp-server/dist/index.js \
+  --content-dir examples/sample-game/content \
+  --mods-dir examples/sample-game/mods
+```
+
+用任何 MCP 客户端通过 stdio 连接,`server.info` 确认内容加载,然后按第 5.16 节的工具序列驱动整局游戏。
+
+**路径三(进阶):headless 核心 + WS 视口**
+
+```sh
+# 终端 1:核心 + MCP + WS 广播
+node apps/mcp-server/dist/index.js \
+  --content-dir examples/sample-game/content \
+  --mods-dir examples/sample-game/mods \
+  --ws-port 8787
+
+# 终端 2:开发 App
+pnpm dev
+```
+
+打开 `http://localhost:5173/apps/dev/?sync=ws&url=ws://localhost:8787`。浏览器只渲染 headless 核心推送的快照。
+
+### 4.5 打包为桌面软件(安装器)
+
+做好游戏后,可以用引擎自带的 `nengine-pack` CLI 把它打包成**桌面软件**(Electron 安装器)。完整配方(以仓库自带的示例游戏 Pinefield Valley 为例):
+
+```sh
+# 1. 构建全部包(CLI 从 dist 交付)
+pnpm -r build
+
+# 2. 打包示例游戏 → 静态网站 + 桌面壳 + 安装器配置(--installer 隐含 --desktop)
+NODE_OPTIONS=--use-system-ca pnpm pack:example:full
+```
+
+> 注:`NODE_OPTIONS=--use-system-ca` 是本环境下载 AppImage 运行时(electron-builder 从 GitHub 拉取)所需的 CA 修正;`pnpm pack:example:full` 等价于
+> `node apps/pack/dist/index.js --content examples/sample-game/content --mods examples/sample-game/mods --out examples/sample-game/dist-pack --desktop --installer --name "Pinefield Valley"`。
+
+第 2 步会生成:
+- `examples/sample-game/dist-pack/` —— 自包含静态网站(`index.html` + `assets/` + `bundle.json` + `nengine-assets/`,浏览器可直接静态托管游玩);
+- `examples/sample-game/dist-pack/desktop/` —— Electron 壳(`main.cjs` + `package.json` + `README-desktop.md` + `electron-builder.yml` + 站点文件副本)。
+
+**构建 Linux AppImage 安装器**:
+
+```sh
+cd examples/sample-game/dist-pack/desktop
+npm install        # 首次安装 electron + electron-builder(约 100 MB)
+npm run dist       # 生成 dist/*.AppImage(Linux)
+```
+
+产物在 `dist/Pinefield Valley-0.1.0.AppImage`(**本仓库已端到端验证**)。
+
+**运行 AppImage**:
+
+```sh
+ELECTRON_DISABLE_SANDBOX=1 ./"Pinefield Valley-0.1.0.AppImage"
+# 或(无 sandbox 权限时):./"Pinefield Valley-0.1.0.AppImage" --appimage-extract-and-run
+```
+
+(Windows 安装器:`npm run dist:win`,需在 Windows 主机上运行——electron-builder 的 NSIS 目标需要 Windows,已作为文档化配置提供。)
+
+**常用参数**:
+
+- `--name <name>`:桌面产品名(窗口标题 + 安装器 `productName`)。不带则默认 `"Farm Game"`。
+- `--installer`:隐含 `--desktop`,额外生成 `electron-builder.yml` + `dist`/`dist:win` 脚本,并把站点文件复制进 `desktop/`(这样 electron-builder 才能把整个应用打包进安装器)。
+- `--mods <dir>`:把 Mod 打包进 bundle(桌面版里市长 NPC、钓鱼小游戏等 Mod 行为照常运行)。
+- `--split-assets`:把二进制素材写成独立的 `nengine-assets/` 文件(默认内联进 `bundle.json`)。
+
+**重要**:`nengine-pack` **每次运行都会重建 `desktop/` 目录**——改了内容后重新打包,必须回到 `desktop/` 里**重新 `npm install`** 再 `npm run dist`。完整玩法与打包说明见 `examples/sample-game/README.md`;机制细节见第 5.19 节与 `docs/decisions.md` D30/D32/D36/D37。
+
+---
+
+## 5. 用本引擎制作一个游戏(教程)
+
+> 本节以 `examples/sample-game`(Pinefield Valley)为蓝本,按创作顺序分步讲解。示例 YAML 取自真实内容(个别地方简化)。
+> 游戏剧情:春天第一天,你是新农民。第一天开始收到新手礼包(3 防风草种子 + 2 胡萝卜种子),第二天任务链启动(收获 3 个防风草 → 收获胡萝卜 → 与镇长对话),商店买卖、盖鸡舍、养鸡下蛋、对话、天气、钓鱼小游戏 Mod。
+
+### 5.1 ① 规划与建文件夹
+
+游戏 = `content/`(全部 YAML 数据)+ `mods/`(可选代码 Mod)。任何引擎逻辑都不用改。
+
+```
+my-game/
+├── content/
+│   ├── maps/           # 地图(每个 <id>.yaml)
+│   ├── time.yaml       # 时间配置(单文件)
+│   ├── weather.yaml    # 天气概率表(单文件,可选)
+│   ├── items/          # 物品
+│   ├── crops/          # 作物
+│   ├── animals/        # 动物
+│   ├── buildings/      # 建筑定义
+│   ├── shops/          # 商店
+│   ├── behaviors/      # 触发器
+│   ├── statemachines/  # 状态机(任务/NPC 日程/生产规则)
+│   ├── dialogues/      # 对话
+│   ├── sprites/        # 程序化精灵(外观,可选)
+│   └── sounds/         # 合成音效(可选)
+└── mods/
+    └── my-mod/index.mjs  # 可选代码 Mod
+```
+
+引擎没有"游戏名"概念——`apps/mcp-server` 通过 `--content-dir`/`--mods-dir`(或 `NENGINE_CONTENT_DIR`/`NENGINE_MODS_DIR`)指向任何内容/Mod 对。
+
+### 5.2 ② 地图 map
+
+`content/maps/farm.yaml`。`layers` 的 `data` 是二维 tile id 数组;**名为 `"ground"` 的图层被用作地形层**(否则取第一层)。`spawn` 是玩家出生点。
+
+```yaml
+id: farm
+width: 16
+height: 16
+tilesetRef: "farm-tiles"
+spawn: { x: 2, y: 4 }
+layers:
+  - name: ground
+    data:
+      - [water, water, water, water, grass, grass, ...]   # 16 格/行
+      - ...
+```
+
+要点:
+- tile id 就是地形的词汇表:`grass`(可走)、`tilled`(预犁地,纯视觉)、**`water`(引擎的 v1 不可走约定,`UNWALKABLE_TILES = {"water"}`)**。
+- 地图必须有 `spawn`(玩家出生)。`engine.start()` 用第一张地图生成玩家实体。
+- **注意:`map.*` 没有创作工具**(见 6.4)——地图直接手写 YAML。加载器校验它,`map.list`/`map.get` 读取它。
+
+### 5.3 ③ 时间 time
+
+`content/time.yaml`(单文件种类,无 MCP 创作工具):
+
+```yaml
+id: default
+dayLengthTicks: 480          # 一天 = 480 个引擎 tick
+seasons:
+  - { name: spring, dayCount: 28 }
+  - { name: summer, dayCount: 28 }
+  - { name: autumn, dayCount: 28 }
+  - { name: winter, dayCount: 28 }
+start: { day: 1, season: spring }
+```
+
+- `dayLengthTicks` 是所有 tick 语义的基准:`time.tick {ticks: 480}` = 推进一整天。
+- 引擎自动跨边界发 `day-start`/`season-change` 事件;tick 循环还发 `time-tick`。
+
+### 5.4 ④ 物品 item
+
+`content/items/<id>.yaml`。`kind` 是 `tool|seed|material|consumable|decoration` 之一;`kind: seed` 是播种机制认出的种子类型。
+
+```yaml
+id: parsnip-seed
+name: Parsnip Seed
+kind: seed
+stackable: true
+price: 20
+use: plant in tilled soil to grow a parsnip
+```
+
+用 MCP 创作同样可行:`item.validate {def}` → `item.create {def}` 会校验并写文件 + 热注册。背包在玩家实体上:`player.inventory: {itemId: qty}`。
+
+### 5.5 ⑤ 作物 crop(种子 → 阶段 → 掉落)
+
+`content/crops/<id>.yaml`。`seedItem` 关联种子物品;`stages` 定义生长周期(`days` 天数 + `waterRequired` 是否必须浇水);`drops` 收获掉落表。
+
+```yaml
+id: parsnip
+seedItem: parsnip-seed
+stages:
+  - { name: sprout, days: 2, waterRequired: true, spriteRef: parsnip-sprout }
+  - { name: grown, days: 2, waterRequired: true, spriteRef: parsnip-grown }
+  - { name: mature, days: 1, waterRequired: false, spriteRef: parsnip-mature }
+seasons:
+  - spring
+drops:
+  - { itemId: parsnip, qty: 1 }
+```
+
+玩法循环(可全程走 MCP 或浏览器意图):
+1. **犁地** `crop.till {x, y}`(幂等,创建 `plot:<x>:<y>` 实体)
+2. **播种** `crop.sow {x, y, seedItemId: parsnip-seed}`(消耗 1 种子,`cropItemId` 解析为**作物定义 id** 而非种子 id)
+3. **浇水** `crop.water {x, y}`(**设置** `watered: true`,不是切换——重复浇安全)
+4. **生长** `time.tick {ticks: 480}`(每日推进,`waterRequired` 阶段未浇水则跳过)
+5. **收获** `crop.harvest {x, y}`(要求最终阶段;按 `drops` 掷掉落进背包,发 `crop-harvest`)
+
+生长是模拟(GrowthSystem.step),不是 MCP 工具——MCP 的 `crop.stage` 只用于调试强推阶段。
+
+### 5.6 ⑥ 动物 animal
+
+`content/animals/<id>.yaml`。**动物复用 GrowthSystem**(没有 AnimalSystem);`feedRequired` 镜像作物的 `waterRequired`。
+
+```yaml
+id: chicken
+stages:
+  - { name: chick, days: 2, feedRequired: false, spriteRef: chicken-chick }
+  - { name: grown, days: 2, feedRequired: true, spriteRef: chicken-grown }
+  - { name: laying, days: 1, feedRequired: true, spriteRef: chicken-laying }
+produces:
+  - { itemId: egg, qty: 1 }
+```
+
+- 动物实体:`animal.spawn {animalId: chicken, x: 9, y: 3}` → `{id: "animal:chicken:1", type: "animal", ...}`(计数器后缀避免多实例冲突)。
+- 每天:成熟(最终阶段)动物发 `animal-produce` 并重置 `fed`(喂养每日消耗);非成熟动物推进 `daysInStage`,达标后 `animal-grow`。
+- **喂食/产出规则是行为语言内容,不是引擎**:`chicken-egg` 状态机匹配 `animal-produce` 事件来发鸡蛋(见 5.10)。引擎只发事件,不送任何东西。
+
+### 5.7 ⑦ 建筑 building(放置)
+
+`content/buildings/<id>.yaml`。`buildable` 是三态联合:`"map"`(地图上任意 tile)/ `{tiles: [...]}`(每个 footprint 格子的 tile id 必须在列表里)/ `"any"`(跳过 tile 检查)。
+
+```yaml
+id: coop
+name: Coop
+footprint: { w: 3, h: 3 }
+buildable: { tiles: [grass] }
+blocksMovement: true
+description: "Shelters a flock of chickens."
+spriteRef: coop
+```
+
+- `placement.place {buildingId: coop, x: 10, y: 4}` → 校验 def 存在 → 地图边界 → 每格可建性 → 不重叠,然后创建实体 `{id: "building:coop:10:4", type: "building", ...}`,发 `placement-change`。
+- v1 占用规则:**建筑挡建筑**;玩家和 NPC 是瞬态实体不占用格子(NPC 阻挡是 MovementSystem 的事)。
+- `blocksMovement: true` 的建筑同时挡 NPC 移动。
+
+### 5.8 ⑧ 商店 shop
+
+`content/shops/<id>.yaml`。**约定:`sells` = 商店卖给玩家(玩家付 `sells[].price`);`buys` = 商店从玩家收购(玩家赚 `buys[].price`)**。
+
+```yaml
+id: general-store
+buys:
+  - { itemId: parsnip, price: 30 }
+  - { itemId: carrot, price: 40 }
+  - { itemId: egg, price: 15 }
+sells:
+  - { itemId: hoe, price: 500 }
+  - { itemId: parsnip-seed, price: 20, stock: 99 }
+  - { itemId: carrot-seed, price: 25, stock: 99 }
+```
+
+- `shop.buy {shopId, itemId, qty}` → 检查金币 → 扣 `player.gold` → 加物品 → 发 `transaction`。
+- `shop.sell` 反向。`stock` 在本切片是**信息性的**(无库存消耗/补货逻辑)。
+- 玩家金币是实体平面字段 `player.gold`(初始 `STARTING_GOLD = 0`)。
+
+### 5.9 ⑨ 行为触发器 behavior
+
+`content/behaviors/<id>.yaml`。格式:`when <条件> → fire <动作>`。**触发器在除 `trigger-fire` 外的每个总线事件上求值**。
+
+```yaml
+id: starter-gift
+when:
+  and:
+    - { op: gte, path: time.day, value: 1 }
+    - not: { op: exists, path: player.flag.starter-gift }
+fire:
+  - action: give-item
+    itemId: parsnip-seed
+    qty: 3
+  - action: give-item
+    itemId: carrot-seed
+    qty: 2
+  - action: play-sound
+    sound: coin
+  - action: set-state
+    path: player.flag.starter-gift
+    value: given
+```
+
+"单次触发"模式:用 `set-state` 写一个 flag,条件里用 `not exists` 检查它。调试工具:`behavior.fire {triggerId}` 手动触发。
+
+### 5.10 ⑩ 状态机任务 statemachine(quest 链)
+
+`content/statemachines/<id>.yaml`。**任务就是状态机**(D19:QuestSystem 会重造状态机)。任务进度约定写 `player.questProgress.<questId>`。
+
+**任务链第 1 个——starter-quest**(第一天之后激活,收获 3 个防风草完成):
+
+```yaml
+id: starter-quest
+initial: inactive
+states:
+  - id: inactive
+  - id: active
+  - id: complete
+    onEnter:
+      - action: give-item
+        itemId: parsnip-seed
+        qty: 2
+      - action: play-sound
+        sound: harvest
+      - action: set-state
+        path: player.questProgress.starter-quest
+        value: complete
+transitions:
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      and:
+        - { op: gte, path: time.day, value: 2 }
+        - not: { op: exists, path: player.questProgress.starter-quest }
+  - from: active
+    to: complete
+    on: crop-harvest
+    when:
+      op: gte
+      path: player.inventory.parsnip
+      value: 3
+```
+
+**任务链第 2 个——carrot-quest**(starter 完成后 day-start 激活;收获胡萝卜完成,用 `payload.cropItemId`):
+
+```yaml
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      and:
+        - { op: eq, path: player.questProgress.starter-quest, value: complete }
+        - not: { op: exists, path: player.questProgress.carrot-quest }
+  - from: active
+    to: complete
+    on: crop-harvest
+    when:
+      op: eq
+      path: payload.cropItemId
+      value: carrot
+```
+
+**任务链第 3 个——mayor-talk-quest**(与镇长对话完成,`payload.dialogueId === "mayor"`;奖励蛋 + 金币)。**已知语言限制**:`set-state` 是绝对赋值,所以金币奖励是平值授予 `set-state player.gold 100`(增量动作延后到语言层)。
+
+**其他常用状态机模式**:
+- **NPC 日程**(`mayor-routine`):`onSchedule` 的 day-start 条目按 `payload.day` 条件切换状态;Mod 读这个实体的状态来决定 NPC 走去哪。
+- **生产规则**(`chicken-egg`):匹配 `animal-produce` 事件发鸡蛋——产出频率由内容(状态机)拥有,不是引擎。
+- **体力规则**(`daily-stamina`):day-start 恢复体力(`onSchedule: [{at: day-start, to: ready}]`,`ready.onEnter` 里 `set-state player.stamina 100`);低体力告警用条件转移 `when: {op: lte, path: player.stamina, value: 30}`。注意 `initial` 故意不同于 day-start 目标——调度机器进入当前状态是 no-op 会跳过 `onEnter`。
+- **Mod→内容桥**(`fishing-treat`):`mod:fish-caught` 是 Mod 发的自定义事件,状态机用 `on: mod:fish-caught` 匹配(见 5.15)。
+
+调试工具:`behavior.statemachine.status {machineId}`(`started: false` 表示还没转移过)、`behavior.statemachine.transition {machineId, to}`(强制转移)。
+
+### 5.11 ⑪ 对话 dialogue(分支/gating)
+
+`content/dialogues/<id>.yaml`。对话是**内容文档**(节点/选项/条件),不是状态机。
+
+```yaml
+id: mayor
+startNode: greeting
+nodes:
+  - id: greeting
+    text: "Mayor: Welcome to Pinefield Valley! How can I help?"
+    choices:
+      - text: "Any farming tips?"
+        to: farming-tips
+        actions:
+          - action: give-item
+            itemId: parsnip-seed
+            qty: 1
+      - text: "Got anything for me?"
+        to: quest-reward
+        when:
+          op: exists
+          path: player.questProgress.starter-quest
+      - text: "I am just passing through."
+        to: farewell
+  - id: farming-tips
+    text: "Mayor: Till a plot, sow parsnip seeds, and water them daily."
+    to: greeting
+  - id: quest-reward
+    text: "Mayor: You harvested three parsnips! Here is a little something."
+    actions:
+      - action: give-item
+        itemId: egg
+        qty: 2
+    to: farewell
+  - id: farewell
+    text: "Mayor: Good luck, farmer!"
+```
+
+要点:
+- 每个节点有 `id` + `text`,并且**恰好一个** `choices` 或 `to`(或两者都无 = 终节点)。
+- `choices[].when` 在**读取时**过滤可选选项(条件为假则隐藏,`dialogue.choose` 的 index 指向过滤后的列表)。
+- `to` 节点会合成一个 `Continue` 选项(headless 驱动有一个统一的推进动作 `choose(index)`)。
+- 终节点 / 全被 gate 的节点在进入时**自动结束对话**(文字仍交付,发 `dialogue-end`,移除 `dialogue:active` 实体)——避免 headless 软锁。
+- 节点 `actions` 进入时运行;选项 `actions` 选中时运行(同一 5 种动作集)。
+- 活动对话状态在实体 `{id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?}` 上(可存档)。
+
+### 5.12 ⑫ 天气 weather
+
+`content/weather.yaml`(单文件种类,**无创作工具**——直接写 YAML):
+
+```yaml
+id: default
+defaults:
+  sunny: { name: Sunny }
+  rain: { name: Rain }
+  wind: { name: Wind }
+  storm: { name: Storm }
+seasons:
+  - season: spring
+    weathers:
+      - { id: sunny, weight: 45 }
+      - { id: rain, weight: 35 }
+      - { id: wind, weight: 15 }
+      - { id: storm, weight: 5 }
+  - season: summer
+    weathers:
+      - { id: sunny, weight: 65 }
+      - { id: rain, weight: 15 }
+      # ...
+```
+
+- 权重是正整数(相对概率)。`defaults` 提供显示名(键必须是已声明的天气 id)。
+- 每次 `day-start` 从当前季节的表里做**加权抽取**(RNG 来自 `engine.rng`,可注入,同种子确定性)。
+- 初始天气 = 当前季节表第一项(启动无随机,确定性)。
+- `weather-change {from, to}` 只在真正变化时发。`weather.get`/`weather.set` 是调试工具。
+- 当前季节未被表覆盖时回退到第一季的表。没有 `weather.yaml` 的游戏正常启动(系统退化,调用抛清晰错误)。
+
+### 5.13 ⑬ 精灵 sprite(外观数据驱动)
+
+`content/sprites/<id>.yaml`——程序化绘制指令(0..32 tile 空间,左上原点),zod strict。
+
+```yaml
+id: player
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: rect, x: 10, y: 16, w: 12, h: 12, fill: "#8d6e63" }   # 身体
+      - { shape: circle, x: 16, y: 12, r: 6, fill: "#ffccbc" }        # 头
+      - { shape: rect, x: 10, y: 8, w: 12, h: 4, fill: "#ffb300" }    # 草帽
+```
+
+- 实体靠 `spriteRef` 引用精灵。系统自动维护外观:播种→作物阶段 0;生长推进→对应阶段;建筑放置→def 的 `spriteRef`;动物出生→阶段 0;玩家种子 `spriteRef: "player"`。
+- 多帧 = 动画(`durationTicks` 权重,默认 10 fps)。建筑精灵按 footprint 缩放。
+- **`image` 层(D31)**:`{ shape: image, src: 资产id, x?, y?, w?, h? }` —— 引用 `content/assets/` 里的图片(详见 5.17 ⑰ 素材管线)。
+- **缺 `spriteRef` / 未知 id / 缺素材 → 渲染器回退形状绘制**(现有内容零影响)。
+
+### 5.14 ⑭ 音效 sound
+
+`content/sounds/<id>.yaml`——合成器说明(Web Audio 风格)+ **`src` 音频资产引用(D31)**,zod strict。
+
+```yaml
+id: coin
+src: click-1
+```
+
+- `wave` ∈ `sine|square|triangle|sawtooth`;`gain` 可选 0..1;`slideTo` 可选(结束频率,扫掠)。
+- 行为语言的 `play-sound {sound}` 动作发 `sound-play {soundId}` 总线事件 → WS 广播 → 渲染层 `createSoundPlayer` 播放。
+- **`src` 是 `content/assets/` 里的音频资产 id(D31)**——渲染层 `decodeAudioData` 解码播放(按 id 缓存 AudioBuffer);**`src` 优先于 `synth`**,缺素材/解码失败静默跳过。至少 `synth`/`src` 之一必填。
+
+### 5.15 ⑮ Mod(hooks、onPlayerIntent 钓鱼案例)
+
+Mod 是 `mods/<id>/index.mjs`,`export default { manifest: {id, version}, hooks: {...} }`。**只接触 `HookContext`,永远不 import 引擎内部。**
+
+```js
+export default {
+  manifest: { id: "sample-game-mod", version: "0.1.0" },
+  hooks: {
+    onDayStart(ctx) {
+      const { world, time, emit } = ctx;
+      const { day, season } = time.get();
+      let mayor = world.get("npc:mayor");
+      if (!mayor) {
+        mayor = world.create({ id: "npc:mayor", type: "npc", x: 6, y: 3, name: "Mayor" });
+      }
+      // 读状态机实体的状态决定 NPC 走位(NPC 没有内容种类,是运行时实体)
+      const routineState = world.get("sm:mayor-routine")?.state ?? "sleeping";
+      mayor.x = routineState === "market-day" ? 12 : 6;
+      mayor.y = 3 + (day % 4);
+      emit("mod:sample-day-start", { modId: "sample-game-mod", day, season });
+    },
+
+    onPlayerIntent(ctx) {
+      // ctx.payload = 意图结果 {action, tile, ok, error?, detail?}(唯一带 payload 的钩子)
+      const intent = ctx.payload;
+      if (!intent || typeof intent !== "object") return;
+      if (intent.action !== "interact" || intent.ok !== true) return;
+      const tile = intent.tile;
+      if (!isWaterTile(ctx, tile.x, tile.y)) return;   // 读地图地形层判断"water"
+
+      const player = ctx.world.get("player");
+      if (player.stamina < 5) {
+        ctx.emit("mod:fish-too-tired", { x: tile.x, y: tile.y, stamina: player.stamina, day });
+        return;   // 体力不足 → "too tired" 门
+      }
+      player.inventory.fish = (player.inventory.fish ?? 0) + 1;   // +1 鱼
+      player.stamina -= 5;                                        // −5 体力
+      player.skills.fishing = (player.skills.fishing ?? 0) + 1;   // 技能 +1
+      ctx.emit("mod:fish-caught", { fish: "fish", size: 1 + (stats.catches % 3), ... });
+      ctx.world.remove(`plot:${tile.x}:${tile.y}`);   // 归一化引擎默认的"interact 犁地"行为
+    },
+  },
+};
+```
+
+要点:
+- **Mod↔内容桥**:Mod 只能注册钩子、不能订阅原始总线事件(D6/D13),所以 Mod 用 `ctx.emit("mod:fish-caught", ...)` 发**自定义总线事件**,让内容层(状态机 `fishing-treat` 用 `on: mod:fish-caught` 匹配)接住——第 3 次捕获送一个蛋。这就是 D5 逃生舱分工:内容匹配事件、Mod 处理命令式逻辑。
+- **`onPlayerIntent` 是唯一 payload 钩子**:意图负载是瞬态的(不在世界状态里),Mod 只能通过钩子的 `payload` 读到。
+- 鱼的 size 是确定性伪尺寸(`1 + (catches % 3)`)——**Mod 没有 RNG 访问权**(引擎 RNG 是世界随机,D28)。
+- `mod.reload` 可进程内重载单个 Mod(cache-busting 重导入);权威路径是重启 headless 核心(D13)。
+- `mod.list`/`mod.status` 只读内省。**没有 Mod↔MCP 桥**(D10):Mod 不能注册 MCP 工具,MCP 也不能调用 Mod 注册的行为。
+
+### 5.16 ⑯ 测试与调试
+
+**MCP 客户端驱动整局(示例序列)**——连上 stdio 后:
+
+1. `server.info` — 确认内容已加载(map `farm`、crops `carrot`+`parsnip`、statemachines 7 个、dialogue `mayor`、shop `general-store`…)。
+2. `time.advance {ticks: 480}` — 第一天开始:新手礼包到账、starter-quest 激活、天气掷出、Mod 生成 `npc:mayor`。
+3. `item.query_inventory` — 查看礼包。
+4. `npc.list` — 看到 Mod 生成的镇长。
+5. `weather.get` — 看到掷出的天气。
+6. 耕作:`crop.till {x: 4, y: 4}` → `crop.sow {x: 4, y: 4, seedItemId: parsnip-seed}` → `crop.water {x: 4, y: 4}`。
+7. `time.tick {ticks: 480}` — 生长一天(重复浇水)。
+8. 成熟后 `crop.harvest {x: 4, y: 4}` → 任务完成(`behavior.statemachine.status {machineId: starter-quest}`)。
+9. `shop.sell {shopId: general-store, itemId: parsnip, qty: 3}` → 金币。
+10. `shop.buy {shopId: general-store, itemId: carrot-seed, qty: 1}`。
+11. `placement.place {buildingId: coop, x: 10, y: 4}`。
+12. `animal.spawn {animalId: chicken, x: 9, y: 3}`。
+13. `animal.stage {id: animal:chicken:1, stageIndex: 2}` + 每日 `animal.feed`。
+14. `time.tick {ticks: 480}` → `animal-produce` → `chicken-egg` 机器发蛋。
+15. `dialogue.start {dialogueId: mayor}` → `dialogue.choose {index: 2}`(仅任务完成后出现)。
+16. 任务链:推进一天激活 carrot-quest → 种/收胡萝卜 → 再推一天激活 mayor-talk-quest → 对话完成。
+17. **钓鱼**:`player.intent {action: interact, tile: {x: 3, y: 2}}`(左上 4×3 水塘)→ +1 鱼、−5 体力、`skills.fishing` +1;体力 < 5 → `mod:fish-too-tired`。
+
+**浏览器游玩**:本地模式或 ws 模式都行。Interact/Move 工具栏 + 画布点击 + 方向键移动 + HUD(天/季节、种子数、金币、意图反馈)。`player.intent` MCP 工具就是给 Agent 的同一意图机制。
+
+**测试**:`pnpm -r test`。`apps/mcp-server/test/sample-game.test.ts` 把整个 Pinefield Valley 从启动到钓鱼完整驱动一遍。
+
+### 5.17 ⑰ 二进制素材管线(Assets, D31)
+
+**素材 = 引擎内容,但不是内容种类**:把 `.png`/`.ogg` 文件放进 `content/assets/`,加载器就会把每个文件读成一条 **base64 资产条目**(`{kind: "image"|"audio", mime, data}`),id = 去掉扩展名的文件名(如 `grass-1.png` → `grass-1`)。资产表挂在 `registry.assets` / `engine.assets`(同一个 Map),**不需要 schema、没有 MCP 命名空间**——YAML 只用 id 引用(MCP 面 18/100/28/12 完全不变)。
+
+**三处消费点**:
+
+1. **精灵 `image` 层**:`{ shape: image, src: 资产id, x?, y?, w?, h? }`。`x`/`y` 是图像左上角在精灵 0..32 贴图空间里的位置(默认 0,0);`w`/`h` 是图像在贴图空间里的尺寸,**裁剪永不超出源图**(比原图小 = 从源图左上角裁这么多像素;比原图大 = 整图放大;缺省 = 自然像素尺寸)。原点是源图左上角(v1 只支持单块贴图,精灵表/图集格式是文档化未来路径)。示例:作物阶段、鸡舍、围栏都用了 `image` 层(有的叠了程序化根/叶点缀)。
+2. **地图 `tiles` 映射**:`map` 定义可加 `tiles: { grass: grass-1, water: water-1, tilled: tilled-1 }`——渲染时对应 tile id 用图像贴图绘制(对齐瓦片像素框),缺省/未映射回退到 `tileColor(id)` 调色板。
+3. **音效 `src`**:`sound` 定义可写 `src: 资产id`(如 `coin` → `click-1`),渲染层 `decodeAudioData` 解码播放(按 id 缓存 AudioBuffer,异步;**`src` 优先于 `synth`**,缺素材/解码失败静默跳过)。
+
+**传输与打包**:资产以 base64 内联在 **bundle 的 `assets` 字段**(`BUNDLE_VERSION` 保持 1,老 bundle 无 `assets` 照常加载)和 **WS 快照的 `assets` 字段**(`connectWs`/`connectLocal` 把表喂给 viewport 和声音播放器)里。`nengine-pack` 编译时把 `content/assets/` 收进 `bundle.assets`——打包产物仍是单文件 `bundle.json`(纯 JSON,无独立二进制文件)。**约束:保持素材小巧**——base64 会让体积膨胀约 33%,且每个快照帧都携带整表。
+
+**渲染加载策略**:viewport 把每个图像资产经 `data:<mime>;base64,<data>` URL 载入 PixiJS 纹理(按 id 缓存 `texture` + 像素尺寸;**异步加载 + 全部就绪后重绘**,避免首帧停在回退形状);图像命令用 `Sprite` 绘制(子纹理 `frame` 矩形 = 源裁剪区)。缺纹理 → 回退到形状/调色板,绝不崩溃。
+
+**示例素材**:`examples/sample-game/content/assets/` 放了 13 张 Kenney Tiny Farm 瓦片 + 8 个 Kenney 界面音效(CC0),id 自解释:`grass-1`/`grass-2`/`water-1`/`tilled-1`/`plant-sprout`/`plant-grown`/`plant-mature`/`carrot-sprout`/`carrot-grown`/`carrot-mature`/`coop-1`/`coop-2`/`fence-1`;`click-1`/`confirm-1`/`error-1`/`back-1`/`drop-1`/`pluck-1`/`switch-1`/`bong-1`。地图 `tiles`、作物/建筑精灵、四个音效 def(till→drop-1、water→pluck-1、harvest→confirm-1、coin→click-1)都改用了资产;玩家与鸡保持程序化(两种路径并存演示)。
+
+### 5.18 ⑱ 存档与种子(save + seed)
+
+- **存档**:`save.snapshot` 拿走完整世界快照;`save.restore` 放回。快照含 `rngState`(RNG 锚),恢复时重新锚定种子。**时间系统状态 v1 不序列化**(只序列化世界实体)。
+- **种子**:`createEngine({seed: 42})` 让整个世界随机确定性化(天气掷取、收获掉落)。`rng.get` 看当前 `{seed, calls}`;`rng.set {seed}` 重置。**同种子 → 相同世界**——回放、确定性测试、调试全靠它。
+
+---
+
+### 5.19 ⑲ 打包发布(Packaging, D30)
+
+做好一个游戏后,可以用引擎自带的打包 CLI 把它发布成**自包含静态网站**:一个 `bundle.json`(内容包 + **D31 资产表**——base64 内联的 `content/assets/` 二进制;打包产物的唯一内容来源,不发布任何 YAML、也不发布独立二进制文件)+ `index.html` + `assets/`(浏览器端完整可玩 UI)。打包出的游戏**不需要开发服务器、不需要无头核心**,直接在浏览器里用 `createEngine({bundle})`(bundle 模式)+ LocalSync 运行。
+
+**打包命令**(先构建 CLI,产物在 `dist`):
+
+```sh
+pnpm -r build
+
+# 打包示例游戏(等价命令)
+pnpm pack:example
+node apps/pack/dist/index.js \
+  --content examples/sample-game/content \
+  --out examples/sample-game/dist-pack
+```
+
+- `--content <dir>`:游戏内容目录(必填)。
+- `--out <dir>`:输出目录(默认 `./dist-pack`)。
+- `--desktop`:额外生成 Electron 桌面应用到 `<out>/desktop/`(D32)。
+- `--name <name>`:桌面产品名(窗口标题;默认 `"Farm Game"`)。
+- `--help`:查看用法。
+
+**打包流程**(`apps/pack` 三步):① 用核心 `ContentLoader` 加载并**逐条 zod 校验**内容(任何一条失败 → 带 kind/id 的错误信息,退出码 1);② 生成 `bundle.json`(`{format: "nengine-bundle", version: 1, content: {种类: [已校验定义]}}`);③ 用 vite 构建 `apps/dev` 应用(复用同一套 UI 的 `?bundle=` 模式,烘焙默认 bundle 路径)输出到目标目录。
+
+**运行打包后的游戏**:
+
+```sh
+npx serve examples/sample-game/dist-pack
+# 或:python3 -m http.server 8000 --directory examples/sample-game/dist-pack
+# 打开 http://localhost:3000(或 :8000)——无需任何 URL 参数
+```
+
+打开后与开发模式体验一致:工具栏 Interact/Move、方向键移动、HUD(天/季节、种子、金币)、精灵、合成音效,全部可用。
+
+**引擎机制**:`ContentBundle`/`contentBundleSchema`(`packages/core/src/content/bundle.ts`);`ContentLoader.loadBundle(bundle)` 在启动时**逐条复验**每种定义(边界防御,很便宜);`createEngine({bundle})` 是 `contentDir` 的替代内容源(二选一,且只允许一个;bundle 模式完全不碰文件系统,浏览器安全)。开发模式下也能用 `?bundle=` 参数试同一份包:`http://localhost:5173/apps/dev/?bundle=examples/sample-game/dist-pack/bundle.json`。
+
+**桌面运行(D32)**:`nengine-pack --desktop` 会在 `out/desktop/` 生成 Electron 壳:
+
+```sh
+# 打包示例游戏 + 桌面壳(窗口标题 "Pinefield Valley")
+pnpm pack:example:desktop
+
+# 运行桌面版(首次安装 Electron,约 100 MB)
+cd examples/sample-game/dist-pack/desktop
+npm install
+npm start
+```
+
+壳的 `main.cjs` 用 `node:http` 在 127.0.0.1 开一个临时端口 HTTP 服务(仅 GET、正确的 MIME、路径穿越防护、只提供确切文件),再开一个 960×640 的 `BrowserWindow`(`contextIsolation: true`/`nodeIntegration: false`,窗口标题 = 产品名)加载 `index.html`——与浏览器里跑的是**同一份 bundle 页面**(`createEngine({bundle})` + LocalSync,页面零 Node 访问)。`SITE_ROOT = resolve(__dirname, "..")`,从任何工作目录启动都能找到站点目录。选 Electron 而非 Tauri:Tauri 需要 `webkit2gtk-4.1`/`javascriptcoregtk-4.1` 系统依赖(本环境无 sudo 装不了),Electron 纯 npm(自带 Chromium)。
+
+**v1 明确不做(已文档化)**:打包产物**不含 Mod**(Mod 是服务端/创作期功能,浏览器模块导入受限);桌面端 v1 **无安装器**(不用 electron-builder)、用默认 Electron 图标、无旁路无头核心(Mod + 多人游戏玩法是 v2 文档化未来路径);打包是**离线构建期工具**,因此**没有 `pack.*` MCP 命名空间**(D30/D32——桌面壳同样零 MCP/引擎表面变化)。
+
+---
+
+## 6. 参考
+
+### 6.1 文档地图(保持同步)
+
+| 路径 | 用途 |
+|---|---|
+| `docs/decisions.md` | 全部架构决策(D1–D32),含理由与用户修正。**WHY 的事实来源。** |
+| `docs/architecture.md` | 顶层架构、包边界、目录布局、核心 API 概览 |
+| `docs/mcp.md` | **MCP-first 接口规格**:每个子系统的命名空间工具/资源/prompt。接口契约 |
+| `docs/api.md` | 引擎 API 参考,按 MCP 服务组织。**活的 API 文档** |
+| `docs/boundaries.md` | 引擎/游戏边界:引擎内置 vs 创作者内容,三判据 + 四层模型 + 15 项裁决 |
+| `AGENTS.md` | 项目规则 + 当前状态摘要 |
+| `examples/sample-game/README.md` | 示例游戏 Pinefield Valley 的完整游玩/驱动说明 |
+
+### 6.2 决策摘要(D1–D32 一句话)
+
+| 决策 | 一句话 |
+|---|---|
+| D1 | 基于成熟渲染库(PixiJS)自建引擎层 |
+| D2 | 技术栈 TypeScript + PixiJS |
+| D3 | MCP 以**创作优先**,运行时只做调试 |
+| D4 | **强制**数据驱动创作(内容不含代码、引擎不硬编码内容) |
+| D5 | 声明式行为语言 + Mod 逃生舱 |
+| D6 | Mod 纯钩子接口,只能碰上下文对象 |
+| D7 | 核心/渲染分离,核心零 DOM,浏览器优先 |
+| D8 | 无头 Node 核心是权威,浏览器是渲染视口 |
+| D9 | 中央 MCP 网关 + 命名空间注册(`server.enable/disable_subsystem`) |
+| D10 | **禁止 Mod↔MCP 桥** |
+| D11 | 轻量 ECS:实体 = 扁平数据对象,系统 = 纯函数 |
+| D12 | MVP 范围 Tier 1,架构预留全部层级 |
+| D13 | 数据热重载;代码 Mod 重启核心 |
+| D14 | 持久化 = 通用序列化 + 版本迁移,策略留给创作者 |
+| D15 | 行为语言 v1:条件表达式 + 触发器 |
+| D16 | 自用,但要完整丰富文档 |
+| D17 | 先做纵向切片(数据/渲染/MCP/Mod 端到端) |
+| D18 | 内容 YAML 手写 + JSON 内部 |
+| D19 | 引擎/游戏边界:三判据 + 四层模型 + 15 项裁决 |
+| D20 | 行为语言 v2:独立 `statemachine` 种类 + `onSchedule` 时间原语 |
+| D21 | 对话(内容文档系统)+ 天气(轻量概率掷取系统) |
+| D22 | 放置(网格 footprint 原语)+ NPC 网格移动(`moveTo` + 阻挡) |
+| D23 | tick 循环(`engine.tick(dt)` + `time-tick`)+ GrowthSystem 泛化到动物 `age` |
+| D24 | 多视口同步:WS 广播通道 + `connectWs` 客户端 + 快照驱动绘制 |
+| D25 | 玩家意图:`IntentSystem.handle`,三个薄通道(MCP/WS/local) |
+| D26 | 表现管线数据/协议半:程序化 `sprite` + 合成 `sound` + 原始 `input` |
+| D27 | 表现管线渲染半:精灵绘制 + Web Audio 播放 + 键盘输入 |
+| D28 | 确定性种子 RNG(LÖVE 对比中唯一采纳的差距) |
+| D29 | Tier 3 内容轮:体力/技能字段 + `onPlayerIntent` 钩子 + 多任务链 + 钓鱼 Mod |
+| D30 | 游戏打包:内容包(`nengine-bundle`)+ 自包含静态网站;`createEngine({bundle})`;无 Mod/无 `pack.*` MCP |
+| D31 | **素材管线**:`content/assets/` 二进制(`.png`/`.ogg`)作为引擎内容(非内容种类、无 MCP 命名空间);base64 进 bundle/WS 快照;精灵 `image` 层 + 地图 `tiles` 映射 + `sound.src` 音频播放;渲染纹理/缓冲按 id 缓存、异步加载、就绪重绘 |
+| D32 | **桌面打包**:Electron(弃 Tauri——需 `webkit2gtk-4.1` 系统依赖);`nengine-pack --desktop` 在 `out/desktop/` 写 `main.cjs`(本地 HTTP 服务 + BrowserWindow);无安装器、默认图标、旁路无头核心保留 v2 |
+
+运行原则(meta-decisions):MCP-first 接口设计;API 文档与接口变更同改;机制优于策略;永不压制类型错误(`as any`/`@ts-ignore` 禁用)。
+
+### 6.3 常用 MCP 工具速查
+
+| 目的 | 工具 |
+|---|---|
+| 查看引擎/内容/子系统 | `server.info`、`server.status` |
+| 关/开子系统 | `server.disable_subsystem {name}`、`server.enable_subsystem {name}` |
+| 造物品/作物/动物/商店/精灵/音效 | `*.validate` → `*.create` → `*.update` → `*.list`/`*.get` |
+| 造触发器/状态机/对话 | `behavior.trigger.create`、`behavior.statemachine.create`、`dialogue.create` |
+| 看时间/推进时钟/完整模拟步 | `time.get`、`time.advance {ticks}`、`time.tick {ticks}` |
+| 耕作循环 | `crop.till` → `crop.sow` → `crop.water` → `time.tick` → `crop.harvest` |
+| 经济 | `shop.buy`、`shop.sell` |
+| 建筑/NPC/动物 | `placement.place`、`npc.move`、`animal.spawn`/`animal.feed` |
+| 玩家操作(同浏览器) | `player.intent {action, tile}` |
+| 对话驱动 | `dialogue.start`、`dialogue.choose {index}` |
+| 天气/随机 | `weather.get`/`set`、`rng.get`/`set` |
+| 存档 | `save.snapshot`/`validate`/`restore`/`migrate` |
+| Mod 内省 | `mod.list`、`mod.status`、`mod.reload` |
+
+### 6.4 已知限制(诚实清单)
+
+1. **没有图集/精灵表格式**——`content/assets/` 是单块贴图按 id 引用(`image` 层从源图左上角裁剪,无 sprite-sheet 帧矩形);图集格式是文档化未来路径。
+2. **`sound.src` 只支持 `.ogg`(v1)**——`decodeAudioData` 解码;其他音频格式未支持。`synth` 合成路径不变。
+3. **没有物理引擎**——网格阻挡覆盖碰撞需求(`MovementSystem` + `PlacementSystem`)。
+4. **行为语言 `set-state` 是绝对赋值**,没有增量动作——示例任务的 `+N` 金币用平值授予表达(增量动作延后)。
+5. **`map.*` 没有创作工具**——地图直接手写 YAML(加载器会校验)。
+6. **浏览器 Mod 加载受限**——浏览器无法直接 `import` 任意本地 `.mjs`;Mod 在 Node 侧验证(`apps/mcp-server` / 测试)。
+7. **存档不序列化 TimeSystem 状态**——只序列化世界实体(+ RNG 锚)。
+8. **`save.restore` 不回放 RNG 抽取**——重新锚定种子但 `calls` 重置(已文档化语义)。
+9. 玩家移动不经过 `MovementSystem.moveTo`(直接复用共享地图/放置助手)——`npc-move` 行为与测试不受影响。
+10. 多人游戏**玩法**保留未做(多视口同步机制已建好;多玩家/交易/权限是创作者内容)。
+11. **打包产物不含 Mod**(D30 v1)——示例游戏的市长 NPC 与钓鱼小游戏是 Mod 行为,打包版里没有;Mod 是服务端/创作期功能(浏览器模块导入受限)。
+12. **桌面端 v1 无安装器**(D32)——`nengine-pack --desktop` 生成**可运行的 Electron 壳**(`out/desktop/`,`npm i && npm start`);安装器(electron-builder)、自定义图标、旁路无头核心(Mod + 多人游戏玩法)是文档化的未来路径(D36 已补安装器)。
+13. **打包无 MCP 命名空间**(D30/D32)——`nengine-pack` 是离线构建期工具,无运行时引擎能力;桌面壳只包同样的 bundle 页面,也没有任何 MCP/引擎表面变化。
+14. **资产不是内容种类、没有 MCP 命名空间**(D31)——二进制文件无法用 JSON 工具创作;MCP 创作的 YAML 只能按 id 引用(加载器在渲染期解析;缺资产回退到形状/调色板/静音,绝不崩溃)。
+
+### 6.5 文档与代码差异修正说明
+
+编写本文时逐项核对,发现以下文档与代码的偏差(**以代码为准**,本文按代码描述):
+
+| 位置 | 文档声称 | 代码事实 | 本文处理 |
+|---|---|---|---|
+| `docs/mcp.md` §map | `map.validate/create/update` 生命周期 + `map.tile`/`map.set_tile` 调试 + `map.create-prompt` | `map.*` 只有 `map.list`/`map.get` + `map://{mapId}` 资源,无 prompt(`namespaces/map.ts` 空 `prompts: []`) | 按代码:`map.*` = 2 工具、只读、无创作工具 |
+| `docs/mcp.md` §time | `time.configure-prompt` | `time` 命名空间无 prompt(`namespaces/time.ts` `prompts: []`) | 按代码:`time.*` 无 prompt |
+| `docs/mcp.md` §server | 只列 `server.info`/`enable_subsystem`/`disable_subsystem` | 代码另有 `server.status` 工具(4 工具) | 按代码:`server.*` = 4 工具 |
+| `AGENTS.md` / 本文档任务描述 | 测试数 480 / 内容种类 17 | 实测 **528** 个测试(D30 打包新增 16、D31 资产管线新增 28);`contentSchemas` 只有 **13** 个内容种类(+ 存档快照与二进制资产表为非种类) | 本文统一用 528 / 13(+ 两个非种类) |
+| `docs/api.md` | 顶部注明"signatures are the pre-implementation draft"(多处如 `shop.price` 标 "planned"、map 表含 `create-prompt`) | 部分签名是实施前草案,未随实施更新 | 本文以实际代码/命名空间文件为准 |
+| 仓库根 `content/` | — | 无 `sprites/`/`sounds/` 目录(D26 表现内容只在 sample-game) | 本文注明根内容是早期示例,现代示例看 sample-game |
+
+---
+
+(完)

Dosya farkı çok büyük olduğundan ihmal edildi
+ 657 - 0
docs/mcp.md


+ 353 - 0
examples/sample-game/README.md

@@ -0,0 +1,353 @@
+# Sample Game — Pinefield Valley
+
+A complete, small, playable farm game built **entirely from engine content**
+(YAML) plus **one mod**. It exists to prove the nEngine's data-driven
+contract: no engine code was touched to make this game; every capability is
+exercised through content and the MCP surface.
+
+## What the game is
+
+Spring Day 1. You are a new farmer. The game's arc:
+
+1. **Start** — `Spring Day 1`, 0 gold, empty inventory.
+2. **Starter gift** — on the first `day-start`, the `starter-gift` trigger
+   grants 3 parsnip seeds + 2 carrot seeds (single-shot via a flag).
+3. **Quest** — the `starter-quest` state machine activates on Day 2:
+   *harvest 3 parsnips*.
+4. **Grow** — till, sow, and water plots; `time.tick` drives growth day by
+   day (parsnip = spring crop, 2/2/1 days; carrot = summer crop, 2/3/1 days).
+5. **Harvest & sell** — harvest mature crops, sell them at the general store.
+6. **Buy** — spend gold on tools/seeds.
+7. **Build** — place a 3×3 coop (and fences) on the grass.
+8. **Chicken** — acquire a chicken, force it to laying, feed it daily; the
+   `chicken-egg` state machine turns `animal-produce` events into eggs.
+9. **Talk to the Mayor** — a dialogue with choices, a condition-gated branch
+   (only after the starter quest completes), and give-item actions on choices
+   and nodes.
+10. **Quest chain (D29)** — once the starter quest completes, the
+    `carrot-quest` machine activates on the next day-start (*harvest a
+    carrot*); completing it activates `mayor-talk-quest` (*finish a mayor
+    dialogue*), which pays an egg + gold and unlocks a new "fishing tip"
+    branch in the mayor dialogue.
+11. **Fishing (D29)** — stamina/skills are plain player fields; the fishing
+    **Mod** hooks the player's intent: interact with a pond `water` tile to
+    cast (costs 5 stamina, `skills.fishing` +1, grants a fish), and every
+    third catch pays a free egg via the `fishing-treat` machine. Exhaust your
+    stamina below 5 and the Mod tells you you're too tired.
+
+Along the way, weather rolls every day-start, and the **mod** spawns the
+Mayor NPC (NPCs have no content kind — they are runtime entities), walks him
+according to the `mayor-routine` state machine, reports the weather, and
+emits `mod:sample-day-start`.
+
+## Content inventory
+
+| File | Demonstrates |
+|---|---|
+| `content/maps/farm.yaml` | 16×16 map, variety (pond `water`, pre-tilled bed, open `grass`), spawn point |
+| `content/time.yaml` | 4 seasons × 28 days, 480 ticks/day, Spring Day 1 start |
+| `content/weather.yaml` | 4-season weighted weather table (sunny/rain/wind/storm) |
+| `content/items/hoe.yaml` | tool, non-stackable |
+| `content/items/watering-can.yaml` | tool (decorative use in this slice) |
+| `content/items/parsnip-seed.yaml` / `carrot-seed.yaml` | seeds |
+| `content/items/parsnip.yaml` / `carrot.yaml` / `egg.yaml` / `fish.yaml` | harvest materials + produce + the fishing catch (granted by the Mod, D29) |
+| `content/crops/parsnip.yaml` | spring crop, 3 stages, water-gated, drops parsnip |
+| `content/crops/carrot.yaml` | summer crop, different days (2/3/1) + same water gating |
+| `content/animals/chicken.yaml` | chick→grown→laying, `feedRequired` from grown, produces egg |
+| `content/sprites/*.yaml` | procedural sprite draw instructions (D26) **+ image layers (D31)**: player + chicken stages stay programmatic; parsnip/carrot stages, the coop, and the fence draw Kenney assets from `content/assets/` (`image` layers with `src`), some with programmatic root/leaf accents layered on top |
+| `content/sounds/*.yaml` | sound specs (D26): till/water/harvest/coin now play **audio assets** (`src: drop-1`/`pluck-1`/`confirm-1`/`click-1`, D31) |
+| `content/assets/` | **binary asset table (D31)**: 13 Kenney Tiny Farm tiles (`.png`) + 8 Kenney interface sounds (`.ogg`), CC0 — self-descriptive ids (`grass-1`, `water-1`, `tilled-1`, `plant-sprout`, `plant-grown`, `plant-mature`, `carrot-sprout`, `carrot-grown`, `carrot-mature`, `coop-1`, `coop-2`, `fence-1`, `grass-2`; `click-1`, `confirm-1`, `error-1`, `back-1`, `drop-1`, `pluck-1`, `switch-1`, `bong-1`). NOT a content kind — the loader builds the asset table (`engine.assets`); YAML references assets by id. Keep assets small: the table is base64-inlined in `bundle.json` and every WS snapshot frame |
+| `content/buildings/coop.yaml` | 3×3 footprint, grass-only, blocks movement |
+| `content/buildings/fence.yaml` | 1×1 footprint, grass-only, blocks movement |
+| `content/shops/general-store.yaml` | buys parsnip/carrot/egg; sells tools + seeds |
+| `content/behaviors/starter-gift.yaml` | single-shot trigger (flag re-arm pattern) with give-item + set-state |
+| `content/statemachines/starter-quest.yaml` | quest state machine (day-start activate, crop-harvest complete, reward) |
+| `content/statemachines/carrot-quest.yaml` | quest chain 2 (D29): activates when the starter quest completes, completes on a carrot harvest |
+| `content/statemachines/mayor-talk-quest.yaml` | quest chain 3 (D29): activates when the carrot quest completes, completes on a mayor `dialogue-end`, pays egg + gold |
+| `content/statemachines/chicken-egg.yaml` | production rule: animal-produce event → egg grant |
+| `content/statemachines/mayor-routine.yaml` | NPC daily routine via `onSchedule` day-start entries |
+| `content/statemachines/daily-stamina.yaml` | stamina rules (D29): a condition on `player.stamina` + a day-start restore |
+| `content/statemachines/fishing-treat.yaml` | Mod→content bridge (D29): matches the Mod's `mod:fish-caught` event, every third catch pays an egg |
+| `content/dialogues/mayor.yaml` | choices, condition-gated branches (incl. the D29 quest-gated fishing tip), give-item actions on choice + node |
+| `mods/sample-game-mod/index.mjs` | the Mod escape hatch (D6/D9/D29): spawns + walks the Mayor NPC, reports weather, emits an event, and settles the fishing minigame on `onPlayerIntent` (fish grant, stamina drain, `skills.fishing`, `mod:fish-caught`/`mod:fish-too-tired`) |
+
+## Run the MCP server against this game
+
+Build first (the entry ships from `dist`):
+
+```sh
+pnpm -r build
+```
+
+Then boot the stdio server pointing at this game's content/mods:
+
+```sh
+node apps/mcp-server/dist/index.js \
+  --content-dir examples/sample-game/content \
+  --mods-dir examples/sample-game/mods
+```
+
+Equivalent via environment variables (CLI still wins if both are set):
+
+```sh
+NENGINE_CONTENT_DIR=examples/sample-game/content \
+NENGINE_MODS_DIR=examples/sample-game/mods \
+node apps/mcp-server/dist/index.js
+```
+
+Zero-arg invocation keeps serving the monorepo's own `content/`/`mods/`.
+
+## Drive the game with an MCP client
+
+Connect any MCP client over stdio. Key tool calls for the full loop:
+
+1. `server.info` — confirm the loaded game content (map `farm`, crops
+   `carrot`+`parsnip`, animals `chicken`, buildings `coop`+`fence`, dialogue
+   `mayor`, behaviors `starter-gift`, statemachines
+   `carrot-quest`/`chicken-egg`/`daily-stamina`/`fishing-treat`/`mayor-routine`/`mayor-talk-quest`/`starter-quest`,
+   shop `general-store`).
+2. `time.advance {ticks: 480}` — first day-start: starter gift lands
+   (3 parsnip-seed + 2 carrot-seed), quest activates, weather rolls, mod
+   spawns `npc:mayor`.
+3. `item.query_inventory` — see the gift.
+4. `npc.list` — see `npc:mayor` (spawned by the mod, not by the engine).
+5. `weather.get` — see the rolled weather.
+6. Till/sow/water plots — `crop.till {x, y}` then `crop.sow {x, y,
+   seedItemId: parsnip-seed}` (consumes 1 seed from the player inventory),
+   then `crop.water {x, y}` (sets the plot watered; mirrors `PlantingSystem.water`,
+   safe to call repeatedly — watering an already-watered plot keeps it watered,
+   so a `waterRequired` stage never flips back mid-loop).
+7. `time.tick {ticks: 480}` — grow the crop one day (repeat + re-water).
+8. `crop.harvest {x, y}` once mature (`crop.stage {x, y, stageIndex: 2}` or
+   `time.tick` growth) → quest completes (`behavior.statemachine.status
+   {machineId: starter-quest}`).
+9. `shop.sell {shopId: general-store, itemId: parsnip, qty: 3}` → gold.
+10. `shop.buy {shopId: general-store, itemId: carrot-seed, qty: 1}`.
+11. `placement.place {buildingId: coop, x: 10, y: 4}` (+ `fence`).
+12. `animal.spawn {animalId: chicken, x: 9, y: 3}` — chickens are runtime
+    entities; the shop sells items only.
+13. `animal.stage {id: animal:chicken:1, stageIndex: 2}` + `animal.feed`
+    (feed each day).
+14. `time.tick {ticks: 480}` → `animal-produce` fires → the `chicken-egg`
+    machine grants an egg; see it in `item.query_inventory`.
+15. `dialogue.start {dialogueId: mayor}` → `dialogue.choose {index: 2}`
+    ("Got anything for me?", only present after the quest) → +2 eggs;
+    then `dialogue.choose {index: 0}` to leave.
+16. **Quest chain (D29)** — after the starter quest completes, advance a day
+    (`time.tick {ticks: 480}`) to activate `carrot-quest`; harvest a carrot
+    (grow `carrot-seed` — 2/3/1 days — then `crop.harvest`) → +2 carrot
+    seeds. Advance a day again → `mayor-talk-quest` activates; start + finish
+    the mayor dialogue (`dialogue.choose` to the terminal node) → +1 egg +
+    gold, and a new "Tell me about fishing." branch appears.
+17. **Fishing (D29)** — the pond is the top-left 4×3 `water` block. Use
+    `player.intent {action: interact, tile: {x: 3, y: 2}}` (or the dev app's
+    canvas) to cast: +1 fish (sellable at the general store), −5 stamina,
+    `player.skills.fishing` +1. Every third catch pays a free egg
+    (`fishing-treat`). Fish with `player.stamina < 5` and the Mod emits
+    `mod:fish-too-tired` instead of a catch.
+
+## Run the dev render app against this game
+
+The dev render app (`apps/dev`) is parameterized via URL query — it boots an engine in the browser for its own content by default, or renders a headless core's world over WebSocket. To view THIS game's world, start the headless server with a WS channel and open the app in ws mode:
+
+```sh
+# Terminal 1 — headless core + MCP + WS broadcast on 8787
+pnpm -r build
+node apps/mcp-server/dist/index.js \
+  --content-dir examples/sample-game/content \
+  --mods-dir examples/sample-game/mods \
+  --ws-port 8787
+
+# Terminal 2 — the dev app
+pnpm dev
+```
+
+Then open `http://localhost:5173/apps/dev/?sync=ws&url=ws://localhost:8787`. The browser renders snapshots from the headless core; the HUD shows day/season, seed counts, and gold.
+
+## Package this game (D30)
+
+The engine ships a packaging CLI (`apps/pack`, `nengine-pack`) that compiles a
+game's content (YAML) into a **self-contained static site**: `bundle.json`
+(the content **plus the asset table** — base64-inlined `content/assets/`
+binaries; the single source of truth for packaged output, no YAML files and no
+separate binary files ship) plus `index.html` + `assets/` (the built dev app in
+`?bundle=` mode). The packaged game runs entirely in the browser via
+`createEngine({bundle})` + LocalSync — no dev server, no headless core.
+
+```sh
+# build first (the CLI ships from dist)
+pnpm -r build
+
+# package this game
+pnpm pack:example
+# equivalent:
+node apps/pack/dist/index.js \
+  --content examples/sample-game/content \
+  --out examples/sample-game/dist-pack
+```
+
+Then serve the output folder statically and open it — **no URL params needed**
+(the built index.html defaults to the bundle):
+
+```sh
+npx serve examples/sample-game/dist-pack
+# or: python3 -m http.server 8000 --directory examples/sample-game/dist-pack
+# open http://localhost:3000 (or :8000)
+```
+
+The packaged game keeps the full dev-app playability (Interact/Move toolbar,
+arrow keys, HUD with day/season + inventory + gold, sprite image layers + map
+tile textures + `src` audio sounds). Since **D36** you can package the Mod too
+with `--mods` — the Mayor NPC and the fishing minigame then run in the packaged
+game exactly as in dev (the engine executes the bundled Mod source via a
+data-URL import, browser + desktop):
+
+```sh
+pnpm pack:example:full
+# equivalent:
+node apps/pack/dist/index.js \
+  --content examples/sample-game/content \
+  --mods examples/sample-game/mods \
+  --out examples/sample-game/dist-pack
+```
+
+`pack:example` (no `--mods`) still packages the content-only game, unchanged.
+Desktop (D32) + installers (D36): see below.
+
+**Split-assets packaging (D37)**: by default the 21 binary assets are
+base64-inlined in `bundle.json` (self-contained single file). Pass
+`--split-assets` to write them as separate files instead — the packaged site
+then serves `out/nengine-assets/<id>.png`/`.ogg` (one file per asset) with a
+metadata-only `bundle.json` (the renderer fetches the bytes on demand):
+
+```sh
+pnpm pack:example:split
+# equivalent:
+node apps/pack/dist/index.js \
+  --content examples/sample-game/content \
+  --out examples/sample-game/dist-pack \
+  --split-assets
+```
+
+The game renders identically in both modes (the renderer fetches from the
+sibling `nengine-assets/` dir when entries carry `file` instead of inline base64);
+inline stays the default (zero-regression). The dev-app `?bundle=` mode derives
+the fetch base from the bundle's directory, so pointing it at a split
+`dist-pack/bundle.json` also works.
+
+To try the same bundle in the dev app (over the vite dev server), pass
+`?bundle=` with a repo-relative path:
+
+```
+http://localhost:5173/apps/dev/?bundle=examples/sample-game/dist-pack/bundle.json
+```
+
+## Run this game as a desktop app (D32)
+
+`nengine-pack --desktop` writes a **runnable Electron app** into
+`out/desktop/` next to the static site. It serves the folder over a local
+loopback HTTP server and opens the same bundle-mode page in a 960×640 window.
+
+```sh
+# package with the desktop shell (window title "Pinefield Valley")
+pnpm pack:example:desktop
+# equivalent:
+node apps/pack/dist/index.js \
+  --content examples/sample-game/content \
+  --out examples/sample-game/dist-pack \
+  --desktop --name "Pinefield Valley"
+
+# run it (installs Electron once, ~100 MB)
+cd examples/sample-game/dist-pack/desktop
+npm install
+npm start
+```
+
+The game window is fully playable exactly like the browser package:
+Interact/Move toolbar, arrow-key movement, HUD, textures and sounds. Electron
+was chosen over Tauri because it needs no system packages (npm-only, bundles
+Chromium); Tauri requires `webkit2gtk-4.1` system deps. v1 ships no installer
+(`electron-builder` is not a dependency), uses the default Electron icon, and
+does not bundle a sidecar headless core (server-side Mods + multiplayer stay a
+documented future path) — see `docs/decisions.md` D32. **Desktop + Mods +
+installers (D36)**: pass `--mods` and `--installer` to package the Mod into
+the game and generate installer config:
+
+## Build installers (D36)
+
+`nengine-pack --installer` (implies `--desktop`) writes an `electron-builder.yml`
+into `out/desktop/` (and copies the site files in so the builder can package
+them), plus `dist`/`dist:win` scripts:
+
+```sh
+pnpm pack:example:full
+# equivalent:
+node apps/pack/dist/index.js \
+  --content examples/sample-game/content \
+  --mods examples/sample-game/mods \
+  --out examples/sample-game/dist-pack \
+  --desktop --installer --name "Pinefield Valley"
+
+# build the installer (installs electron-builder once)
+cd examples/sample-game/dist-pack/desktop
+npm install
+npm run dist       # builds dist/*.AppImage (Linux)
+npm run dist:win   # builds a Windows NSIS installer (run on Windows)
+```
+
+The Linux AppImage build is verified end-to-end in this repo
+(`dist/Pinefield Valley-0.1.0.AppImage`); the Windows NSIS target is
+documented config (needs a Windows host). The packaged game includes the Mod
+(`--mods`), so the Mayor and the fishing minigame work in the installed app
+too.
+
+## How to play
+
+The dev app is playable in **both** sync modes (`?sync=ws&url=ws://...` connects to a headless core; the default local mode boots the engine in the browser — use `?contentDir=examples/sample-game/content&modsDir=examples/sample-game/mods` to boot THIS game's content locally).
+
+1. **Choose a tool** from the HUD toolbar: **Interact** (default) or **Move**.
+2. **Use the arrow keys** (↑ ↓ ← →) to walk the player one tile in that direction — works in both sync modes: ws mode sends the raw `{"type":"input","input":"key:Arrow<…>"}` message (the headless core maps it to a player move), local mode maps the same arrow client-side. Holding a key repeats.
+3. **Click a tile on the map** — the canvas click becomes an intent:
+   - **Move** — walk the player to the clicked tile (grass only; water and buildings block).
+   - **Interact** — a smart context action on the clicked plot tile, in priority order:
+     - a **water** tile is **fished** by the sample-game Mod (`onPlayerIntent`): +1 fish, −5 stamina, `skills.fishing` +1 (the engine's default till-on-interact is overridden on the pond; with `stamina < 5` the Mod reports "too tired" instead),
+     - a mature crop is **harvested** (the HUD feedback shows `harvested <crop>`),
+     - a planted, un-watered plot is **watered**,
+     - a tilled, empty plot is **sown** with the **selected seed** — the HUD seed picker (D35) sets it via the `select-seed` intent (`sowed <seed>`; falls back to the first seed in inventory; `no seed in inventory` otherwise),
+     - an un-tilled tile is **tilled** (creates the plot),
+     - anything else reports an error message (e.g. `crop parsnip is not ready to harvest`).
+4. **Read the HUD** — day/season, gold, stamina, every inventory item, the selected seed, and the last intent's feedback message (auto-clears after a few seconds). The **inventory bar** (bottom) lists items with counts.
+5. **Advance time** — in local mode the HUD **Advance day** button drives a full day (480 ticks); in ws mode time is driven by the headless core (via MCP `time.advance`/`time.tick` or auto-tick), so the button is disabled.
+
+### In-game UI panels (D35)
+
+The dev app adds four DOM panels driven through the same intent mechanism:
+
+- **Seed picker** (HUD): one button per owned seed (local mode filters to `seed`-kind items via the registry; ws mode offers every owned item — the snapshot carries no item defs — and the engine validates). Clicking sends `player.intent select-seed {seedItemId}`; the selected button is highlighted and `interact`'s sow prefers that seed while it is owned.
+- **Shop panel**: clicking a building that carries a `shop` field (the sample game's `shop` building def → `shop: general-store`; place it with `placement.place {buildingId:"shop", x, y}`) opens a panel with the shop's buy rows (Shop sells — click **Buy**) and sell rows (Shop buys — click **Sell**, priced from your owned stock). Rows come from the shop def (registry in local mode, the WS snapshot's `shops` catalog in ws mode) and owned counts refresh on each snapshot.
+- **Dialogue panel**: talking to an NPC (the mayor Mod spawn carries `dialogue: "mayor"`) — via `player.intent talk {npcId:"npc:mayor"}` or by clicking the mayor's tile — opens a panel with the current node text and choice buttons. Choosing sends `player.intent choose {index}`; the panel hides when the dialogue ends (the `dialogue:active` entity leaves the world).
+- **Inventory bar** (bottom): all inventory items with counts, gold, stamina, and the current seed selection — updated from every snapshot.
+
+### Presentation (D26/D27/D31)
+
+The world is drawn from the game's **sprite content** (`content/sprites/`): the player, crop stages (parsnip/carrot × sprout/grown/mature), chicken stages, and the coop/fence buildings each render as their sprite def (buildings scaled to their footprint); entities without a sprite ref fall back to the engine's shape drawing. Since **D31**, crop-stage/coop/fence sprites use **image layers** that draw Kenney assets from `content/assets/` as PixiJS textures (the map's `tiles` mapping draws grass/water/tilled from the same assets), while the player and chickens stay programmatic — both paths coexist. Game events play **sound** (`content/sounds/`): the four defs now decode **audio assets** (`src`), e.g. the starter-gift day-start plays the `coin` ping from `click-1.ogg`; audio starts on the first keypress/click (browser autoplay policy).
+
+Game loop reminder: the `starter-gift` trigger fires on the first day-start (3 parsnip-seed + 2 carrot-seed). Till → sow → water the central bed, advance days to grow (parsnips 2/2/1 in spring), harvest, sell at the general store, and follow the quest.
+
+### Agents can play too
+
+The same intent mechanism is exposed as MCP `player.intent` — an agent drives the identical `IntentSystem` a human drives from the canvas, with the same validation and events (D25): `move`/`interact` take `{action, tile}`; since **D35** `buy`/`sell` take `{action, shopId, itemId, qty?}`, `talk` takes `{action, npcId|tile}`, `choose` takes `{action, index}`, and `select-seed` takes `{action, seedItemId?}`.
+
+## Test coverage
+
+`apps/mcp-server/test/sample-game.test.ts` boots this exact game through the
+parameterized entry (`resolveDirs` → `createEngine`), asserts every content
+kind loads, the mod spawned the Mayor, the starter gift fires on the first
+day-start, the full gameplay arc runs (till/sow/water/tick/harvest/
+sell/buy/place/feed/egg + quest + dialogue), the **asset table loads (D31 —
+21 assets: 13 tiles + 8 sounds, referenced by the map `tiles` mapping, sprite
+image layers, and sound `src` ids)** and — since D29 — the Tier 3
+round: the chained quests (carrot-quest + mayor-talk-quest complete with
+rewards), the seeded stamina/skills fields, and the fishing minigame flow
+(water-tile interact → fish + stamina drain + `skills.fishing` + `mod:fish-caught`
+→ fishing-treat egg on the third catch → too-tired gate).

+ 10 - 0
examples/sample-game/content/animals/chicken.yaml

@@ -0,0 +1,10 @@
+# Chicken: chick -> grown -> laying. Feed is required from grown onward;
+# a mature (final-stage) chicken emits animal-produce each fed day. Stages
+# reference procedural sprites (content/sprites/).
+id: chicken
+stages:
+  - { name: chick, days: 2, feedRequired: false, spriteRef: chicken-chick }
+  - { name: grown, days: 2, feedRequired: true, spriteRef: chicken-grown }
+  - { name: laying, days: 1, feedRequired: true, spriteRef: chicken-laying }
+produces:
+  - { itemId: egg, qty: 1 }

+ 11 - 0
examples/sample-game/content/animations/player.yaml

@@ -0,0 +1,11 @@
+# Player animation state machine (D38): states map to sprite defs; the render
+# layer picks the state by the entity's anim field or walk/idle motion inference
+# (an entity whose spriteRef matches this id — "player" — uses this def).
+id: player
+initial: idle
+states:
+  - { id: idle, spriteRef: player, loop: true }
+  - { id: walk, spriteRef: player-walk, fps: 8, loop: true }
+transitions:
+  - { from: idle, to: walk, on: move }
+  - { from: walk, to: idle, on: idle }

BIN
examples/sample-game/content/assets/back-1.ogg


BIN
examples/sample-game/content/assets/bong-1.ogg


BIN
examples/sample-game/content/assets/carrot-grown.png


BIN
examples/sample-game/content/assets/carrot-mature.png


BIN
examples/sample-game/content/assets/carrot-sprout.png


BIN
examples/sample-game/content/assets/click-1.ogg


BIN
examples/sample-game/content/assets/confirm-1.ogg


BIN
examples/sample-game/content/assets/coop-1.png


BIN
examples/sample-game/content/assets/coop-2.png


BIN
examples/sample-game/content/assets/drop-1.ogg


BIN
examples/sample-game/content/assets/error-1.ogg


BIN
examples/sample-game/content/assets/fence-1.png


BIN
examples/sample-game/content/assets/grass-1.png


BIN
examples/sample-game/content/assets/grass-2.png


BIN
examples/sample-game/content/assets/plant-grown.png


BIN
examples/sample-game/content/assets/plant-mature.png


BIN
examples/sample-game/content/assets/plant-sprout.png


BIN
examples/sample-game/content/assets/pluck-1.ogg


BIN
examples/sample-game/content/assets/switch-1.ogg


BIN
examples/sample-game/content/assets/tilled-1.png


BIN
examples/sample-game/content/assets/water-1.png


+ 25 - 0
examples/sample-game/content/behaviors/starter-gift.yaml

@@ -0,0 +1,25 @@
+# Starter gift trigger: on the first day-start of a fresh game the player
+# receives seeds and a flag is set, making the trigger single-shot (the flag
+# guard is the v1 re-arm pattern; the condition stays true while time runs).
+id: starter-gift
+when:
+  and:
+    - { op: gte, path: time.day, value: 1 }
+    - not: { op: exists, path: player.flag.starter-gift }
+fire:
+  - action: give-item
+    itemId: parsnip-seed
+    qty: 3
+  - action: give-item
+    itemId: carrot-seed
+    qty: 2
+  - action: play-sound
+    sound: coin
+  - action: emit-event
+    event: particle
+    payload:
+      particleId: sparkle
+      entityId: player
+  - action: set-state
+    path: player.flag.starter-gift
+    value: given

+ 8 - 0
examples/sample-game/content/buildings/coop.yaml

@@ -0,0 +1,8 @@
+# Building: 3x3 coop, only placeable on grass, blocks NPC movement.
+id: coop
+name: Coop
+footprint: { w: 3, h: 3 }
+buildable: { tiles: [grass] }
+blocksMovement: true
+description: "Shelters a flock of chickens."
+spriteRef: coop

+ 8 - 0
examples/sample-game/content/buildings/fence.yaml

@@ -0,0 +1,8 @@
+# Building: 1x1 fence post, only placeable on grass, blocks NPC movement.
+id: fence
+name: Fence
+footprint: { w: 1, h: 1 }
+buildable: { tiles: [grass] }
+blocksMovement: true
+description: "A wooden fence post."
+spriteRef: fence

+ 12 - 0
examples/sample-game/content/buildings/shop.yaml

@@ -0,0 +1,12 @@
+# Building: 1x1 general-store front, only placeable on grass, walk-over-able
+# (blocksMovement false). The `shop` field (D35) links it to the general-store
+# shop def — the dev UI opens the shop panel when a shop-carrying building is
+# clicked. Placement is runtime (placement.place), like every building.
+id: shop
+name: Shop
+footprint: { w: 1, h: 1 }
+buildable: { tiles: [grass] }
+blocksMovement: false
+description: "The general store: buys harvests, sells seeds and tools."
+spriteRef: shop
+shop: general-store

+ 12 - 0
examples/sample-game/content/crops/carrot.yaml

@@ -0,0 +1,12 @@
+# Summer crop: same 3-stage shape as parsnip but a longer grown stage (3 days
+# vs 2) — a different water/days requirement to contrast with parsnip.
+id: carrot
+seedItem: carrot-seed
+stages:
+  - { name: sprout, days: 2, waterRequired: true, spriteRef: carrot-sprout }
+  - { name: grown, days: 3, waterRequired: true, spriteRef: carrot-grown }
+  - { name: mature, days: 1, waterRequired: false, spriteRef: carrot-mature }
+seasons:
+  - summer
+drops:
+  - { itemId: carrot, qty: 1 }

+ 12 - 0
examples/sample-game/content/crops/parsnip.yaml

@@ -0,0 +1,12 @@
+# Spring crop: 3 stages, water required through the grown stage; drops 1
+# parsnip at harvest. Each stage references a procedural sprite (content/sprites/).
+id: parsnip
+seedItem: parsnip-seed
+stages:
+  - { name: sprout, days: 2, waterRequired: true, spriteRef: parsnip-sprout }
+  - { name: grown, days: 2, waterRequired: true, spriteRef: parsnip-grown }
+  - { name: mature, days: 1, waterRequired: false, spriteRef: parsnip-mature }
+seasons:
+  - spring
+drops:
+  - { itemId: parsnip, qty: 1 }

+ 53 - 0
examples/sample-game/content/dialogues/mayor.yaml

@@ -0,0 +1,53 @@
+# Mayor dialogue: choices, a condition-gated branch, and action nodes.
+# The "Got anything for me?" choice only appears once the starter quest is
+# complete (player.questProgress.starter-quest exists); choosing it enters a
+# node that grants eggs. The "Fishing tip" branch (D29) only appears once the
+# mayor-talk-quest is complete (player.questProgress.mayor-talk-quest exists).
+id: mayor
+startNode: greeting
+nodes:
+  - id: greeting
+    text: "Mayor: Welcome to Pinefield Valley! How can I help?"
+    choices:
+      - text: "Any farming tips?"
+        to: farming-tips
+        actions:
+          - action: give-item
+            itemId: parsnip-seed
+            qty: 1
+      - text: "Tell me about chickens."
+        to: chicken-tips
+      - text: "Got anything for me?"
+        to: quest-reward
+        when:
+          op: exists
+          path: player.questProgress.starter-quest
+      - text: "Tell me about fishing."
+        to: fishing-tip
+        when:
+          op: exists
+          path: player.questProgress.mayor-talk-quest
+      - text: "I am just passing through."
+        to: farewell
+  - id: farming-tips
+    text: "Mayor: Till a plot, sow parsnip seeds, and water them daily. A mature parsnip sells for 30g at the store."
+    to: greeting
+  - id: chicken-tips
+    text: "Mayor: Build a coop on the grass, get a chicken, and feed it every day. A fed chicken lays eggs."
+    to: greeting
+  - id: quest-reward
+    text: "Mayor: You harvested three parsnips! Here is a little something for the road."
+    actions:
+      - action: give-item
+        itemId: egg
+        qty: 2
+    to: farewell
+  - id: fishing-tip
+    text: "Mayor: Fish the pond by interacting with the water. You look well-rested today — have an egg for the journey."
+    actions:
+      - action: give-item
+        itemId: egg
+        qty: 1
+    to: greeting
+  - id: farewell
+    text: "Mayor: Good luck, farmer!"

+ 10 - 0
examples/sample-game/content/interact.yaml

@@ -0,0 +1,10 @@
+# Interact priority order (D33): the engine's four interact actions in
+# content-determined order. Applicability conditions stay engine mechanisms
+# (harvest when mature, water when planted & unwatered, sow when tilled &
+# empty & has seed, till when not tilled) — only the ORDER is content policy.
+id: default
+order:
+  - harvest
+  - water
+  - sow
+  - till

+ 7 - 0
examples/sample-game/content/items/carrot-seed.yaml

@@ -0,0 +1,7 @@
+# Seed: planted in tilled soil to grow a carrot (summer crop, slower).
+id: carrot-seed
+name: Carrot Seed
+kind: seed
+stackable: true
+price: 25
+use: plant in tilled soil to grow a carrot

+ 6 - 0
examples/sample-game/content/items/carrot.yaml

@@ -0,0 +1,6 @@
+# Harvest material: the carrot crop's drop, sold at the store.
+id: carrot
+name: Carrot
+kind: material
+stackable: true
+price: 40

+ 8 - 0
examples/sample-game/content/items/egg.yaml

@@ -0,0 +1,8 @@
+# Material: the chicken's produce; granted by the chicken-egg state machine
+# (the produce table is data on the animal def; the grant rule is behavior).
+id: egg
+name: Egg
+kind: material
+stackable: true
+price: 15
+use: a chicken's daily produce

+ 9 - 0
examples/sample-game/content/items/fish.yaml

@@ -0,0 +1,9 @@
+# Material: the fishing minigame's catch, granted by the sample-game Mod
+# (the fishing minigame body lives in the Mod, D19/D29 — engine emits the
+# intent event, the Mod settles the catch, content prices it).
+id: fish
+name: Fish
+kind: material
+stackable: true
+price: 25
+use: caught by fishing in the pond

+ 7 - 0
examples/sample-game/content/items/hoe.yaml

@@ -0,0 +1,7 @@
+# Tool: tills a soil tile so a seed can be sown.
+id: hoe
+name: Hoe
+kind: tool
+stackable: false
+price: 500
+use: tills a soil tile for planting

+ 7 - 0
examples/sample-game/content/items/parsnip-seed.yaml

@@ -0,0 +1,7 @@
+# Seed: planted in tilled soil to grow a parsnip (spring crop).
+id: parsnip-seed
+name: Parsnip Seed
+kind: seed
+stackable: true
+price: 20
+use: plant in tilled soil to grow a parsnip

+ 6 - 0
examples/sample-game/content/items/parsnip.yaml

@@ -0,0 +1,6 @@
+# Harvest material: the parsnip crop's drop, sold at the store.
+id: parsnip
+name: Parsnip
+kind: material
+stackable: true
+price: 35

+ 8 - 0
examples/sample-game/content/items/watering-can.yaml

@@ -0,0 +1,8 @@
+# Tool: waters a tilled plot (decorative in this slice — watering is driven by
+# crop.water / the planting system).
+id: watering-can
+name: Watering Can
+kind: tool
+stackable: false
+price: 300
+use: waters a tilled plot

+ 36 - 0
examples/sample-game/content/maps/farm.yaml

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

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor