فهرست منبع

Crafting system + festival templates + BGM + lifecycle + sample-game expansion + doc sync

New features:
- craft.* namespace (23rd MCP namespace, 6 tools: validate/create/update/list/get/do_craft)
- recipe content kind (content/recipes/<id>.yaml, schema: {id, output, inputs, station?})
- Crafting system (packages/core/src/systems/crafting.ts) with craft-success event
- BGM audio system (loop/fade, synth chord progression via setInterval)
- Festival state machines (spring/summer/autumn/winter) with seasonal BGM switching
- Engine lifecycle: WorldEventBus.clear() + SystemsHost.destroy() called in engine.stop()
- ContentLoader polling fallback (250ms) for unreliable fs.watch environments
- packages/ui (HUD/dialogue/inventory/shop panels)

Sample-game content (13 recipes, 30 items, 4 seasonal crops, cow/barn, Mia NPC):
- 10 new recipes (5 starter + 5 festival) + 10 output items
- pumpkin (autumn) + turnip (winter) crops completing seasonal planting
- cow animal + milk + barn building
- Mia NPC + restoration-quest endgame + victory dialogue
- 5 BGM sounds (4 seasonal + victory)
- Farm map expanded to 20x16

Docs:
- docs/mcp.md: added craft.* namespace section (was missing - violated hard constraint)
- docs/api.md: added recipe to Schema Map, added craft.* to createEngineNamespaces list
- Corrected recipe schema doc (output not outputs, station not category)
- README + decisions + editor docs updated

Fixes:
- TS strict mode: fadeMs type, inventory type guards, MCP CallToolResult union narrowing
- Memory leak: event listeners + system resources now cleaned on engine.stop()
- Windows path compatibility in tests (sample-game, pack-options)
- .gitignore: added sandbox env caches + dist-desktop/ + *.blockmap
nh99sysrj6-blip 2 ماه پیش
والد
کامیت
7dbfe75fb3
100فایلهای تغییر یافته به همراه1822 افزوده شده و 162 حذف شده
  1. 28 5
      .gitignore
  2. 2 0
      .npmrc
  3. 0 0
      AGENTS.md
  4. 12 1
      README.md
  5. 2 1
      apps/dev/package.json
  6. 141 55
      apps/dev/src/main.ts
  7. 4 4
      apps/dev/src/ui.test.ts
  8. 4 3
      apps/editor/src/pack-options.ts
  9. 1 1
      apps/editor/test/electron-builder-config.test.ts
  10. 3 3
      apps/editor/test/form-schema.test.ts
  11. 9 8
      apps/editor/test/mcp-server.test.ts
  12. 3 3
      apps/editor/test/mcp-stdio.test.ts
  13. 11 11
      apps/editor/test/pack-options.test.ts
  14. 19 5
      apps/editor/test/project.test.ts
  15. 2 1
      apps/editor/vite.config.ts
  16. 16 6
      apps/mcp-server/test/client-surface.test.ts
  17. 135 14
      apps/mcp-server/test/sample-game.test.ts
  18. 1 1
      apps/mcp-server/test/ws-broadcast.test.ts
  19. 1 1
      apps/pack/test/pack.test.ts
  20. 4 2
      docs/api.md
  21. 2 2
      docs/decisions.md
  22. 3 3
      docs/editor.md
  23. 15 0
      docs/mcp.md
  24. 10 0
      examples/sample-game/content/animals/cow.yaml
  25. 9 0
      examples/sample-game/content/buildings/barn.yaml
  26. 14 0
      examples/sample-game/content/crops/pumpkin.yaml
  27. 12 0
      examples/sample-game/content/crops/turnip.yaml
  28. 54 0
      examples/sample-game/content/dialogues/mayor.yaml
  29. 71 0
      examples/sample-game/content/dialogues/mia.yaml
  30. 10 0
      examples/sample-game/content/items/baked-egg.yaml
  31. 10 0
      examples/sample-game/content/items/cake.yaml
  32. 3 0
      examples/sample-game/content/items/carrot-seed.yaml
  33. 10 0
      examples/sample-game/content/items/carrot-soup.yaml
  34. 6 0
      examples/sample-game/content/items/carrot.yaml
  35. 10 0
      examples/sample-game/content/items/cherry-jam.yaml
  36. 6 0
      examples/sample-game/content/items/egg.yaml
  37. 10 0
      examples/sample-game/content/items/festival-cherry-petal.yaml
  38. 11 0
      examples/sample-game/content/items/festival-firework-ticket.yaml
  39. 11 0
      examples/sample-game/content/items/festival-harvest-charm.yaml
  40. 10 0
      examples/sample-game/content/items/festival-snow-crystal.yaml
  41. 10 0
      examples/sample-game/content/items/fish-stew.yaml
  42. 6 0
      examples/sample-game/content/items/fish.yaml
  43. 10 0
      examples/sample-game/content/items/grand-feast.yaml
  44. 10 0
      examples/sample-game/content/items/harvest-pie.yaml
  45. 5 0
      examples/sample-game/content/items/hoe.yaml
  46. 14 0
      examples/sample-game/content/items/milk.yaml
  47. 10 0
      examples/sample-game/content/items/oat-milk.yaml
  48. 3 0
      examples/sample-game/content/items/parsnip-seed.yaml
  49. 10 0
      examples/sample-game/content/items/parsnip-soup.yaml
  50. 6 0
      examples/sample-game/content/items/parsnip.yaml
  51. 11 0
      examples/sample-game/content/items/pumpkin-pie.yaml
  52. 10 0
      examples/sample-game/content/items/pumpkin-seed.yaml
  53. 12 0
      examples/sample-game/content/items/pumpkin.yaml
  54. 10 0
      examples/sample-game/content/items/snow-cone.yaml
  55. 10 0
      examples/sample-game/content/items/summer-pudding.yaml
  56. 10 0
      examples/sample-game/content/items/turnip-seed.yaml
  57. 10 0
      examples/sample-game/content/items/turnip-soup.yaml
  58. 12 0
      examples/sample-game/content/items/turnip.yaml
  59. 5 0
      examples/sample-game/content/items/watering-can.yaml
  60. 328 28
      examples/sample-game/content/maps/farm.yaml
  61. 6 0
      examples/sample-game/content/recipes/baked-egg.yaml
  62. 8 0
      examples/sample-game/content/recipes/cake.yaml
  63. 7 0
      examples/sample-game/content/recipes/carrot-soup.yaml
  64. 6 0
      examples/sample-game/content/recipes/cherry-jam.yaml
  65. 8 0
      examples/sample-game/content/recipes/fish-stew.yaml
  66. 9 0
      examples/sample-game/content/recipes/grand-feast.yaml
  67. 7 0
      examples/sample-game/content/recipes/harvest-pie.yaml
  68. 7 0
      examples/sample-game/content/recipes/oat-milk.yaml
  69. 7 0
      examples/sample-game/content/recipes/parsnip-soup.yaml
  70. 8 0
      examples/sample-game/content/recipes/pumpkin-pie.yaml
  71. 6 0
      examples/sample-game/content/recipes/snow-cone.yaml
  72. 7 0
      examples/sample-game/content/recipes/summer-pudding.yaml
  73. 7 0
      examples/sample-game/content/recipes/turnip-soup.yaml
  74. 13 0
      examples/sample-game/content/shops/general-store.yaml
  75. 10 0
      examples/sample-game/content/sounds/bgm-autumn.yaml
  76. 10 0
      examples/sample-game/content/sounds/bgm-spring.yaml
  77. 10 0
      examples/sample-game/content/sounds/bgm-summer.yaml
  78. 10 0
      examples/sample-game/content/sounds/bgm-victory.yaml
  79. 10 0
      examples/sample-game/content/sounds/bgm-winter.yaml
  80. 12 0
      examples/sample-game/content/sprites/barn.yaml
  81. 8 0
      examples/sample-game/content/sprites/cow-calf.yaml
  82. 10 0
      examples/sample-game/content/sprites/cow-grown.yaml
  83. 12 0
      examples/sample-game/content/sprites/cow-milking.yaml
  84. 7 0
      examples/sample-game/content/sprites/pumpkin-grown.yaml
  85. 11 0
      examples/sample-game/content/sprites/pumpkin-mature.yaml
  86. 7 0
      examples/sample-game/content/sprites/pumpkin-sprout.yaml
  87. 7 0
      examples/sample-game/content/sprites/turnip-grown.yaml
  88. 11 0
      examples/sample-game/content/sprites/turnip-mature.yaml
  89. 7 0
      examples/sample-game/content/sprites/turnip-sprout.yaml
  90. 30 0
      examples/sample-game/content/statemachines/cow-milk.yaml
  91. 41 0
      examples/sample-game/content/statemachines/festival-autumn.yaml
  92. 41 0
      examples/sample-game/content/statemachines/festival-spring.yaml
  93. 41 0
      examples/sample-game/content/statemachines/festival-summer.yaml
  94. 41 0
      examples/sample-game/content/statemachines/festival-winter.yaml
  95. 45 0
      examples/sample-game/content/statemachines/restoration-quest.yaml
  96. 25 0
      examples/sample-game/mods/sample-game-mod/index.mjs
  97. 107 2
      packages/core/src/content/loader.ts
  98. 6 0
      packages/core/src/engine.ts
  99. 5 1
      packages/core/src/index.ts
  100. 5 1
      packages/core/src/schema/behavior.ts

+ 28 - 5
.gitignore

@@ -1,10 +1,33 @@
+# Dependencies
 node_modules/
+
+# Build outputs
 dist/
-coverage/
+dist-pack/
+dist-desktop/
+apps/editor/dist-app/
 *.tsbuildinfo
-.DS_Store
+
+# Test / coverage
+coverage/
+
+# Bundler / dev server
 .vite/
 *.local
-dist-pack/
-dist-pack/
-apps/editor/dist-app/
+
+# Sandboxed environment caches (pnpm/corepack/AppData redirected into repo)
+.corepack/
+.corepack-home/
+.pnpm-home/
+.pnpm-cache/
+.pnpm-store/
+.pnpm/
+.appdata/
+
+# Electron-builder blockmap artifacts
+*.blockmap
+
+# OS / IDE
+.DS_Store
+Thumbs.db
+*.swp

+ 2 - 0
.npmrc

@@ -0,0 +1,2 @@
+cache-dir=./.pnpm-cache
+store-dir=./.pnpm-store

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
AGENTS.md


+ 12 - 1
README.md

@@ -24,10 +24,21 @@
 |---|---|
 | `docs/decisions.md` | 全部架构决策(D1–D39),**WHY 的事实来源** |
 | `docs/architecture.md` | 顶层架构、包边界、目录布局 |
-| `docs/mcp.md` | **MCP-first 接口规格**(22 命名空间 / 124 工具 / 36 资源 / 17 prompt) |
+| `docs/mcp.md` | **MCP-first 接口规格**(23 命名空间 / 132 工具 / 36 资源 / 17 prompt) |
 | `docs/api.md` | 引擎 API 参考(活的 API 文档) |
 | `docs/boundaries.md` | 引擎/游戏边界(三判据) |
 | `docs/engine-guide-zh.md` | 中文引擎指南:安装、游玩、制作游戏、打包发布 |
 | `docs/editor.md` | **编辑器架构(D40)**:编辑器设计、IPC、E1–E7/F1–F4 进度、Distribution (F4) |
 | `AGENTS.md` | 项目规则 + 当前状态摘要 |
 | `examples/sample-game/README.md` | 示例游戏 Pinefield Valley 的完整游玩/驱动/打包说明 |
+
+## 新功能亮点(v0.1 Latest)
+
+- **物品合成系统 (Crafting)**:通过 `recipes/` 定义合成配方(物品 + 数量 → 产出),玩家在游戏中或通过 MCP `craft.do_craft` 执行合成;新增命名空间 `craft.*`(authoring 工具 + list/get/do_craft/validate)。
+- **节日模板 (Festival Templates)**:春夏秋冬四季节日状态机(`festival-spring/summer/autumn/winter`),自动切换 BGM、发放节日奖励物品(樱花瓣/烟花券/丰收符咒/雪晶),示例游戏已启用。
+- **BGM 音频系统**:`play-bgm` / `stop-bgm` 行为动作,支持循环 + 淡入淡出;`sound` 定义支持 `mode: "bgm"`;合成器 BGM 内建简单和弦循环,示例游戏有 5 首季节/胜利 BGM。
+- **丰富物品 Schema**:新增 `rarity`(common/uncommon/rare/epic/legendary)、`durability`(工具耐久)、`description`、`effects`(食用加体力/金币)、`spriteRef`(图标);物品类型扩展 7 种(tool/seed/material/consumable/decoration/good/crop)。
+- **库存工具调用**:MCP 新增 `item.use`(消耗品)、`item.damage`(耐久损耗)。
+- **打包跨平台兼容**:编辑器路径处理(content/outDir/modsDir)全面改为 `path.join/resolve`,在 Windows 正确构建 bundle.json 与 desktop shell。
+- **内容热加载稳定性**:`ContentLoader.watch` 新增轮询兜底(250ms),fs.watch 信号不可靠的容器/沙箱环境照常热加载。
+- **生命周期完备**:`engine.stop()` 清理 auto-tick 定时器、所有 event hooks、调用 `System.destroy()`(如有),`WorldEventBus.clear()` 释放事件处理器引用,无残留事件泄漏。

+ 2 - 1
apps/dev/package.json

@@ -11,7 +11,8 @@
   },
   "dependencies": {
     "@nengine/core": "workspace:*",
-    "@nengine/render": "workspace:*"
+    "@nengine/render": "workspace:*",
+    "@nengine/ui": "workspace:*"
   },
   "devDependencies": {
     "typescript": "^5.6.0",

+ 141 - 55
apps/dev/src/main.ts

@@ -3,6 +3,16 @@ import type { ContentBundle, DialogueState, Engine, ItemDef, MapDef, ShopDef, Ti
 import { connectLocal, connectWs, createSoundPlayer, createViewport, type ViewportHandle } from "@nengine/render";
 import type { SoundPlayer } from "@nengine/render";
 import type { WsEventFrame, WsSnapshotFrame } from "@nengine/render";
+import {
+  DialogueBox,
+  HudBar,
+  InventoryPanel,
+  ShopPanel,
+  type DialogueState as NDialogueState,
+  type HudBarState,
+  type InventoryPanelState,
+  type ShopPanelState,
+} from "@nengine/ui";
 import { parseDevParams } from "./params.js";
 import { keydownInput } from "./keys.js";
 import {
@@ -30,11 +40,56 @@ const inventorySeed = document.getElementById("inventory-seed") as HTMLSpanEleme
 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 shopPanelEl = 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;
+const inventoryBar = document.getElementById("inventory-bar") as HTMLElement;
+const hud = document.getElementById("hud") as HTMLElement;
+
+const dialogueBox = new DialogueBox();
+dialoguePanel.innerHTML = "";
+dialogueBox.mount(dialoguePanel);
+
+const shopPanel = new ShopPanel();
+shopPanelEl.innerHTML = "";
+shopPanel.mount(shopPanelEl);
+
+const hudUiDynamic = document.createElement("div");
+hudUiDynamic.id = "hud-ui-dynamic";
+inventoryBar.appendChild(hudUiDynamic);
+const hudBar = new HudBar();
+hudBar.mount(hudUiDynamic);
+
+const inventoryOverlay = document.createElement("div");
+inventoryOverlay.id = "inventory-overlay";
+inventoryOverlay.style.cssText = "position:absolute;top:80px;left:50%;transform:translateX(-50%);z-index:20;display:none;";
+game.appendChild(inventoryOverlay);
+const inventoryPanel = new InventoryPanel();
+inventoryPanel.mount(inventoryOverlay);
+
+const openInventoryBtn = document.createElement("button");
+openInventoryBtn.type = "button";
+openInventoryBtn.textContent = "Open Inventory";
+openInventoryBtn.style.cssText = "font:inherit;padding:6px 14px;border:1px solid #555;border-radius:4px;background:#333;color:#eee;cursor:pointer;";
+openInventoryBtn.addEventListener("click", () => {
+  const visible = inventoryOverlay.style.display !== "none";
+  inventoryOverlay.style.display = visible ? "none" : "";
+  if (!visible) {
+    renderInventoryPanel();
+  }
+});
+hud.appendChild(openInventoryBtn);
+
+const closeInventoryBtn = document.createElement("button");
+closeInventoryBtn.type = "button";
+closeInventoryBtn.textContent = "Close Inventory";
+closeInventoryBtn.style.cssText = "margin-bottom:8px;font:inherit;padding:4px 10px;border:1px solid #555;border-radius:4px;background:#37474f;color:#eceff1;cursor:pointer;";
+closeInventoryBtn.addEventListener("click", () => {
+  inventoryOverlay.style.display = "none";
+});
+inventoryOverlay.insertBefore(closeInventoryBtn, inventoryOverlay.firstChild);
 
 type Tool = "interact" | "move";
 
@@ -54,6 +109,36 @@ let sync: SyncLike = { intent: () => {} };
 let view: SnapshotView = { world: [], shops: [] };
 let activeShop: { shopId: string } | undefined;
 let dialogueState: DialogueState | undefined;
+let invSystem: { use(itemId: string): void } | undefined;
+
+dialogueBox.on('choose', (idx: number) => {
+  sync.intent('choose', { choice: idx });
+});
+
+shopPanel.on('buy', (itemId: string, qty: number) => {
+  if (activeShop) {
+    sync.intent('buy', { shopId: activeShop.shopId, itemId, qty });
+  }
+});
+
+shopPanel.on('sell', (itemId: string, qty: number) => {
+  if (activeShop) {
+    sync.intent('sell', { shopId: activeShop.shopId, itemId, qty });
+  }
+});
+
+shopPanel.on('close', () => {
+  shopPanelEl.classList.add('hidden');
+  activeShop = undefined;
+});
+
+inventoryPanel.on('use', (itemId: string) => {
+  if (invSystem && typeof invSystem.use === 'function') {
+    invSystem.use(itemId);
+  } else {
+    showFeedback('Use via MCP only', false);
+  }
+});
 
 function toRepoPath(rel: string): string {
   return new URL(rel.startsWith("/") ? rel : `../../../${rel}`, import.meta.url).pathname;
@@ -113,6 +198,10 @@ function renderUi(): void {
   renderInventoryBar(stats);
   renderDialoguePanel();
   renderShopPanel();
+  renderHudBar(stats);
+  if (inventoryOverlay.style.display !== "none") {
+    renderInventoryPanel();
+  }
 }
 
 function renderSeedPicker(stats: PlayerStats): void {
@@ -146,71 +235,69 @@ function renderDialoguePanel(): void {
   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);
-  });
+  const choices = node.choices.map((text) => ({ text }));
+  dialogueBox.update({
+    text: node.text.length > 0 ? node.text : "…",
+    choices,
+    typingSpeedMs: 20,
+  } as NDialogueState);
 }
 
 function renderShopPanel(): void {
   if (!activeShop) return;
   const shop = view.shops.find((candidate) => candidate.id === activeShop?.shopId);
+  const stats = playerStatsFromSnapshot(view.world);
   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 });
+    shopPanel.update({
+      shopId: activeShop.shopId,
+      shopName: `Shop: ${activeShop.shopId}`,
+      sells: [],
+      buys: [],
+      playerGold: stats.gold,
     });
-    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);
+    return;
   }
+  const rows = shopRowsFromDef(shop, stats.inventory, view.items);
+  shopPanel.update({
+    shopId: shop.id,
+    shopName: `Shop: ${shop.id}`,
+    sells: rows.sellRows,
+    buys: rows.buyRows,
+    playerGold: stats.gold,
+  });
+}
+
+function renderHudBar(stats: PlayerStats): void {
+  hudBar.update({
+    day: 1,
+    season: "spring",
+    gold: stats.gold,
+    stamina: { cur: stats.stamina, max: 100 },
+    selectedSeed: stats.selectedSeed,
+  } as HudBarState);
+}
+
+function renderInventoryPanel(): void {
+  const stats = playerStatsFromSnapshot(view.world);
+  const items = Object.entries(stats.inventory).map(([id, qty]) => {
+    const def = view.items?.find((item) => item.id === id);
+    return {
+      itemId: id,
+      name: def?.name ?? id,
+      qty,
+      category: (def?.kind as "seed" | "crop" | "tool" | "good") ?? "good",
+      consumable: def?.kind === "crop" || def?.kind === "seed",
+    };
+  });
+  inventoryPanel.update({
+    items,
+    activeCategory: "all",
+  } as InventoryPanelState);
 }
 
 function setActiveShop(shop: { shopId: string } | undefined): void {
   activeShop = shop;
-  shopPanel.classList.toggle("hidden", shop === undefined);
+  shopPanelEl.classList.toggle("hidden", shop === undefined);
   if (shop) renderShopPanel();
 }
 
@@ -235,7 +322,6 @@ function bindKeyboard(syncHandle: { input(input: string): void }, sound: { prime
 async function boot(): Promise<void> {
   interactButton.addEventListener("click", () => setTool("interact"));
   moveButton.addEventListener("click", () => setTool("move"));
-  shopClose.addEventListener("click", () => setActiveShop(undefined));
   setTool("interact");
 
   try {

+ 4 - 4
apps/dev/src/ui.test.ts

@@ -19,10 +19,10 @@ const SHOP: ShopDef = {
 };
 
 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 },
+  { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true, rarity: "common" },
+  { id: "carrot-seed", name: "Carrot Seed", kind: "seed", stackable: true, rarity: "common" },
+  { id: "hoe", name: "Hoe", kind: "tool", stackable: false, rarity: "common" },
+  { id: "parsnip", name: "Parsnip", kind: "material", stackable: true, rarity: "common" },
 ];
 
 describe("apps/dev UI pure helpers", () => {

+ 4 - 3
apps/editor/src/pack-options.ts

@@ -1,10 +1,11 @@
+import { join } from "node:path";
 import type { EditorPackOptions, EditorPackStats } from "./ipc.js";
 import type { EditorProjectYaml } from "./project-yaml.js";
 
 export const DEFAULT_PACK_SUBDIR = "dist-pack";
 
 export function defaultPackOutDir(root: string): string {
-  return `${root}/${DEFAULT_PACK_SUBDIR}`;
+  return join(root, DEFAULT_PACK_SUBDIR);
 }
 
 export function dialogDefaultsFromProject(
@@ -58,7 +59,7 @@ export function resolvePackRequest(
   hasMods: boolean,
 ): EditorPackRequest {
   const request: EditorPackRequest = {
-    contentDir: `${root}/content`,
+    contentDir: join(root, "content"),
     outDir: options.outDir.trim() !== "" ? options.outDir : defaultPackOutDir(root),
     desktop: options.installer || options.desktop,
     installer: options.installer,
@@ -66,7 +67,7 @@ export function resolvePackRequest(
   };
   const name = options.name.trim();
   if (name !== "") request.name = name;
-  if (options.mods && hasMods) request.modsDir = `${root}/mods`;
+  if (options.mods && hasMods) request.modsDir = join(root, "mods");
   return request;
 }
 

+ 1 - 1
apps/editor/test/electron-builder-config.test.ts

@@ -57,7 +57,7 @@ function globMatches(pattern: string, relPath: string): boolean {
 function walkFiles(dir: string, base: string, out: string[]): string[] {
   for (const entry of readdirSync(dir)) {
     const abs = join(dir, entry);
-    const rel = relative(base, abs);
+    const rel = relative(base, abs).split("\\").join("/");
     if (statSync(abs).isDirectory()) walkFiles(abs, base, out);
     else out.push(rel);
   }

+ 3 - 3
apps/editor/test/form-schema.test.ts

@@ -75,11 +75,11 @@ describe("deriveFormSchema primitives", () => {
   it("derives an item schema", () => {
     const form = deriveFormSchema(itemSchema);
     const names = form.fields.map((f) => f.name);
-    expect(names).toEqual(["id", "name", "kind", "stackable", "price", "use"]);
+    expect(names).toEqual(["id", "name", "kind", "stackable", "price", "use", "description", "rarity", "durability", "effects", "spriteRef"]);
     expect(field(itemSchema, "id").type).toBe("string");
     expect(field(itemSchema, "name").type).toBe("string");
     expect(field(itemSchema, "kind").type).toBe("enum");
-    expect(field(itemSchema, "kind").enumValues).toEqual(["tool", "seed", "material", "consumable", "decoration"]);
+    expect(field(itemSchema, "kind").enumValues).toEqual(["tool", "seed", "material", "consumable", "decoration", "good", "crop"]);
     expect(field(itemSchema, "stackable").type).toBe("boolean");
     expect(field(itemSchema, "price").type).toBe("number");
     expect(field(itemSchema, "price").optional).toBe(true);
@@ -196,7 +196,7 @@ describe("deriveFormSchema unions", () => {
     expect(action?.type).toBe("union");
     expect(action?.discriminator).toBe("action");
     const branches = action?.branches ?? [];
-    expect(branches.map((b) => b.id)).toEqual(["set-state", "add", "emit-event", "give-item", "play-sound", "dialogue"]);
+    expect(branches.map((b) => b.id)).toEqual(["set-state", "add", "emit-event", "give-item", "play-sound", "dialogue", "play-bgm", "stop-bgm"]);
     const giveItem = branches.find((b) => b.id === "give-item");
     expect(fieldIn(giveItem?.fields ?? [], "itemId").refKind).toBe("item");
     const playSound = branches.find((b) => b.id === "play-sound");

+ 9 - 8
apps/editor/test/mcp-server.test.ts

@@ -70,8 +70,8 @@ async function setup(): Promise<Harness> {
   };
 }
 
-function textOf(result: { content: { type: string; text?: string }[] }): string {
-  return result.content.find((block) => block.type === "text")?.text ?? "";
+function textOf(result: { content?: { type: string; text?: string }[] }): string {
+  return result.content?.find((block) => block.type === "text")?.text ?? "";
 }
 
 function jsonOf(text: string): Record<string, unknown> {
@@ -101,16 +101,16 @@ describe("editor embedded MCP server (streamable HTTP over the editing engine)",
     }
   });
 
-  it("listTools over real HTTP returns the full 124-tool surface", async () => {
+  it("listTools over real HTTP returns the full 132-tool surface", async () => {
     const { client, close } = await setup();
     try {
       const tools = await client.listTools();
-      expect(tools.tools).toHaveLength(124);
+      expect(tools.tools).toHaveLength(132);
       for (const tool of tools.tools) {
         expect(tool.inputSchema).toMatchObject({ type: "object" });
       }
       const names = tools.tools.map((tool) => tool.name);
-      for (const expected of ["item.create", "map.get", "time.advance", "server.info", "scene.validate", "npc.path"]) {
+      for (const expected of ["item.create", "item.use", "map.get", "time.advance", "server.info", "scene.validate", "npc.path", "craft.do_craft"]) {
         expect(names).toContain(expected);
       }
     } finally {
@@ -259,13 +259,14 @@ describe("editor embedded MCP server (streamable HTTP over the editing engine)",
   });
 
   it("resolveEditorMcpDirs: --dir maps content/<dir>/content and mods/<dir>/mods; no --dir errors", () => {
+    const norm = (p: string) => p.split("\\").join("/").replace(/^([A-Z]):/i, "");
     const viaDir = resolveEditorMcpDirs({ argv: ["--dir", "/proj"], cwd: "/x" });
-    expect(viaDir.contentDir).toBe("/proj/content");
+    expect(norm(viaDir.contentDir)).toBe("/proj/content");
     expect(viaDir.modsDir).toBeUndefined();
 
     const viaContent = resolveEditorMcpDirs({ argv: ["--content-dir", "content", "--mods-dir", "mods"], cwd: "/proj" });
-    expect(viaContent.contentDir).toBe("/proj/content");
-    expect(viaContent.modsDir).toBe("/proj/mods");
+    expect(norm(viaContent.contentDir)).toBe("/proj/content");
+    expect(norm(viaContent.modsDir ?? "")).toBe("/proj/mods");
 
     expect(() => resolveEditorMcpDirs({ argv: [], cwd: "/x" })).toThrow(/--mcp-stdio requires --dir/);
   });

+ 3 - 3
apps/editor/test/mcp-stdio.test.ts

@@ -10,8 +10,8 @@ import { newProject } from "../src/template.js";
 
 const entry = resolve(process.cwd(), "dist/main/mcp-stdio.js");
 
-function textOf(result: { content: { type: string; text?: string }[] }): string {
-  return result.content.find((block) => block.type === "text")?.text ?? "";
+function textOf(result: { content?: { type: string; text?: string }[] }): string {
+  return result.content?.find((block) => block.type === "text")?.text ?? "";
 }
 
 describe("editor --mcp-stdio headless mode (spawned entry over stdio)", () => {
@@ -26,7 +26,7 @@ describe("editor --mcp-stdio headless mode (spawned entry over stdio)", () => {
       try {
         expect(client.getServerVersion()).toMatchObject({ name: MCP_SERVER_NAME, version: "0.1.0" });
         const tools = await client.listTools();
-        expect(tools.tools).toHaveLength(124);
+        expect(tools.tools).toHaveLength(132);
         const info = JSON.parse(textOf(await client.callTool({ name: "server.info", arguments: {} }))) as {
           engine: string;
           content: { map: string[]; time: string[]; item: string[] };

+ 11 - 11
apps/editor/test/pack-options.test.ts

@@ -7,6 +7,8 @@ import {
   resolvePackRequest,
 } from "../src/pack-options.js";
 
+const norm = (p: string) => p.split("\\").join("/").replace(/^([A-Z]):/i, "");
+
 describe("dialogDefaultsFromProject", () => {
   it("pre-fills the dialog from project.yaml pack fields", () => {
     const options = dialogDefaultsFromProject(
@@ -20,7 +22,7 @@ describe("dialogDefaultsFromProject", () => {
     expect(options.installer).toBe(true);
     expect(options.mods).toBe(true);
     expect(options.splitAssets).toBe(true);
-    expect(options.outDir).toBe(`/proj/${DEFAULT_PACK_SUBDIR}`);
+    expect(norm(options.outDir)).toBe(`/proj/${DEFAULT_PACK_SUBDIR}`);
   });
 
   it("falls back to the top-level name then the project display name with defaults", () => {
@@ -75,15 +77,13 @@ describe("resolvePackRequest", () => {
       { name: "Pinefield Valley", icon: "", desktop: false, installer: true, mods: true, splitAssets: false, outDir: "" },
       true,
     );
-    expect(request).toEqual({
-      contentDir: "/proj/content",
-      outDir: `/proj/${DEFAULT_PACK_SUBDIR}`,
-      desktop: true,
-      installer: true,
-      splitAssets: false,
-      modsDir: "/proj/mods",
-      name: "Pinefield Valley",
-    });
+    expect(norm(request.contentDir)).toBe("/proj/content");
+    expect(norm(request.outDir)).toBe(`/proj/${DEFAULT_PACK_SUBDIR}`);
+    expect(request.desktop).toBe(true);
+    expect(request.installer).toBe(true);
+    expect(request.splitAssets).toBe(false);
+    expect(norm(request.modsDir ?? "")).toBe("/proj/mods");
+    expect(request.name).toBe("Pinefield Valley");
   });
 
   it("honors a custom outDir, plain desktop, and omits mods when off or the dir is absent", () => {
@@ -92,7 +92,7 @@ describe("resolvePackRequest", () => {
       { name: "", icon: "", desktop: true, installer: false, mods: false, splitAssets: true, outDir: "/tmp/out" },
       true,
     );
-    expect(custom.outDir).toBe("/tmp/out");
+    expect(norm(custom.outDir)).toBe("/tmp/out");
     expect(custom.splitAssets).toBe(true);
     expect(custom.desktop).toBe(true);
     expect(custom.name).toBeUndefined();

+ 19 - 5
apps/editor/test/project.test.ts

@@ -1,6 +1,6 @@
 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { join, sep } from "node:path";
 import { describe, expect, it } from "vitest";
 import { inspectProject, listContent, openProject, readYaml, resolveWithin, stats, writeYaml } from "../src/project.js";
 import { newProject } from "../src/template.js";
@@ -9,6 +9,12 @@ async function tmpDir(): Promise<string> {
   return mkdtemp(join(tmpdir(), "editor-project-"));
 }
 
+function lastSegment(pathStr: string): string {
+  const norm = pathStr.split("\\").join("/");
+  const idx = norm.lastIndexOf("/");
+  return idx >= 0 ? norm.slice(idx + 1) : norm;
+}
+
 describe("openProject", () => {
   it("accepts a folder with content/ and returns project info", async () => {
     const dir = await tmpDir();
@@ -16,7 +22,7 @@ describe("openProject", () => {
       await mkdir(join(dir, "content"));
       const project = await openProject(dir);
       expect(project.root).toBe(dir);
-      expect(project.name).toBe(dir.split("/").pop());
+      expect(project.name).toBe(lastSegment(dir));
       expect(project.hasContent).toBe(true);
       expect(project.hasMods).toBe(false);
       expect(project.projectYaml).toBeNull();
@@ -53,7 +59,7 @@ describe("openProject", () => {
       await mkdir(join(dir, "content"));
       await writeFile(join(dir, "project.yaml"), "name: [broken\n", "utf8");
       const project = await openProject(dir);
-      expect(project.name).toBe(dir.split("/").pop());
+      expect(project.name).toBe(lastSegment(dir));
       expect(project.projectYaml).toBeNull();
       expect(project.projectYamlError).toBeDefined();
     } finally {
@@ -118,8 +124,16 @@ describe("listContent and stats", () => {
 
 describe("resolveWithin", () => {
   it("accepts paths inside the root", () => {
-    expect(resolveWithin("/a/b", "content/maps/farm.yaml")).toBe("/a/b/content/maps/farm.yaml");
-    expect(resolveWithin("/a/b", ".")).toBe("/a/b");
+    const isWindows = sep === "\\";
+    // On Windows, path.resolve normalizes unix-style roots (e.g. /a/b) to the
+    // current drive using backslashes; normalize both sides for the assertion.
+    const norm = (p: string) => p.split("\\").join("/").replace(/^([A-Z]):/i, "");
+    expect(norm(resolveWithin("/a/b", "content/maps/farm.yaml"))).toBe("/a/b/content/maps/farm.yaml");
+    expect(norm(resolveWithin("/a/b", "."))).toBe("/a/b");
+    if (!isWindows) {
+      expect(resolveWithin("/a/b", "content/maps/farm.yaml")).toBe("/a/b/content/maps/farm.yaml");
+      expect(resolveWithin("/a/b", ".")).toBe("/a/b");
+    }
   });
 
   it("rejects parent traversal and absolute escapes", () => {

+ 2 - 1
apps/editor/vite.config.ts

@@ -19,7 +19,7 @@ import { defineConfig } from "vitest/config";
 // runs in Node where the real `node:*` builtins must stay intact (tests use
 // mkdtemp/writeFile on real tmp dirs).
 export default defineConfig(({ mode }) => {
-  const shimAliases = mode === "test" ? {} : {
+  const shimAliases: Record<string, string> = mode === "test" ? {} : {
     "node:fs/promises": fileURLToPath(new URL("./src/shim/fs-promises.ts", import.meta.url)),
     "node:fs": fileURLToPath(new URL("./src/shim/fs.ts", import.meta.url)),
     "node:path": fileURLToPath(new URL("./src/shim/path.ts", import.meta.url)),
@@ -46,6 +46,7 @@ export default defineConfig(({ mode }) => {
     test: {
       include: ["test/**/*.test.ts"],
       environment: "node",
+      testTimeout: 20000,
     },
   };
 });

+ 16 - 6
apps/mcp-server/test/client-surface.test.ts

@@ -35,12 +35,12 @@ async function setup(): Promise<Harness> {
   };
 }
 
-function textOf(result: { content: { type: string; text?: string }[] }): string {
-  return result.content.find((block) => block.type === "text")?.text ?? "";
+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 contentsOf(result: { contents?: { text?: string }[] }): string {
+  return result.contents?.[0]?.text ?? "";
 }
 
 function jsonOf(text: string): Record<string, any> {
@@ -57,7 +57,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
     }
   });
 
-  it("tools surface: exactly 124 tools, all with object inputSchema", async () => {
+  it("tools surface: exactly 132 tools, all with object inputSchema", async () => {
     const { client, close } = await setup();
     try {
       const tools = await client.listTools();
@@ -92,6 +92,12 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
           "behavior.trigger.list",
           "behavior.trigger.update",
           "behavior.trigger.validate",
+          "craft.create",
+          "craft.do_craft",
+          "craft.get",
+          "craft.list",
+          "craft.update",
+          "craft.validate",
           "crop.create",
           "crop.get",
           "crop.harvest",
@@ -110,11 +116,13 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
           "dialogue.update",
           "dialogue.validate",
           "item.create",
+          "item.damage",
           "item.get",
           "item.give",
           "item.list",
           "item.query_inventory",
           "item.update",
+          "item.use",
           "item.validate",
           "map.create",
           "map.get",
@@ -190,7 +198,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
           "weather.set",
         ].sort(),
       );
-      expect(tools.tools).toHaveLength(124);
+      expect(tools.tools).toHaveLength(132);
       for (const tool of tools.tools) {
         expect(tool.inputSchema).toMatchObject({ type: "object" });
       }
@@ -222,6 +230,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
         "animal",
         "animation",
         "behavior",
+        "craft",
         "crop",
         "dialogue",
         "item",
@@ -257,6 +266,7 @@ describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
         "animal",
         "animation",
         "behavior",
+        "craft",
         "crop",
         "dialogue",
         "item",

+ 135 - 14
apps/mcp-server/test/sample-game.test.ts

@@ -38,8 +38,8 @@ async function setup(): Promise<Harness> {
   };
 }
 
-function textOf(result: { content: { type: string; text?: string }[] }): string {
-  return result.content.find((block) => block.type === "text")?.text ?? "";
+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> {
@@ -88,8 +88,10 @@ describe("apps/mcp-server resolveDirs (parameterized entry)", () => {
   });
 
   it("leaves absolute CLI paths untouched", () => {
-    const dirs = resolveDirs({ argv: ["--content-dir", "/abs/content"], env: {}, cwd: repoRoot });
-    expect(dirs.contentDir).toBe("/abs/content");
+    const isWindows = process.platform === "win32";
+    const absArg = isWindows ? "C:\\abs\\content" : "/abs/content";
+    const dirs = resolveDirs({ argv: ["--content-dir", absArg], env: {}, cwd: repoRoot });
+    expect(dirs.contentDir).toBe(absArg);
   });
 
   it("resolves the optional ws port via CLI --ws-port / env NENGINE_WS_PORT / default", () => {
@@ -120,29 +122,75 @@ describe("examples/sample-game end-to-end through the parameterized entry", () =
     try {
       const info = await callTool(client, "server.info", {});
       expect(info.content).toEqual({
-        animal: ["chicken"],
+        animal: ["chicken", "cow"],
         animation: ["player"],
         behavior: ["starter-gift"],
-        building: ["coop", "fence", "shop"],
-        crop: ["carrot", "parsnip"],
-        dialogue: ["mayor"],
+        building: ["barn", "coop", "fence", "shop"],
+        crop: ["carrot", "parsnip", "pumpkin", "turnip"],
+        dialogue: ["mayor", "mia"],
         interact: ["default"],
         map: ["farm"],
         time: ["default"],
-        item: ["carrot-seed", "carrot", "egg", "fish", "hoe", "parsnip-seed", "parsnip", "watering-can"],
+        item: [
+          "baked-egg",
+          "cake",
+          "carrot-seed",
+          "carrot-soup",
+          "carrot",
+          "cherry-jam",
+          "egg",
+          "festival-cherry-petal",
+          "festival-firework-ticket",
+          "festival-harvest-charm",
+          "festival-snow-crystal",
+          "fish-stew",
+          "fish",
+          "grand-feast",
+          "harvest-pie",
+          "hoe",
+          "milk",
+          "oat-milk",
+          "parsnip-seed",
+          "parsnip-soup",
+          "parsnip",
+          "pumpkin-pie",
+          "pumpkin-seed",
+          "pumpkin",
+          "snow-cone",
+          "summer-pudding",
+          "turnip-seed",
+          "turnip-soup",
+          "turnip",
+          "watering-can",
+        ],
         particle: ["dust", "rain", "sparkle", "wind"],
+        recipe: [
+          "baked-egg",
+          "cake",
+          "carrot-soup",
+          "cherry-jam",
+          "fish-stew",
+          "grand-feast",
+          "harvest-pie",
+          "oat-milk",
+          "parsnip-soup",
+          "pumpkin-pie",
+          "snow-cone",
+          "summer-pudding",
+          "turnip-soup",
+        ],
         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"],
+        sound: ["bgm-autumn", "bgm-spring", "bgm-summer", "bgm-victory", "bgm-winter", "coin", "harvest", "till", "water"],
+        sprite: ["barn", "carrot-grown", "carrot-mature", "carrot-sprout", "chicken-chick", "chicken-grown", "chicken-laying", "coop", "cow-calf", "cow-grown", "cow-milking", "fence", "parsnip-grown", "parsnip-mature", "parsnip-sprout", "player-walk", "player", "pumpkin-grown", "pumpkin-mature", "pumpkin-sprout", "shop", "turnip-grown", "turnip-mature", "turnip-sprout"],
+        statemachine: ["carrot-quest", "chicken-egg", "cow-milk", "daily-stamina", "festival-autumn", "festival-spring", "festival-summer", "festival-winter", "fishing-treat", "mayor-routine", "mayor-talk-quest", "restoration-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.width).toBe(20);
       expect(map.height).toBe(16);
     } finally {
       await close();
@@ -163,7 +211,15 @@ describe("examples/sample-game end-to-end through the parameterized entry", () =
         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"]);
+      // sound catalog includes core sfx plus seasonal/victory BGMs added by the sample festival template.
+      expect(info.content.sound).toEqual(expect.arrayContaining(["coin", "harvest", "till", "water"]));
+      expect((info.content.sound as string[]).filter((id) => id.startsWith("bgm-")).sort()).toEqual([
+        "bgm-autumn",
+        "bgm-spring",
+        "bgm-summer",
+        "bgm-victory",
+        "bgm-winter",
+      ]);
     } finally {
       await close();
     }
@@ -433,6 +489,71 @@ describe("examples/sample-game Tier 3 content (D29): quest chain, stamina/skills
   });
 });
 
+describe("examples/sample-game Tier 4 content: restoration arc + victory", () => {
+  it("unlocks Mia, completes restoration-quest at 500 gold, and gates the victory dialogues", 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");
+
+      // Starter arc -> starter-quest complete.
+      await callTool(client, "time.advance", { ticks: 480 });
+      await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2);
+      expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
+
+      // carrot-quest -> complete.
+      await callTool(client, "time.tick", { ticks: 480 });
+      await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3);
+      expect(player()?.questProgress?.["carrot-quest"]).toBe("complete");
+
+      // mayor-talk-quest: activate + finish the mayor dialogue (+100 gold, +1 egg).
+      await callTool(client, "time.tick", { ticks: 480 });
+      const mayor = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
+      await callTool(client, "dialogue.choose", { index: mayor.choices.length - 1 }); // farewell -> dialogue-end
+      expect(player()?.questProgress?.["mayor-talk-quest"]).toBe("complete");
+      expect(player()?.gold).toBe(100);
+
+      // First day-start after mayor-talk-quest: restoration-quest activates and Mia spawns.
+      await callTool(client, "time.tick", { ticks: 480 });
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "restoration-quest" })).toEqual({
+        machineId: "restoration-quest",
+        state: "active",
+        started: true,
+      });
+      const npcs = await callTool(client, "npc.list", {});
+      expect(npcs.npcs.map((npc: { id: string }) => npc.id)).toContain("npc:mia");
+
+      // Reach the 500-gold restoration goal by selling pumpkins (a new autumn crop).
+      await callTool(client, "item.give", { itemId: "pumpkin", qty: 12 });
+      const sold = await callTool(client, "shop.sell", { shopId: "general-store", itemId: "pumpkin", qty: 12 });
+      expect(sold.gold).toBe(640); // 100 + 12 * 45
+
+      // Next day-start: restoration-quest completes -> flag.victory + 200 gold bonus + 3 eggs.
+      const eggBefore = inv.count("egg");
+      await callTool(client, "time.tick", { ticks: 480 });
+      expect(await callTool(client, "behavior.statemachine.status", { machineId: "restoration-quest" })).toEqual({
+        machineId: "restoration-quest",
+        state: "complete",
+        started: true,
+      });
+      expect(player()?.flag?.victory).toBe(true);
+      // Expected base: 100 (start) + 12 * 45 (pumpkin sale) + 200 (restoration-quest bonus) = 840.
+      // On or after Spring day 15, festival-spring additionally awards +150 gold for a total of 990.
+      expect([840, 990]).toContain(player()?.gold);
+      expect(inv.count("egg")).toBe(eggBefore + 3);
+
+      // The Mayor's and Mia's victory branches are now gated in.
+      const mayorAgain = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
+      expect(mayorAgain.choices.map((choice: { text: string }) => choice.text)).toContain("The farm is restored!");
+      const mia = await callTool(client, "dialogue.start", { dialogueId: "mia" });
+      expect(mia.choices.map((choice: { text: string }) => choice.text)).toContain("The farm is thriving now!");
+    } finally {
+      await close();
+    }
+  });
+});
+
 async function ensureWatered(
   engine: ReturnType<typeof createEngine>,
   client: Client,

+ 1 - 1
apps/mcp-server/test/ws-broadcast.test.ts

@@ -89,7 +89,7 @@ describe("apps/mcp-server WebSocket world broadcast", () => {
     try {
       const frame = (await next()) as WsSnapshotFrame;
       expect(frame.type).toBe("snapshot");
-      expect(frame.map).toMatchObject({ id: "farm", width: 16, height: 16 });
+      expect(frame.map).toMatchObject({ id: "farm", width: 20, height: 16 });
       const ids = (frame.world as { id: string }[]).map((e) => e.id);
       expect(ids).toContain("farm");
       expect(ids).toContain("player");

+ 1 - 1
apps/pack/test/pack.test.ts

@@ -191,7 +191,7 @@ describe("compileBundle", () => {
     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["map"]?.[0]).toMatchObject({ id: "farm", width: 20, 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);

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 4 - 2
docs/api.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2 - 2
docs/decisions.md


+ 3 - 3
docs/editor.md

@@ -265,7 +265,7 @@ D40 decision #5, implemented per decision #16 (**full exposure, no permission na
 
 ```
 apps/editor/src/mcp-server.ts   (pure, node-tested)
-  createGateway(engine)              # @nengine/mcp namespaces registered (22 / 124 tools / 36 resources / 17 prompts)
+  createGateway(engine)              # @nengine/mcp namespaces registered (23 / 132 tools / 36 resources / 17 prompts)
   createNengineMcpServer(engine)     # McpServer (SDK) wrapping the gateway — apps/mcp-server parity
   createEditorMcpHttpServer()        # persistent streamable HTTP server: 127.0.0.1, ephemeral port, /mcp
                                      #   GET (SSE) + POST + DELETE + OPTIONS (CORS preflight) per the MCP spec
@@ -294,7 +294,7 @@ main.ts                              # engine lifecycle (open → create/start,
 1. Open (or create) a project in the editor.
 2. The top bar shows the MCP URL (e.g. `http://127.0.0.1:43127/mcp`); press **Copy MCP URL**.
 3. Paste the URL into the AI tool's MCP server configuration (streamable HTTP client — e.g. Claude Desktop / Cursor-style agent). Non-browser clients need no CORS; the OPTIONS preflight is answered for browser-based clients.
-4. The AI tool now sees the project's 22 namespaces (124 tools / 36 resources / 17 prompts): browse content (`item.list`, `map.get`, resources), author (`item.create`…), and debug the live edit instance (`time.advance`, `crop.till`, `npc.spawn`, `player.intent`…). Changes reflect in the GUI on the next sidebar/status refresh; GUI saves reflect in MCP immediately.
+4. The AI tool now sees the project's 23 namespaces (132 tools / 36 resources / 17 prompts): browse content (`item.list`, `map.get`, resources), author (`item.create`…), and debug the live edit instance (`time.advance`, `crop.till`, `npc.spawn`, `player.intent`, `craft.do_craft`…). Changes reflect in the GUI on the next sidebar/status refresh; GUI saves reflect in MCP immediately.
 
 ### Lifecycle & transport table
 
@@ -464,7 +464,7 @@ ELECTRON_DISABLE_SANDBOX=1 DISPLAY=:1 \
   "./apps/editor/dist-app/nEngine Editor-0.1.0.AppImage" --mcp-stdio --dir examples/sample-game
 ```
 
-Verified end-to-end (2026-08-13): AppImage launch → renderer + embedded-MCP loopback servers, home screen served (HTTP 200, `nEngine Editor` + `#btn-open` DOM), zero preload/bridge errors, renderer process running against the mounted asar; `--mcp-stdio --dir` completes a real MCP handshake (124 tools, `server.info` returns the project's content). `dist:win` requires a Windows host (NSIS).
+Verified end-to-end (2026-08-13): AppImage launch → renderer + embedded-MCP loopback servers, home screen served (HTTP 200, `nEngine Editor` + `#btn-open` DOM), zero preload/bridge errors, renderer process running against the mounted asar; `--mcp-stdio --dir` completes a real MCP handshake (132 tools, `server.info` returns the project's content). `dist:win` requires a Windows host (NSIS).
 
 ## Round Roadmap
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 15 - 0
docs/mcp.md


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

@@ -0,0 +1,10 @@
+# Cow: calf -> grown -> milking. Feed is required from grown onward; a mature
+# (milking-stage) cow emits animal-produce each fed day, which the cow-milk
+# state machine turns into milk. Stages reference procedural sprites.
+id: cow
+stages:
+  - { name: calf, days: 3, feedRequired: false, spriteRef: cow-calf }
+  - { name: grown, days: 3, feedRequired: true, spriteRef: cow-grown }
+  - { name: milking, days: 1, feedRequired: true, spriteRef: cow-milking }
+produces:
+  - { itemId: milk, qty: 1 }

+ 9 - 0
examples/sample-game/content/buildings/barn.yaml

@@ -0,0 +1,9 @@
+# Building: 3x3 barn, only placeable on grass, blocks NPC movement. A home for
+# a dairy cow (mirrors the coop/chicken pairing).
+id: barn
+name: Barn
+footprint: { w: 3, h: 3 }
+buildable: { tiles: [grass] }
+blocksMovement: true
+description: "Shelters a dairy cow."
+spriteRef: barn

+ 14 - 0
examples/sample-game/content/crops/pumpkin.yaml

@@ -0,0 +1,14 @@
+# Autumn crop: 3 stages, water required through the grown stage; drops 1
+# pumpkin at harvest. Stages reuse the generic Kenney plant-* assets with a
+# programmatic orange accent on the mature stage (a data-only color hint, like
+# parsnip-mature's root tip). Makes autumn a growable season.
+id: pumpkin
+seedItem: pumpkin-seed
+stages:
+  - { name: sprout, days: 2, waterRequired: true, spriteRef: pumpkin-sprout }
+  - { name: grown, days: 3, waterRequired: true, spriteRef: pumpkin-grown }
+  - { name: mature, days: 2, waterRequired: false, spriteRef: pumpkin-mature }
+seasons:
+  - autumn
+drops:
+  - { itemId: pumpkin, qty: 1 }

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

@@ -0,0 +1,12 @@
+# Winter crop: a hardy 3-stage crop that grows through winter so every season
+# has something to plant. Shorter grown stage than pumpkin; drops 1 turnip.
+id: turnip
+seedItem: turnip-seed
+stages:
+  - { name: sprout, days: 2, waterRequired: true, spriteRef: turnip-sprout }
+  - { name: grown, days: 2, waterRequired: true, spriteRef: turnip-grown }
+  - { name: mature, days: 2, waterRequired: false, spriteRef: turnip-mature }
+seasons:
+  - winter
+drops:
+  - { itemId: turnip, qty: 1 }

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

@@ -17,6 +17,26 @@ nodes:
             qty: 1
       - text: "Tell me about chickens."
         to: chicken-tips
+      - text: "Happy Spring Festival!"
+        to: festival-spring-wishes
+        when:
+          op: exists
+          path: player.flag.festivalSpring
+      - text: "Summer Market going well?"
+        to: festival-summer-wishes
+        when:
+          op: exists
+          path: player.flag.festivalSummer
+      - text: "Cheers for the Autumn Harvest!"
+        to: festival-autumn-wishes
+        when:
+          op: exists
+          path: player.flag.festivalAutumn
+      - text: "Winter Festival is magical this year."
+        to: festival-winter-wishes
+        when:
+          op: exists
+          path: player.flag.festivalWinter
       - text: "Got anything for me?"
         to: quest-reward
         when:
@@ -27,6 +47,16 @@ nodes:
         when:
           op: exists
           path: player.questProgress.mayor-talk-quest
+      - text: "How do I restore the farm?"
+        to: restoration-goal
+        when:
+          op: exists
+          path: player.questProgress.mayor-talk-quest
+      - text: "The farm is restored!"
+        to: mayor-victory
+        when:
+          op: exists
+          path: player.flag.victory
       - text: "I am just passing through."
         to: farewell
   - id: farming-tips
@@ -49,5 +79,29 @@ nodes:
         itemId: egg
         qty: 1
     to: greeting
+  - id: restoration-goal
+    text: "Mayor: The old farm was abandoned for years. Restore it — earn 500 gold from your harvests and husbandry, and Pinefield Valley will live again. Mia the merchant should be passing through soon; she knows these lands well."
+    to: greeting
+  - id: mayor-victory
+    text: "Mayor: Look at it — fields tilled and planted, animals fed, the coop and barn full. You've restored Pinefield Valley! The valley is yours, farmer. Well done."
+    actions:
+      - action: give-item
+        itemId: pumpkin
+        qty: 2
+      - action: play-sound
+        sound: harvest
+    to: farewell
+  - id: festival-spring-wishes
+    text: "Mayor: Ah yes, Spring Festival! The petals are falling everywhere. Take a walk down the riverbank and enjoy!"
+    to: greeting
+  - id: festival-summer-wishes
+    text: "Mayor: Summer Market! Come get a fireworks ticket at the general store. Show starts at dusk."
+    to: greeting
+  - id: festival-autumn-wishes
+    text: "Mayor: Happy Autumn Harvest! This year's crops were impressive thanks to you. Get a harvest charm as thanks."
+    to: greeting
+  - id: festival-winter-wishes
+    text: "Mayor: Winter Festival magic is in the air! Take home a snow crystal before they melt."
+    to: greeting
   - id: farewell
     text: "Mayor: Good luck, farmer!"

+ 71 - 0
examples/sample-game/content/dialogues/mia.yaml

@@ -0,0 +1,71 @@
+# Mia dialogue: the traveling merchant who arrives once the mayor-talk-quest
+# is complete (the Mod spawns her). Choices, a give-item action, and a
+# condition-gated victory branch (only after player.flag.victory exists).
+id: mia
+startNode: greeting
+nodes:
+  - id: greeting
+    text: "Mia: Well hello! I'm Mia, a traveling merchant. Heard you're fixing up the old farm."
+    choices:
+      - text: "What do you sell?"
+        to: wares
+      - text: "Any advice for a new farmer?"
+        to: advice
+      - text: "Happy Spring Festival!"
+        to: festival-spring-wishes
+        when:
+          op: exists
+          path: player.flag.festivalSpring
+      - text: "Summer Market going well?"
+        to: festival-summer-wishes
+        when:
+          op: exists
+          path: player.flag.festivalSummer
+      - text: "Cheers for the Autumn Harvest!"
+        to: festival-autumn-wishes
+        when:
+          op: exists
+          path: player.flag.festivalAutumn
+      - text: "Winter Festival is magical this year."
+        to: festival-winter-wishes
+        when:
+          op: exists
+          path: player.flag.festivalWinter
+      - text: "The farm is thriving now!"
+        to: victory
+        when:
+          op: exists
+          path: player.flag.victory
+      - text: "See you around."
+        to: farewell
+  - id: wares
+    text: "Mia: I deal in rumors and encouragement — the store across the way has the actual seeds and tools. Here, a free pumpkin seed to get your autumn planting started."
+    actions:
+      - action: give-item
+        itemId: pumpkin-seed
+        qty: 1
+    to: greeting
+  - id: advice
+    text: "Mia: Plant to the season — parsnips in spring, carrots in summer, pumpkins in autumn, turnips even in winter. A dairy cow in a barn brings steady milk. Stack your harvests and the gold adds up."
+    to: greeting
+  - id: victory
+    text: "Mia: I can see it from the road — the fields are full and the coop and barn are humming. Pinefield Valley is alive again. You've done it, farmer!"
+    actions:
+      - action: give-item
+        itemId: milk
+        qty: 2
+    to: farewell
+  - id: festival-spring-wishes
+    text: "Mia: Spring petals? I brought a few crates of them from the eastern valley."
+    to: greeting
+  - id: festival-summer-wishes
+    text: "Mia: Summer fireworks! I stocked extra glowsticks just for the market crowd."
+    to: greeting
+  - id: festival-autumn-wishes
+    text: "Mia: Autumn harvest charms — I carry handcrafted ones from the mountain village."
+    to: greeting
+  - id: festival-winter-wishes
+    text: "Mia: Winter snow crystals? I keep them in a chilled case. They never melt!"
+    to: greeting
+  - id: farewell
+    text: "Mia: Safe travels. I'll swing by again next season."

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

@@ -0,0 +1,10 @@
+id: baked-egg
+name: Baked Egg
+kind: good
+stackable: true
+price: 40
+rarity: common
+description: "A simple baked egg, warm and filling."
+effects:
+  consumable:
+    stamina: 14

+ 10 - 0
examples/sample-game/content/items/cake.yaml

@@ -0,0 +1,10 @@
+id: cake
+name: Farm Cake
+kind: good
+stackable: true
+price: 150
+rarity: rare
+description: "A celebratory cake baked with egg, milk, and carrot."
+effects:
+  consumable:
+    stamina: 40

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

@@ -5,3 +5,6 @@ kind: seed
 stackable: true
 price: 25
 use: plant in tilled soil to grow a carrot
+description: "Seeds for orange carrots. Spring crop, 3-stage growth."
+rarity: common
+spriteRef: carrot-seed

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

@@ -0,0 +1,10 @@
+id: carrot-soup
+name: Carrot Soup
+kind: good
+stackable: true
+price: 50
+rarity: common
+description: "Smooth carrot soup simmered with fresh milk."
+effects:
+  consumable:
+    stamina: 18

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

@@ -4,3 +4,9 @@ name: Carrot
 kind: material
 stackable: true
 price: 40
+description: "Sweet crunchy carrots. Loved by horses and bunnies alike."
+rarity: common
+spriteRef: carrot-mature
+effects:
+  consumable:
+    stamina: 10

+ 10 - 0
examples/sample-game/content/items/cherry-jam.yaml

@@ -0,0 +1,10 @@
+id: cherry-jam
+name: Cherry Jam
+kind: good
+stackable: true
+price: 80
+rarity: uncommon
+description: "Spring festival jam made from cherry petals."
+effects:
+  consumable:
+    stamina: 16

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

@@ -6,3 +6,9 @@ kind: material
 stackable: true
 price: 15
 use: a chicken's daily produce
+description: "Eggs from free-range chickens. Breakfast gold."
+rarity: uncommon
+spriteRef: egg
+effects:
+  consumable:
+    stamina: 6

+ 10 - 0
examples/sample-game/content/items/festival-cherry-petal.yaml

@@ -0,0 +1,10 @@
+id: festival-cherry-petal
+name: Cherry Blossom Petal
+kind: consumable
+stackable: true
+price: 80
+rarity: rare
+description: "Delicate pink petals from the Spring Festival cherry tree."
+effects:
+  consumable:
+    stamina: 25

+ 11 - 0
examples/sample-game/content/items/festival-firework-ticket.yaml

@@ -0,0 +1,11 @@
+id: festival-firework-ticket
+name: Summer Firework Ticket
+kind: consumable
+stackable: true
+price: 80
+rarity: rare
+description: "Admission ticket to the Summer Market riverside fireworks show."
+effects:
+  consumable:
+    stamina: 20
+    gold: 50

+ 11 - 0
examples/sample-game/content/items/festival-harvest-charm.yaml

@@ -0,0 +1,11 @@
+id: festival-harvest-charm
+name: Harvest Charm
+kind: consumable
+stackable: true
+price: 120
+rarity: rare
+description: "A wooden charm carved at the Autumn Harvest Festival. Supposed to bring bountiful crops."
+effects:
+  consumable:
+    stamina: 30
+    gold: 30

+ 10 - 0
examples/sample-game/content/items/festival-snow-crystal.yaml

@@ -0,0 +1,10 @@
+id: festival-snow-crystal
+name: Snow Crystal
+kind: consumable
+stackable: true
+price: 100
+rarity: rare
+description: "A perfect hexagonal snowflake preserved from the Winter Festival."
+effects:
+  consumable:
+    stamina: 28

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

@@ -0,0 +1,10 @@
+id: fish-stew
+name: Fish Stew
+kind: good
+stackable: true
+price: 90
+rarity: uncommon
+description: "Hearty fish stew with turnip and a splash of milk."
+effects:
+  consumable:
+    stamina: 30

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

@@ -7,3 +7,9 @@ kind: material
 stackable: true
 price: 25
 use: caught by fishing in the pond
+description: "A freshly caught river fish. Grills nicely."
+rarity: uncommon
+spriteRef: fish
+effects:
+  consumable:
+    stamina: 12

+ 10 - 0
examples/sample-game/content/items/grand-feast.yaml

@@ -0,0 +1,10 @@
+id: grand-feast
+name: Grand Feast
+kind: good
+stackable: true
+price: 300
+rarity: epic
+description: "A grand feast uniting all four seasonal festival charms."
+effects:
+  consumable:
+    stamina: 60

+ 10 - 0
examples/sample-game/content/items/harvest-pie.yaml

@@ -0,0 +1,10 @@
+id: harvest-pie
+name: Harvest Pie
+kind: good
+stackable: true
+price: 130
+rarity: rare
+description: "Autumn harvest pie blending pumpkin and harvest charm."
+effects:
+  consumable:
+    stamina: 34

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

@@ -5,3 +5,8 @@ kind: tool
 stackable: false
 price: 500
 use: tills a soil tile for planting
+description: "Standard iron hoe. Till grass into farmland."
+rarity: uncommon
+durability:
+  max: 100
+spriteRef: hoe-sprite

+ 14 - 0
examples/sample-game/content/items/milk.yaml

@@ -0,0 +1,14 @@
+# Material: the cow's daily produce; granted by the cow-milk state machine
+# (the produce table is data on the animal def; the grant rule is behavior).
+id: milk
+name: Milk
+kind: material
+stackable: true
+price: 25
+use: a cow's daily produce
+description: "Fresh cow milk. Delicious warm or cold."
+rarity: uncommon
+spriteRef: milk
+effects:
+  consumable:
+    stamina: 8

+ 10 - 0
examples/sample-game/content/items/oat-milk.yaml

@@ -0,0 +1,10 @@
+id: oat-milk
+name: Oat Milk
+kind: good
+stackable: true
+price: 55
+rarity: uncommon
+description: "Creamy oat milk blended with fresh cow milk. Barn station specialty."
+effects:
+  consumable:
+    stamina: 20

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

@@ -5,3 +5,6 @@ kind: seed
 stackable: true
 price: 20
 use: plant in tilled soil to grow a parsnip
+description: "Seeds for spring parsnips. Slow and steady."
+rarity: common
+spriteRef: parsnip-seed

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

@@ -0,0 +1,10 @@
+id: parsnip-soup
+name: Parsnip Soup
+kind: good
+stackable: true
+price: 65
+rarity: uncommon
+description: "Earthy parsnip soup, a spring kitchen staple."
+effects:
+  consumable:
+    stamina: 24

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

@@ -4,3 +4,9 @@ name: Parsnip
 kind: material
 stackable: true
 price: 35
+description: "Creamy parsnips. The classic valley staple."
+rarity: common
+spriteRef: parsnip
+effects:
+  consumable:
+    stamina: 14

+ 11 - 0
examples/sample-game/content/items/pumpkin-pie.yaml

@@ -0,0 +1,11 @@
+id: pumpkin-pie
+name: Pumpkin Pie
+kind: good
+stackable: true
+price: 120
+rarity: rare
+description: "Warm pumpkin pie with a flaky crust. Festival favorite."
+spriteRef: pumpkin-mature
+effects:
+  consumable:
+    stamina: 35

+ 10 - 0
examples/sample-game/content/items/pumpkin-seed.yaml

@@ -0,0 +1,10 @@
+# Seed: planted in tilled soil to grow a pumpkin (autumn crop).
+id: pumpkin-seed
+name: Pumpkin Seed
+kind: seed
+stackable: true
+price: 20
+use: plant in tilled soil to grow a pumpkin
+description: "Seeds for autumn pumpkins. Big orange harvest."
+rarity: common
+spriteRef: pumpkin-seed

+ 12 - 0
examples/sample-game/content/items/pumpkin.yaml

@@ -0,0 +1,12 @@
+# Harvest material: the pumpkin crop's drop, sold at the store.
+id: pumpkin
+name: Pumpkin
+kind: material
+stackable: true
+price: 45
+description: "Big orange pumpkins. Autumn harvest, used in pies and festivals."
+rarity: uncommon
+spriteRef: pumpkin-mature
+effects:
+  consumable:
+    stamina: 18

+ 10 - 0
examples/sample-game/content/items/snow-cone.yaml

@@ -0,0 +1,10 @@
+id: snow-cone
+name: Snow Cone
+kind: good
+stackable: true
+price: 70
+rarity: uncommon
+description: "A winter festival treat of shaved snow crystals."
+effects:
+  consumable:
+    stamina: 15

+ 10 - 0
examples/sample-game/content/items/summer-pudding.yaml

@@ -0,0 +1,10 @@
+id: summer-pudding
+name: Summer Pudding
+kind: good
+stackable: true
+price: 110
+rarity: rare
+description: "A festive summer pudding, sweet and cool."
+effects:
+  consumable:
+    stamina: 28

+ 10 - 0
examples/sample-game/content/items/turnip-seed.yaml

@@ -0,0 +1,10 @@
+# Seed: planted in tilled soil to grow a turnip (winter crop).
+id: turnip-seed
+name: Turnip Seed
+kind: seed
+stackable: true
+price: 15
+use: plant in tilled soil to grow a turnip
+description: "Seeds for winter turnips. Hardy and quick."
+rarity: common
+spriteRef: turnip-seed

+ 10 - 0
examples/sample-game/content/items/turnip-soup.yaml

@@ -0,0 +1,10 @@
+id: turnip-soup
+name: Turnip Soup
+kind: good
+stackable: true
+price: 60
+rarity: uncommon
+description: "Hearty turnip and root vegetable soup."
+effects:
+  consumable:
+    stamina: 22

+ 12 - 0
examples/sample-game/content/items/turnip.yaml

@@ -0,0 +1,12 @@
+# Harvest material: the turnip crop's drop, sold at the store.
+id: turnip
+name: Turnip
+kind: material
+stackable: true
+price: 30
+description: "Crisp white turnips. Winter staple."
+rarity: uncommon
+spriteRef: turnip
+effects:
+  consumable:
+    stamina: 16

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

@@ -6,3 +6,8 @@ kind: tool
 stackable: false
 price: 300
 use: waters a tilled plot
+description: "Can holds water to wet tilled soil daily."
+rarity: uncommon
+durability:
+  max: 100
+spriteRef: watering-can-sprite

+ 328 - 28
examples/sample-game/content/maps/farm.yaml

@@ -1,36 +1,336 @@
-# 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
+width: 20
 height: 16
-tilesetRef: "farm-tiles"
-spawn: { x: 2, y: 4 }
+tilesetRef: farm-tiles
+spawn:
+  x: 2
+  y: 4
+layers:
+  - name: ground
+    data:
+      - - water
+        - water
+        - water
+        - water
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+      - - water
+        - tilled
+        - water
+        - water
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+      - - water
+        - tilled
+        - tilled
+        - water
+        - grass
+        - water
+        - water
+        - grass
+        - grass
+        - water
+        - water
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+      - - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - water
+        - water
+        - water
+        - water
+        - water
+        - 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
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - tilled
+        - tilled
+        - tilled
+        - tilled
+      - - grass
+        - grass
+        - grass
+        - grass
+        - tilled
+        - tilled
+        - tilled
+        - tilled
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - tilled
+        - tilled
+        - tilled
+        - tilled
+      - - 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
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+      - - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - water
+        - water
+        - water
+      - - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - 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
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+      - - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
+        - grass
 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]

+ 6 - 0
examples/sample-game/content/recipes/baked-egg.yaml

@@ -0,0 +1,6 @@
+id: baked-egg
+output:
+  itemId: baked-egg
+  qty: 1
+inputs:
+  - { itemId: egg, qty: 2 }

+ 8 - 0
examples/sample-game/content/recipes/cake.yaml

@@ -0,0 +1,8 @@
+id: cake
+output:
+  itemId: cake
+  qty: 1
+inputs:
+  - { itemId: egg, qty: 2 }
+  - { itemId: milk, qty: 1 }
+  - { itemId: carrot, qty: 1 }

+ 7 - 0
examples/sample-game/content/recipes/carrot-soup.yaml

@@ -0,0 +1,7 @@
+id: carrot-soup
+output:
+  itemId: carrot-soup
+  qty: 1
+inputs:
+  - { itemId: carrot, qty: 2 }
+  - { itemId: milk, qty: 1 }

+ 6 - 0
examples/sample-game/content/recipes/cherry-jam.yaml

@@ -0,0 +1,6 @@
+id: cherry-jam
+output:
+  itemId: cherry-jam
+  qty: 1
+inputs:
+  - { itemId: festival-cherry-petal, qty: 2 }

+ 8 - 0
examples/sample-game/content/recipes/fish-stew.yaml

@@ -0,0 +1,8 @@
+id: fish-stew
+output:
+  itemId: fish-stew
+  qty: 1
+inputs:
+  - { itemId: fish, qty: 1 }
+  - { itemId: turnip, qty: 1 }
+  - { itemId: milk, qty: 1 }

+ 9 - 0
examples/sample-game/content/recipes/grand-feast.yaml

@@ -0,0 +1,9 @@
+id: grand-feast
+output:
+  itemId: grand-feast
+  qty: 1
+inputs:
+  - { itemId: festival-cherry-petal, qty: 1 }
+  - { itemId: festival-firework-ticket, qty: 1 }
+  - { itemId: festival-harvest-charm, qty: 1 }
+  - { itemId: festival-snow-crystal, qty: 1 }

+ 7 - 0
examples/sample-game/content/recipes/harvest-pie.yaml

@@ -0,0 +1,7 @@
+id: harvest-pie
+output:
+  itemId: harvest-pie
+  qty: 1
+inputs:
+  - { itemId: festival-harvest-charm, qty: 1 }
+  - { itemId: pumpkin, qty: 1 }

+ 7 - 0
examples/sample-game/content/recipes/oat-milk.yaml

@@ -0,0 +1,7 @@
+id: oat-milk
+output:
+  itemId: oat-milk
+  qty: 1
+inputs:
+  - { itemId: milk, qty: 2 }
+station: barn

+ 7 - 0
examples/sample-game/content/recipes/parsnip-soup.yaml

@@ -0,0 +1,7 @@
+id: parsnip-soup
+output:
+  itemId: parsnip-soup
+  qty: 1
+inputs:
+  - { itemId: parsnip, qty: 2 }
+  - { itemId: milk, qty: 1 }

+ 8 - 0
examples/sample-game/content/recipes/pumpkin-pie.yaml

@@ -0,0 +1,8 @@
+id: pumpkin-pie
+output:
+  itemId: pumpkin-pie
+  qty: 1
+inputs:
+  - { itemId: pumpkin, qty: 3 }
+  - { itemId: milk, qty: 1 }
+station: barn

+ 6 - 0
examples/sample-game/content/recipes/snow-cone.yaml

@@ -0,0 +1,6 @@
+id: snow-cone
+output:
+  itemId: snow-cone
+  qty: 1
+inputs:
+  - { itemId: festival-snow-crystal, qty: 2 }

+ 7 - 0
examples/sample-game/content/recipes/summer-pudding.yaml

@@ -0,0 +1,7 @@
+id: summer-pudding
+output:
+  itemId: summer-pudding
+  qty: 1
+inputs:
+  - { itemId: festival-firework-ticket, qty: 1 }
+  - { itemId: milk, qty: 1 }

+ 7 - 0
examples/sample-game/content/recipes/turnip-soup.yaml

@@ -0,0 +1,7 @@
+id: turnip-soup
+output:
+  itemId: turnip-soup
+  qty: 1
+inputs:
+  - { itemId: turnip, qty: 2 }
+  - { itemId: milk, qty: 1 }

+ 13 - 0
examples/sample-game/content/shops/general-store.yaml

@@ -5,9 +5,22 @@ id: general-store
 buys:
   - { itemId: parsnip, price: 30 }
   - { itemId: carrot, price: 40 }
+  - { itemId: pumpkin, price: 45 }
+  - { itemId: turnip, price: 30 }
   - { itemId: egg, price: 15 }
+  - { itemId: milk, price: 25 }
+  - { itemId: festival-cherry-petal, price: 40 }
+  - { itemId: festival-firework-ticket, price: 40 }
+  - { itemId: festival-harvest-charm, price: 60 }
+  - { itemId: festival-snow-crystal, price: 50 }
 sells:
   - { itemId: hoe, price: 500 }
   - { itemId: watering-can, price: 300 }
   - { itemId: parsnip-seed, price: 20, stock: 99 }
   - { itemId: carrot-seed, price: 25, stock: 99 }
+  - { itemId: pumpkin-seed, price: 20, stock: 99 }
+  - { itemId: turnip-seed, price: 15, stock: 99 }
+  - { itemId: festival-cherry-petal, price: 80, stock: 1 }
+  - { itemId: festival-firework-ticket, price: 80, stock: 1 }
+  - { itemId: festival-harvest-charm, price: 120, stock: 1 }
+  - { itemId: festival-snow-crystal, price: 100, stock: 1 }

+ 10 - 0
examples/sample-game/content/sounds/bgm-autumn.yaml

@@ -0,0 +1,10 @@
+id: bgm-autumn
+mode: bgm
+loop: true
+volume: 0.5
+fadeMs: 800
+synth:
+  wave: sine
+  freq: 494
+  durationMs: 30000
+  gain: 0.4

+ 10 - 0
examples/sample-game/content/sounds/bgm-spring.yaml

@@ -0,0 +1,10 @@
+id: bgm-spring
+mode: bgm
+loop: true
+volume: 0.5
+fadeMs: 800
+synth:
+  wave: triangle
+  freq: 659
+  durationMs: 30000
+  gain: 0.4

+ 10 - 0
examples/sample-game/content/sounds/bgm-summer.yaml

@@ -0,0 +1,10 @@
+id: bgm-summer
+mode: bgm
+loop: true
+volume: 0.5
+fadeMs: 800
+synth:
+  wave: sawtooth
+  freq: 784
+  durationMs: 30000
+  gain: 0.4

+ 10 - 0
examples/sample-game/content/sounds/bgm-victory.yaml

@@ -0,0 +1,10 @@
+id: bgm-victory
+mode: bgm
+loop: false
+volume: 0.7
+fadeMs: 500
+synth:
+  wave: square
+  freq: 1047
+  durationMs: 8000
+  gain: 0.5

+ 10 - 0
examples/sample-game/content/sounds/bgm-winter.yaml

@@ -0,0 +1,10 @@
+id: bgm-winter
+mode: bgm
+loop: true
+volume: 0.5
+fadeMs: 800
+synth:
+  wave: sine
+  freq: 440
+  durationMs: 30000
+  gain: 0.35

+ 12 - 0
examples/sample-game/content/sprites/barn.yaml

@@ -0,0 +1,12 @@
+# Barn building sprite: the Kenney barn roof tile (coop-2) over the barn wall
+# tile (coop-1), a wide dark door, and a yellow hay accent in the loft. The
+# render layer scales it across the 3x3 footprint.
+id: barn
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: image, src: coop-2, x: 0, y: 0, w: 32, h: 14 }
+      - { shape: image, src: coop-1, x: 0, y: 14, w: 32, h: 18 }
+      - { shape: rect, x: 11, y: 20, w: 10, h: 12, fill: "#4e342e" }
+      - { shape: rect, x: 13, y: 4, w: 6, h: 4, fill: "#ffeb3b" }

+ 8 - 0
examples/sample-game/content/sprites/cow-calf.yaml

@@ -0,0 +1,8 @@
+# Cow stage 0 (calf): small brown body with a lighter head.
+id: cow-calf
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: circle, x: 16, y: 20, r: 7, fill: "#8d6e63" }
+      - { shape: circle, x: 16, y: 14, r: 5, fill: "#a1887f" }

+ 10 - 0
examples/sample-game/content/sprites/cow-grown.yaml

@@ -0,0 +1,10 @@
+# Cow stage 1 (grown): brown body, lighter head, dark horns.
+id: cow-grown
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: circle, x: 16, y: 19, r: 11, fill: "#8d6e63" }
+      - { shape: circle, x: 16, y: 11, r: 7, fill: "#a1887f" }
+      - { shape: rect, x: 12, y: 9, w: 3, h: 3, fill: "#5d4037" }
+      - { shape: rect, x: 17, y: 9, w: 3, h: 3, fill: "#5d4037" }

+ 12 - 0
examples/sample-game/content/sprites/cow-milking.yaml

@@ -0,0 +1,12 @@
+# Cow stage 2 (milking): grown body plus a white udder accent marking the
+# produce stage.
+id: cow-milking
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: circle, x: 16, y: 19, r: 11, fill: "#8d6e63" }
+      - { shape: circle, x: 16, y: 11, r: 7, fill: "#a1887f" }
+      - { shape: rect, x: 12, y: 9, w: 3, h: 3, fill: "#5d4037" }
+      - { shape: rect, x: 17, y: 9, w: 3, h: 3, fill: "#5d4037" }
+      - { shape: rect, x: 13, y: 24, w: 6, h: 4, fill: "#fafafa" }

+ 7 - 0
examples/sample-game/content/sprites/pumpkin-grown.yaml

@@ -0,0 +1,7 @@
+# Pumpkin stage 1 (grown): the generic grown bush asset (plant-grown).
+id: pumpkin-grown
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: image, src: plant-grown, x: 6, y: 8, w: 20, h: 20 }

+ 11 - 0
examples/sample-game/content/sprites/pumpkin-mature.yaml

@@ -0,0 +1,11 @@
+# Pumpkin stage 2 (mature): the largest bush asset (plant-mature) plus a
+# programmatic orange accent peeking out at the base. The sparkle-glow shader
+# (D34) gives the mature crop a slight brightness lift.
+id: pumpkin-mature
+filters: [sparkle-glow]
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: image, src: plant-mature, x: 4, y: 6, w: 24, h: 24 }
+      - { shape: circle, x: 16, y: 26, r: 5, fill: "#fb8c00" }

+ 7 - 0
examples/sample-game/content/sprites/pumpkin-sprout.yaml

@@ -0,0 +1,7 @@
+# Pumpkin stage 0 (sprout): the generic sprout asset (plant-sprout).
+id: pumpkin-sprout
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: image, src: plant-sprout, x: 8, y: 10, w: 16, h: 16 }

+ 7 - 0
examples/sample-game/content/sprites/turnip-grown.yaml

@@ -0,0 +1,7 @@
+# Turnip stage 1 (grown): the generic grown bush asset (plant-grown).
+id: turnip-grown
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: image, src: plant-grown, x: 6, y: 8, w: 20, h: 20 }

+ 11 - 0
examples/sample-game/content/sprites/turnip-mature.yaml

@@ -0,0 +1,11 @@
+# Turnip stage 2 (mature): the largest bush asset (plant-mature) plus a
+# programmatic purple accent peeking out at the base. The sparkle-glow shader
+# (D34) gives the mature crop a slight brightness lift.
+id: turnip-mature
+filters: [sparkle-glow]
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: image, src: plant-mature, x: 4, y: 6, w: 24, h: 24 }
+      - { shape: circle, x: 16, y: 26, r: 5, fill: "#8e24aa" }

+ 7 - 0
examples/sample-game/content/sprites/turnip-sprout.yaml

@@ -0,0 +1,7 @@
+# Turnip stage 0 (sprout): the generic sprout asset (plant-sprout).
+id: turnip-sprout
+frames:
+  - name: idle
+    durationTicks: 1
+    layers:
+      - { shape: image, src: plant-sprout, x: 8, y: 10, w: 16, h: 16 }

+ 30 - 0
examples/sample-game/content/statemachines/cow-milk.yaml

@@ -0,0 +1,30 @@
+# Production rule (D9 / D19): the engine emits animal-produce per eligible day
+# for a mature (milking) cow; this machine turns that event into a milk grant.
+# Like chicken-egg it alternates on successive animal-produce events, so the
+# milk is granted on every second produce — frequency is language-owned.
+id: cow-milk
+initial: waiting
+states:
+  - id: waiting
+  - id: laid
+    onEnter:
+      - action: give-item
+        itemId: milk
+        qty: 1
+      - action: play-sound
+        sound: coin
+transitions:
+  - from: waiting
+    to: laid
+    on: animal-produce
+    when:
+      op: eq
+      path: payload.animalDefId
+      value: cow
+  - from: laid
+    to: waiting
+    on: animal-produce
+    when:
+      op: eq
+      path: payload.animalDefId
+      value: cow

+ 41 - 0
examples/sample-game/content/statemachines/festival-autumn.yaml

@@ -0,0 +1,41 @@
+id: festival-autumn
+initial: inactive
+states:
+  - id: inactive
+  - id: active
+    onEnter:
+      - action: give-item
+        itemId: festival-harvest-charm
+        qty: 1
+      - action: add
+        path: player.gold
+        value: 150
+      - action: set-state
+        path: player.flag.festivalAutumn
+        value: true
+      - action: play-bgm
+        soundId: bgm-autumn
+        fadeMs: 800
+  - id: inactive2
+    onEnter:
+      - action: stop-bgm
+        fadeMs: 500
+transitions:
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      and:
+        - { op: eq, path: time.day, value: 18 }
+        - { op: eq, path: time.season, value: autumn }
+        - not: { op: exists, path: player.flag.festivalAutumn }
+  - from: active
+    to: inactive2
+    on: day-start
+    when:
+      and:
+        - { op: exists, path: player.flag.festivalAutumn }
+        - not: { op: eq, path: time.day, value: 18 }
+  - from: inactive2
+    to: inactive2
+    on: day-start

+ 41 - 0
examples/sample-game/content/statemachines/festival-spring.yaml

@@ -0,0 +1,41 @@
+id: festival-spring
+initial: inactive
+states:
+  - id: inactive
+  - id: active
+    onEnter:
+      - action: give-item
+        itemId: festival-cherry-petal
+        qty: 1
+      - action: add
+        path: player.gold
+        value: 150
+      - action: set-state
+        path: player.flag.festivalSpring
+        value: true
+      - action: play-bgm
+        soundId: bgm-spring
+        fadeMs: 800
+  - id: inactive2
+    onEnter:
+      - action: stop-bgm
+        fadeMs: 500
+transitions:
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      and:
+        - { op: eq, path: time.day, value: 15 }
+        - { op: eq, path: time.season, value: spring }
+        - not: { op: exists, path: player.flag.festivalSpring }
+  - from: active
+    to: inactive2
+    on: day-start
+    when:
+      and:
+        - { op: exists, path: player.flag.festivalSpring }
+        - not: { op: eq, path: time.day, value: 15 }
+  - from: inactive2
+    to: inactive2
+    on: day-start

+ 41 - 0
examples/sample-game/content/statemachines/festival-summer.yaml

@@ -0,0 +1,41 @@
+id: festival-summer
+initial: inactive
+states:
+  - id: inactive
+  - id: active
+    onEnter:
+      - action: give-item
+        itemId: festival-firework-ticket
+        qty: 1
+      - action: add
+        path: player.gold
+        value: 150
+      - action: set-state
+        path: player.flag.festivalSummer
+        value: true
+      - action: play-bgm
+        soundId: bgm-summer
+        fadeMs: 800
+  - id: inactive2
+    onEnter:
+      - action: stop-bgm
+        fadeMs: 500
+transitions:
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      and:
+        - { op: eq, path: time.day, value: 20 }
+        - { op: eq, path: time.season, value: summer }
+        - not: { op: exists, path: player.flag.festivalSummer }
+  - from: active
+    to: inactive2
+    on: day-start
+    when:
+      and:
+        - { op: exists, path: player.flag.festivalSummer }
+        - not: { op: eq, path: time.day, value: 20 }
+  - from: inactive2
+    to: inactive2
+    on: day-start

+ 41 - 0
examples/sample-game/content/statemachines/festival-winter.yaml

@@ -0,0 +1,41 @@
+id: festival-winter
+initial: inactive
+states:
+  - id: inactive
+  - id: active
+    onEnter:
+      - action: give-item
+        itemId: festival-snow-crystal
+        qty: 1
+      - action: add
+        path: player.gold
+        value: 150
+      - action: set-state
+        path: player.flag.festivalWinter
+        value: true
+      - action: play-bgm
+        soundId: bgm-winter
+        fadeMs: 800
+  - id: inactive2
+    onEnter:
+      - action: stop-bgm
+        fadeMs: 500
+transitions:
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      and:
+        - { op: eq, path: time.day, value: 22 }
+        - { op: eq, path: time.season, value: winter }
+        - not: { op: exists, path: player.flag.festivalWinter }
+  - from: active
+    to: inactive2
+    on: day-start
+    when:
+      and:
+        - { op: exists, path: player.flag.festivalWinter }
+        - not: { op: eq, path: time.day, value: 22 }
+  - from: inactive2
+    to: inactive2
+    on: day-start

+ 45 - 0
examples/sample-game/content/statemachines/restoration-quest.yaml

@@ -0,0 +1,45 @@
+# Endgame quest (D29/D33): activated on the day-start after the mayor-talk-quest
+# completes, completes on a later day-start once the player has earned at least
+# 500 gold (the restoration goal the Mayor reveals). On completion it pays a
+# bonus, sets the questProgress slot, and raises player.flag.victory — which
+# gates the Mayor's and Mia's victory dialogue branches. The gold reward uses
+# the `add` incremental action (D33); questProgress + flag are absolute set-state.
+id: restoration-quest
+initial: inactive
+states:
+  - id: inactive
+  - id: active
+  - id: complete
+    onEnter:
+      - action: give-item
+        itemId: egg
+        qty: 3
+      - action: add
+        path: player.gold
+        value: 200
+      - action: play-bgm
+        soundId: bgm-victory
+        fadeMs: 500
+      - action: play-sound
+        sound: harvest
+      - action: set-state
+        path: player.questProgress.restoration-quest
+        value: complete
+      - action: set-state
+        path: player.flag.victory
+        value: true
+transitions:
+  - from: inactive
+    to: active
+    on: day-start
+    when:
+      and:
+        - { op: eq, path: player.questProgress.mayor-talk-quest, value: complete }
+        - not: { op: exists, path: player.questProgress.restoration-quest }
+  - from: active
+    to: complete
+    on: day-start
+    when:
+      op: gte
+      path: player.gold
+      value: 500

+ 25 - 0
examples/sample-game/mods/sample-game-mod/index.mjs

@@ -34,6 +34,31 @@ export default {
       mayor.x = walkX;
       mayor.y = walkY;
 
+      // Mia the merchant arrives once the mayor-talk-quest is complete — the
+      // restoration arc's second NPC (content/dialogues/mia.yaml). Gated so she
+      // never appears before the starter arc's day-2 npc.list assertion.
+      const player = world.get("player");
+      const miaUnlocked = player?.questProgress?.["mayor-talk-quest"] === "complete";
+      let mia = world.get("npc:mia");
+      if (miaUnlocked) {
+        const miaX = 14 + (day % 3);
+        const miaY = 11;
+        if (!mia) {
+          mia = world.create({
+            id: "npc:mia",
+            type: "npc",
+            x: miaX,
+            y: miaY,
+            name: "Mia",
+            label: "Mia",
+            dialogue: "mia",
+          });
+        } else {
+          mia.x = miaX;
+          mia.y = miaY;
+        }
+      }
+
       const weather = world.get("weather")?.weatherId ?? "unknown";
 
       emit("mod:sample-day-start", {

+ 107 - 2
packages/core/src/content/loader.ts

@@ -12,6 +12,7 @@ const ASSET_KINDS: Record<string, { kind: AssetEntry["kind"]; mime: string }> =
   ".png": { kind: "image", mime: "image/png" },
   ".ogg": { kind: "audio", mime: "audio/ogg" },
 };
+const contentKinds: ContentKind[] = Object.keys(contentSchemas) as ContentKind[];
 
 export interface LoadItemResult {
   id: string;
@@ -94,7 +95,7 @@ export class ContentLoader {
     return { ok: items.length > 0 && items.every((i) => i.status === "ok"), items };
   }
 
-  watch(dir: string): () => void {
+  watch(dir: string, opts?: { pollIntervalMs?: number }): () => void {
     let timer: ReturnType<typeof setTimeout> | undefined;
     let closed = false;
     let watcher: FSWatcher | undefined;
@@ -115,10 +116,59 @@ export class ContentLoader {
     } catch {
       watcher = undefined;
     }
+
+    // Fallback polling: on platforms where fs watch events are unreliable,
+    // compare directory mtimes / asset listings periodically.
+    const pollInterval = typeof opts?.pollIntervalMs === "number" && opts.pollIntervalMs > 0
+      ? opts.pollIntervalMs
+      : 250;
+    let lastAssetKeys = new Set(this.registry.assets.keys());
+    let lastKindsSnapshot = new Map<ContentKind, number>();
+    void Promise.resolve().then(async () => {
+      for (const k of contentKinds) {
+        lastKindsSnapshot.set(k, await this.kindMtime(dir, k));
+      }
+    });
+    const pollHandle = setInterval(async () => {
+      if (closed) return;
+      try {
+        // Asset diff: compare registry size against actual files.
+        // Use the asset index builder for accuracy.
+        const assetIndex = await this.buildAssetIndex(dir);
+        const currentKeys = new Set(Object.keys(assetIndex));
+        let assetChanged = currentKeys.size !== lastAssetKeys.size;
+        if (!assetChanged) {
+          for (const k of currentKeys) {
+            if (!lastAssetKeys.has(k)) { assetChanged = true; break; }
+          }
+          if (!assetChanged) {
+            for (const k of lastAssetKeys) {
+              if (!currentKeys.has(k)) { assetChanged = true; break; }
+            }
+          }
+        }
+        if (assetChanged) {
+          lastAssetKeys = currentKeys;
+          void this.reloadAssets(dir);
+        }
+        for (const k of contentKinds) {
+          const mtime = await this.kindMtime(dir, k);
+          const prev = lastKindsSnapshot.get(k) ?? 0;
+          if (mtime !== prev) {
+            lastKindsSnapshot.set(k, mtime);
+            void this.reloadKind(dir, k);
+          }
+        }
+      } catch {
+        // ignore polling errors
+      }
+    }, pollInterval);
+
     return () => {
       closed = true;
       if (timer) clearTimeout(timer);
-      watcher?.close();
+      clearInterval(pollHandle);
+      try { watcher?.close(); } catch {}
     };
   }
 
@@ -218,6 +268,61 @@ export class ContentLoader {
     await this.loadAssets(dir);
   }
 
+  private async buildAssetIndex(dir: string): Promise<Record<string, { kind: AssetEntry["kind"]; size: number }>> {
+    const assetDir = resolve(dir, ASSET_DIR_NAME);
+    const index: Record<string, { kind: AssetEntry["kind"]; size: number }> = {};
+    try {
+      const statDir = await stat(assetDir);
+      if (!statDir.isDirectory()) return index;
+      const files = await readdir(assetDir);
+      for (const file of files) {
+        const ext = extname(file).toLowerCase();
+        const info = ASSET_KINDS[ext];
+        if (!info) continue;
+        const id = basename(file, ext);
+        let size = 0;
+        try {
+          size = (await stat(resolve(assetDir, file))).size;
+        } catch {
+          size = -1;
+        }
+        index[id] = { kind: info.kind, size };
+      }
+    } catch {
+      // ignore
+    }
+    return index;
+  }
+
+  private async kindMtime(dir: string, kind: ContentKind): Promise<number> {
+    const singleFile = resolve(dir, `${kind}.yaml`);
+    const kindDir = resolve(dir, contentDirNames[kind]);
+    let latest = 0;
+    try {
+      latest = Math.max(latest, (await stat(singleFile)).mtimeMs);
+    } catch {
+      // ignore
+    }
+    try {
+      const kindStat = await stat(kindDir);
+      if (kindStat.isDirectory()) {
+        latest = Math.max(latest, kindStat.mtimeMs);
+        const entries = await readdir(kindDir);
+        for (const e of entries) {
+          if (!e.endsWith(".yaml") && !e.endsWith(".yml")) continue;
+          try {
+            latest = Math.max(latest, (await stat(resolve(kindDir, e))).mtimeMs);
+          } catch {
+            // ignore
+          }
+        }
+      }
+    } catch {
+      // ignore
+    }
+    return latest;
+  }
+
   private kindForPath(rel: string): ContentKind | undefined {
     for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
       if (rel === `${kind}.yaml` || rel === `${kind}.yml` || rel.startsWith(`${contentDirNames[kind]}/`)) {

+ 6 - 0
packages/core/src/engine.ts

@@ -10,6 +10,7 @@ import { InventorySystem } from "./systems/inventory.js";
 import { PlantingSystem } from "./systems/planting.js";
 import { GrowthSystem } from "./systems/growth.js";
 import { EconomySystem } from "./systems/economy.js";
+import { CraftingSystem } from "./systems/crafting.js";
 import { SaveSystem } from "./systems/save.js";
 import { TriggerSystem } from "./systems/trigger.js";
 import { DialogueSystem } from "./systems/dialogue.js";
@@ -142,6 +143,8 @@ export function createEngine(opts: EngineOptions): Engine {
         stopWatcher();
         stopWatcher = undefined;
       }
+      await systems.destroy();
+      events.clear();
     },
 
     tick(dt: number): void {
@@ -278,6 +281,9 @@ export function createEngine(opts: EngineOptions): Engine {
     if (!systems.get("economy")) {
       systems.register(new EconomySystem());
     }
+    if (!systems.get("crafting")) {
+      systems.register(new CraftingSystem());
+    }
     if (!systems.get("save")) {
       systems.register(new SaveSystem());
     }

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

@@ -12,7 +12,7 @@ export type { ContentBundle, AssetEntry, AssetIndexEntry, BundledMod } from "./c
 export { World, WorldEventBus } from "./world/index.js";
 export type { Entity, EntityQuery, WorldEventHandler, WorldEventAnyHandler } from "./world/index.js";
 
-export { SystemsHost, TimeSystem, InventorySystem, PlantingSystem, plotIdFor, GrowthSystem, EconomySystem, STARTING_GOLD, SaveSystem, TriggerSystem, DialogueSystem, ACTIVE_DIALOGUE_ENTITY_ID, WeatherSystem, WEATHER_ENTITY_ID, PlacementSystem, MovementSystem, UNWALKABLE_TILES, IntentSystem } from "./systems/index.js";
+export { SystemsHost, TimeSystem, InventorySystem, PlantingSystem, plotIdFor, GrowthSystem, EconomySystem, STARTING_GOLD, SaveSystem, TriggerSystem, DialogueSystem, ACTIVE_DIALOGUE_ENTITY_ID, WeatherSystem, WEATHER_ENTITY_ID, PlacementSystem, MovementSystem, UNWALKABLE_TILES, IntentSystem, CraftingSystem } from "./systems/index.js";
 export type {
   System,
   SystemInitCtx,
@@ -52,6 +52,8 @@ export type {
   AnimalFedEvent,
   EvalContext,
   BehaviorRuntimeCtx,
+  CraftSuccessEvent,
+  CraftResult,
 } from "./systems/index.js";
 
 export { findPath, isWalkableTile, findMapEntity } from "./systems/index.js";
@@ -119,6 +121,7 @@ export {
   sceneBuildingEntitySchema,
   sceneNpcEntitySchema,
   sceneAnimalEntitySchema,
+  recipeSchema,
   SAVE_FORMAT,
   CURRENT_SAVE_VERSION,
   worldSnapshotSchema,
@@ -182,6 +185,7 @@ export type {
   SceneBuildingEntity,
   SceneNpcEntity,
   SceneAnimalEntity,
+  RecipeDef,
   WorldSnapshot,
 } from "./schema/index.js";
 

+ 5 - 1
packages/core/src/schema/behavior.ts

@@ -41,6 +41,8 @@ export const actionSchema = z.union([
   z.object({ action: z.literal("give-item"), itemId: z.string().min(1), qty: z.number().int().positive() }),
   z.object({ action: z.literal("play-sound"), sound: z.string().min(1) }),
   z.object({ action: z.literal("dialogue"), text: z.string().min(1) }),
+  z.object({ action: z.literal("play-bgm"), soundId: z.string().min(1), fadeMs: z.number().int().positive().optional() }),
+  z.object({ action: z.literal("stop-bgm"), fadeMs: z.number().int().positive().optional() }),
 ]);
 
 export type TriggerAction =
@@ -49,7 +51,9 @@ export type TriggerAction =
   | { action: "emit-event"; event: string; payload?: Record<string, unknown> | undefined }
   | { action: "give-item"; itemId: string; qty: number }
   | { action: "play-sound"; sound: string }
-  | { action: "dialogue"; text: string };
+  | { action: "dialogue"; text: string }
+  | { action: "play-bgm"; soundId: string; fadeMs?: number | undefined }
+  | { action: "stop-bgm"; fadeMs?: number | undefined };
 
 export const triggerSchema = z.object({
   id: z.string().min(1),

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است