Przeglądaj źródła

D42: playtest D35 panels (E8) + map/scene editor experience (E9) + full-repo bug-fix round

E8 — D35 panels in the playtest: the four @nengine/ui panels (dialogue/shop/
inventory/HudBar) + seed-picker row over the playtest viewport; choose {index}
contract (dev's {choice} was a latent bug), local NPC-tile talk path (dev
lacked one), direct InventorySystem.use, stopPropagation-guarded panel clicks,
real time-state HudBar; @nengine/ui as editor devDependency + vite source alias.

E9 — map/scene editor experience: real-asset tile rendering (assets:list
dataUrls + async Image cache + color fallback; tileAssetId copied to avoid
pixi in the editor), inverse-command undo/redo stacks (no-ops/rejected ops
never enter; dirty = stack non-empty; save = checkpoint), map resize (pad/
truncate + spawn clamp, non-square maps, scene-bounds sync + sceneFitsIn
guard).

Bug-fix round (4 parallel review passes, every finding vitest-reproduced,
+35 regression tests): inventory.use no longer applies effects with an empty
inventory and decrements durable items (removed at 0); economy/crafting/
behavior/planting grant paths route durable items through inventoryInstances;
SystemsHost.emit isolates throwing event handlers (a bad trigger action no
longer freezes the whole tick chain); crop-harvest/animal-produce fire after
the world state updates; scene-editor resize keeps the dirty flag (clearHistory
doesn't touch it) — the resize is always savable; the playtest shop stays open
across buy/sell (lastTile follows interact/talk clicks only); dev-app choose
now sends {index}; authoring content-id path-traversal guard (isSafeContentId)
+ update write-failure registry rollback + patch.id rename rejection;
bgm-play/bgm-stop forwarded by connectLocal/connectWs; playBgm only claims the
slot when a source actually starts (pending decodes cancellable); shop +1/+5
buttons respect numeric stock; sceneFitsAfterResize rejects clamp-overlap
shrinks; compileMods + engine modDirsOf skip dot dirs; WS reconnect timer
cancelled on destroy; gateway.callTool(undefined) returns a clean error;
any-field coercion is lossless ("007" stays a string); watch() polling
compares asset sizes; DialogueBox dead 'closed' event removed; durability
max=0 renders 0% (no Infinity%); hud-bar falls back to raw season/weather ids
(no "undefined"); inventory tooltip follows the pointer; viewport rAF loop
stops when nothing animates; sound.ts GainNode typing (no as any).

Tests 1078 -> 1113 green; all 11 packages build; docs: decisions.md D42
(decisions + E8/E9/bug-fix implementation notes), editor.md E2/E4/E5/F2
sections + roadmap E8/E9 rows.

Co-Authored-By: Claude <noreply@anthropic.com>
nonvioce 2 miesięcy temu
rodzic
commit
41b96201d2
53 zmienionych plików z 2819 dodań i 103 usunięć
  1. 3 1
      apps/dev/src/main.ts
  2. 23 0
      apps/editor/index.html
  3. 1 0
      apps/editor/package.json
  4. 85 0
      apps/editor/src/renderer/asset-images.ts
  5. 4 1
      apps/editor/src/renderer/form-value.ts
  6. 14 0
      apps/editor/src/renderer/main.ts
  7. 127 6
      apps/editor/src/renderer/map-editor.ts
  8. 194 7
      apps/editor/src/renderer/map-model.ts
  9. 114 0
      apps/editor/src/renderer/playtest-hud.ts
  10. 293 0
      apps/editor/src/renderer/playtest-panels.ts
  11. 52 6
      apps/editor/src/renderer/playtest.ts
  12. 146 6
      apps/editor/src/renderer/scene-editor.ts
  13. 177 12
      apps/editor/src/renderer/scene-model.ts
  14. 28 0
      apps/editor/test/asset-images.test.ts
  15. 28 0
      apps/editor/test/form-value.test.ts
  16. 217 0
      apps/editor/test/map-model.test.ts
  17. 105 1
      apps/editor/test/playtest-hud.test.ts
  18. 163 0
      apps/editor/test/scene-model.test.ts
  19. 2 1
      apps/editor/tsconfig.renderer.json
  20. 1 0
      apps/editor/vite.config.ts
  21. 1 0
      apps/pack/src/index.ts
  22. 20 1
      apps/pack/test/pack.test.ts
  23. 0 0
      docs/decisions.md
  24. 6 4
      docs/editor.md
  25. 12 0
      packages/core/src/content/loader.ts
  26. 1 1
      packages/core/src/mods/loader.ts
  27. 12 0
      packages/core/src/systems/behavior-runtime.ts
  28. 13 0
      packages/core/src/systems/crafting.ts
  29. 4 1
      packages/core/src/systems/dialogue.ts
  30. 13 0
      packages/core/src/systems/economy.ts
  31. 3 1
      packages/core/src/systems/growth.ts
  32. 9 1
      packages/core/src/systems/host.ts
  33. 19 5
      packages/core/src/systems/inventory.ts
  34. 17 1
      packages/core/src/systems/planting.ts
  35. 4 1
      packages/core/src/systems/trigger.ts
  36. 326 0
      packages/core/test/bughunt-regressions.test.ts
  37. 33 1
      packages/mcp/src/authoring.ts
  38. 3 2
      packages/mcp/src/gateway.ts
  39. 165 0
      packages/mcp/test/bughunt-regressions.test.ts
  40. 76 26
      packages/render/src/sound.ts
  41. 8 0
      packages/render/src/sync/local.ts
  42. 7 2
      packages/render/src/sync/ws.ts
  43. 20 4
      packages/render/src/viewport.ts
  44. 36 0
      packages/render/test/sound.test.ts
  45. 23 0
      packages/render/test/sync-local.test.ts
  46. 104 0
      packages/render/test/sync-ws.test.ts
  47. 0 1
      packages/ui/src/dialogue-box.ts
  48. 15 0
      packages/ui/src/hud-bar.test.ts
  49. 11 5
      packages/ui/src/hud-bar.ts
  50. 14 0
      packages/ui/src/inventory-panel.test.ts
  51. 14 3
      packages/ui/src/inventory-panel.ts
  52. 49 0
      packages/ui/src/shop-panel.test.ts
  53. 4 2
      packages/ui/src/shop-panel.ts

+ 3 - 1
apps/dev/src/main.ts

@@ -112,7 +112,9 @@ let dialogueState: DialogueState | undefined;
 let invSystem: { use(itemId: string): void } | undefined;
 
 dialogueBox.on('choose', (idx: number) => {
-  sync.intent('choose', { choice: idx });
+  // `{index}` — the engine's IntentSystem reads args["index"]; the previous
+  // `{choice}` key silently failed (dialogue could never advance).
+  sync.intent('choose', { index: idx });
 });
 
 shopPanel.on('buy', (itemId: string, qty: number) => {

+ 23 - 0
apps/editor/index.html

@@ -463,6 +463,21 @@
         background: #2e7d32;
         border-color: #66bb6a;
       }
+      .map-resize {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+      }
+      .map-resize input {
+        width: 52px;
+        padding: 4px 6px;
+        font: inherit;
+        font-size: 12px;
+        background: #101418;
+        color: #e6e9ec;
+        border: 1px solid #2d343d;
+        border-radius: 4px;
+      }
       .map-body {
         display: flex;
         gap: 12px;
@@ -503,6 +518,14 @@
         border-radius: 3px;
         border: 1px solid rgba(0, 0, 0, 0.4);
         flex: none;
+        overflow: hidden;
+      }
+      .swatch-chip img {
+        width: 100%;
+        height: 100%;
+        display: block;
+        object-fit: cover;
+        image-rendering: pixelated;
       }
       .swatch-label {
         font-size: 12px;

+ 1 - 0
apps/editor/package.json

@@ -24,6 +24,7 @@
   },
   "devDependencies": {
     "@nengine/render": "workspace:*",
+    "@nengine/ui": "workspace:*",
     "@types/node": "^22.0.0",
     "electron": "^43.4.0",
     "electron-builder": "^26.0.0",

+ 85 - 0
apps/editor/src/renderer/asset-images.ts

@@ -0,0 +1,85 @@
+import { TILE_SIZE, tileColor } from "./map-model.js";
+import type { EditorAssetInfo } from "../ipc.js";
+
+/**
+ * E9 (D42 #2) — real-asset tile rendering for the map/scene editors. Images
+ * come from the existing `assets:list` IPC (dataUrls, images only) and load
+ * asynchronously as `HTMLImageElement`s; tiles with an asset draw the image,
+ * everything else falls back to the palette color — the viewport's
+ * asset-then-color logic (packages/render) mirrored on the plain 2D canvas.
+ * The editor renderer stays pixi-free.
+ */
+
+/** id → dataUrl for image assets; audio entries (no dataUrl) are skipped.
+ * Pure — node-testable. */
+export function assetDataUrls(assets: EditorAssetInfo[]): Record<string, string> {
+  const out: Record<string, string> = {};
+  for (const asset of assets) {
+    if (asset.kind === "image" && asset.dataUrl !== undefined) out[asset.id] = asset.dataUrl;
+  }
+  return out;
+}
+
+export interface TileImageCache {
+  /** The loaded image for an asset id, or undefined while loading / on error
+   * (callers fall back to the color swatch; a failed image stays failed). */
+  get(assetId: string): HTMLImageElement | undefined;
+  /** Register a callback fired after any image finishes loading (or fails) —
+   * the caller redraws. */
+  onReady(callback: () => void): void;
+}
+
+export function createTileImageCache(dataUrls: Record<string, string>, onReady: () => void): TileImageCache {
+  const images = new Map<string, HTMLImageElement>();
+  const pending = new Set<string>();
+  const readyCallbacks: (() => void)[] = [onReady];
+  return {
+    get(assetId: string): HTMLImageElement | undefined {
+      const cached = images.get(assetId);
+      if (cached !== undefined) return cached;
+      const dataUrl = dataUrls[assetId];
+      if (dataUrl === undefined || pending.has(assetId)) return undefined;
+      pending.add(assetId);
+      const image = new Image();
+      image.onload = () => {
+        images.set(assetId, image);
+        pending.delete(assetId);
+        for (const callback of readyCallbacks) callback();
+      };
+      image.onerror = () => {
+        // A failed image never retries — the tile stays on its color fallback.
+        images.set(assetId, image);
+        pending.delete(assetId);
+        for (const callback of readyCallbacks) callback();
+      };
+      image.src = dataUrl;
+      return undefined;
+    },
+    onReady(callback: () => void): void {
+      readyCallbacks.push(callback);
+    },
+  };
+}
+
+/** Draw one tile at (x, y) in tile pixels: the asset image when one is
+ * mapped+loaded (32×32 drawImage), else the palette color. Shared by the map
+ * and scene editors (E9). */
+export function drawTile(
+  ctx: CanvasRenderingContext2D,
+  cache: TileImageCache,
+  tileId: string,
+  x: number,
+  y: number,
+  tileAssetId: (id: string) => string | undefined,
+): void {
+  const assetId = tileAssetId(tileId);
+  if (assetId !== undefined) {
+    const image = cache.get(assetId);
+    if (image !== undefined && image.complete && image.naturalWidth > 0) {
+      ctx.drawImage(image, x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
+      return;
+    }
+  }
+  ctx.fillStyle = tileColor(tileId);
+  ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
+}

+ 4 - 1
apps/editor/src/renderer/form-value.ts

@@ -276,9 +276,12 @@ function coerceAny(value: unknown): unknown {
   if (typeof value !== "string") return value;
   const trimmed = value.trim();
   if (trimmed === "") return value;
+  // Only coerce when the conversion round-trips EXACTLY — "007", "1.50", "1e3"
+  // and friends stay strings (a save must never silently rewrite untouched
+  // any-field values; "007" → 7 is data corruption).
   if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
     const n = Number(trimmed);
-    if (Number.isFinite(n)) return n;
+    if (Number.isFinite(n) && String(n) === trimmed) return n;
   }
   if (trimmed === "true") return true;
   if (trimmed === "false") return false;

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

@@ -3,6 +3,7 @@ import { buildingSchema } from "@nengine/core-schema";
 import type { ContentKind } from "@nengine/core-schema";
 import type { EditorContentFile, EditorContentListing } from "../ipc.js";
 import type { EditorProjectInfo } from "../ipc.js";
+import { assetDataUrls } from "./asset-images.js";
 import { createMapModel } from "./map-model.js";
 import { mountMapEditor } from "./map-editor.js";
 import { mountFormPanel } from "./form-panel.js";
@@ -44,6 +45,17 @@ function registerFlush(collect: () => PlaytestFlush[]): void {
   flushCollector = collect;
 }
 
+/** asset id → dataUrl for the map/scene editors (E9 real-tile rendering);
+ * empty when the project has no assets or the scan fails (color fallback). */
+async function editorAssets(root: string): Promise<Record<string, string>> {
+  try {
+    const result = await window.nengine.assets.list(root);
+    return assetDataUrls(result.assets);
+  } catch {
+    return {};
+  }
+}
+
 function showScreen(screen: "home" | "editor" | "playtest"): void {
   home.classList.toggle("hidden", screen !== "home");
   editor.classList.toggle("hidden", screen !== "editor");
@@ -335,6 +347,7 @@ async function openMap(file: EditorContentFile): Promise<void> {
     disposeEditor = mountMapEditor(editorPanel, model, {
       root: currentProject.root,
       path: file.path,
+      assets: await editorAssets(currentProject.root),
       onStatus: setStatus,
       onFlush: registerFlush,
     });
@@ -399,6 +412,7 @@ async function openScene(file: EditorContentFile | null): Promise<void> {
       sceneModel,
       buildings,
       animals,
+      assets: await editorAssets(currentProject.root),
       onStatus: setStatus,
       onSaved: () => void renderContent(),
       onFlush: registerFlush,

+ 127 - 6
apps/editor/src/renderer/map-editor.ts

@@ -2,17 +2,25 @@ import { stringify } from "yaml";
 import {
   TILE_SIZE,
   addTileId,
+  canRedo,
+  canUndo,
   erase,
   isWalkableTile,
   markSaved,
   paint,
   pointToTile,
+  redo,
+  resizeMap,
   serialize,
   setSpawn,
   tileColor,
+  tileAssetId,
   toggleWalkable,
+  undo,
 } from "./map-model.js";
 import type { MapModel } from "./map-model.js";
+import { createTileImageCache, drawTile } from "./asset-images.js";
+import type { TileImageCache } from "./asset-images.js";
 import type { PlaytestFlush } from "./playtest-session.js";
 
 export type MapTool = "paint" | "erase" | "spawn" | "walkable";
@@ -20,6 +28,10 @@ export type MapTool = "paint" | "erase" | "spawn" | "walkable";
 export interface MapEditorOptions {
   root: string;
   path: string;
+  /** asset id → dataUrl (from `assets:list`, images only). E9 — tiles with a
+   * mapped asset render the real image; everything else falls back to the
+   * palette color. */
+  assets?: Record<string, string>;
   onStatus: (message: string, isError?: boolean) => void;
   onFlush?: (collect: () => PlaytestFlush[]) => void;
 }
@@ -70,7 +82,41 @@ export function mountMapEditor(container: HTMLElement, model: MapModel, options:
   save.addEventListener("click", () => {
     void saveMap();
   });
-  toolbar.append(title, path, tools, save);
+  const undoButton = document.createElement("button");
+  undoButton.type = "button";
+  undoButton.textContent = "Undo";
+  undoButton.title = "Undo (Ctrl+Z)";
+  undoButton.addEventListener("click", () => {
+    applyUndo();
+  });
+  const redoButton = document.createElement("button");
+  redoButton.type = "button";
+  redoButton.textContent = "Redo";
+  redoButton.title = "Redo (Ctrl+Shift+Z / Ctrl+Y)";
+  redoButton.addEventListener("click", () => {
+    applyRedo();
+  });
+  const resize = document.createElement("div");
+  resize.className = "map-resize";
+  const widthInput = document.createElement("input");
+  widthInput.type = "number";
+  widthInput.min = "1";
+  widthInput.value = String(model.def.width);
+  widthInput.title = "Map width (tiles)";
+  const heightInput = document.createElement("input");
+  heightInput.type = "number";
+  heightInput.min = "1";
+  heightInput.value = String(model.def.height);
+  heightInput.title = "Map height (tiles)";
+  const resizeButton = document.createElement("button");
+  resizeButton.type = "button";
+  resizeButton.textContent = "Resize";
+  resizeButton.title = "Resize the map (pads with the erase tile / truncates; spawn is clamped)";
+  resizeButton.addEventListener("click", () => {
+    applyResize();
+  });
+  resize.append(widthInput, "×", heightInput, resizeButton);
+  toolbar.append(title, path, tools, undoButton, redoButton, resize, save);
   root.appendChild(toolbar);
 
   const body = document.createElement("div");
@@ -104,12 +150,24 @@ export function mountMapEditor(container: HTMLElement, model: MapModel, options:
   const wrap = document.createElement("div");
   wrap.className = "map-canvas-wrap";
   const canvas = document.createElement("canvas");
-  canvas.width = model.def.width * TILE_SIZE;
-  canvas.height = model.def.height * TILE_SIZE;
+  sizeCanvas();
   wrap.appendChild(canvas);
   body.appendChild(wrap);
   root.appendChild(body);
 
+  function sizeCanvas(): void {
+    canvas.width = model.def.width * TILE_SIZE;
+    canvas.height = model.def.height * TILE_SIZE;
+  }
+
+  // E9 — real tile assets: images load asynchronously; once any image is
+  // ready the canvas and palette re-render (the first draw triggers the loads).
+  const imageCache: TileImageCache = createTileImageCache(options.assets ?? {}, () => {
+    if (disposed) return;
+    draw();
+    renderPalette();
+  });
+
   function renderPalette(): void {
     paletteList.replaceChildren();
     for (const id of model.palette) {
@@ -119,7 +177,13 @@ export function mountMapEditor(container: HTMLElement, model: MapModel, options:
       if (id === selectedId && tool === "paint") swatch.classList.add("selected");
       const chip = document.createElement("span");
       chip.className = "swatch-chip";
-      chip.style.background = tileColor(id);
+      const assetId = tileAssetId(model, id);
+      const image = assetId !== undefined ? imageCache.get(assetId) : undefined;
+      if (image !== undefined && image.complete && image.naturalWidth > 0) {
+        chip.replaceChildren(image);
+      } else {
+        chip.style.background = tileColor(id);
+      }
       const label = document.createElement("span");
       label.className = "swatch-label";
       label.textContent = isWalkableTile(model, id) ? id : `${id} (blocked)`;
@@ -157,8 +221,7 @@ export function mountMapEditor(container: HTMLElement, model: MapModel, options:
     for (let y = 0; y < model.def.height; y++) {
       for (let x = 0; x < model.def.width; x++) {
         const tile = data?.[y]?.[x] ?? "";
-        ctx.fillStyle = tileColor(tile);
-        ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
+        drawTile(ctx, imageCache, tile, x, y, (id) => tileAssetId(model, id));
       }
     }
     drawGrid(ctx);
@@ -245,8 +308,65 @@ export function mountMapEditor(container: HTMLElement, model: MapModel, options:
   function updateSave(): void {
     save.textContent = model.dirty ? "Save *" : "Save";
     save.disabled = !model.dirty;
+    undoButton.disabled = !canUndo(model);
+    redoButton.disabled = !canRedo(model);
   }
 
+  function applyUndo(): void {
+    if (!undo(model)) return;
+    sizeCanvas();
+    draw();
+    renderPalette();
+    updateSave();
+  }
+
+  function applyRedo(): void {
+    if (!redo(model)) return;
+    sizeCanvas();
+    draw();
+    renderPalette();
+    updateSave();
+  }
+
+  function applyResize(): void {
+    const width = Number(widthInput.value);
+    const height = Number(heightInput.value);
+    if (!resizeMap(model, width, height)) {
+      options.onStatus("invalid or unchanged dimensions", true);
+      return;
+    }
+    widthInput.value = String(model.def.width);
+    heightInput.value = String(model.def.height);
+    sizeCanvas();
+    draw();
+    renderPalette();
+    updateSave();
+    options.onStatus(`resized to ${model.def.width}×${model.def.height}`);
+  }
+
+  const onKey = (event: KeyboardEvent): void => {
+    const target = event.target;
+    if (
+      target instanceof HTMLInputElement ||
+      target instanceof HTMLTextAreaElement ||
+      (target instanceof HTMLElement && target.isContentEditable)
+    ) {
+      return;
+    }
+    const mod = event.ctrlKey || event.metaKey;
+    if (!mod) return;
+    const key = event.key.toLowerCase();
+    if (key === "z") {
+      event.preventDefault();
+      if (event.shiftKey) applyRedo();
+      else applyUndo();
+    } else if (key === "y") {
+      event.preventDefault();
+      applyRedo();
+    }
+  };
+  window.addEventListener("keydown", onKey);
+
   async function saveMap(): Promise<void> {
     const def = serialize(model);
     if (def === null) {
@@ -304,5 +424,6 @@ export function mountMapEditor(container: HTMLElement, model: MapModel, options:
 
   return () => {
     disposed = true;
+    window.removeEventListener("keydown", onKey);
   };
 }

+ 194 - 7
apps/editor/src/renderer/map-model.ts

@@ -12,6 +12,11 @@ export const DEFAULT_ERASE_TILE = "grass";
  * Strict-typed editor model over a MapDef. The model owns a deep-cloned
  * working copy of the def; grid operations never mutate the def passed to
  * `createMapModel`. Pure logic — node-testable, no DOM.
+ *
+ * E9 (D42 #2) — inverse-command undo/redo: every operation records its inverse
+ * into `history` (no-ops never enter the stack; the palette is additive UI and
+ * does not roll back), and `dirty` IS `history.length > 0` — `markSaved`
+ * clears both stacks (save = checkpoint; no undo across saves).
  */
 export interface MapModel {
   /** Working copy of the map definition (paint/erase/spawn/walkable edits live here). */
@@ -23,14 +28,39 @@ export interface MapModel {
   palette: string[];
   /** True when any serializable edit (paint/erase/spawn/walkable) is unsaved. */
   dirty: boolean;
+  /** Inverse-command stack (most recent last); non-empty ⇒ dirty. */
+  history: MapHistoryEntry[];
+  /** Forward-command stack for redo (cleared on every new op). */
+  future: MapHistoryEntry[];
 }
 
+/** Full-grid snapshot used by the coarse `resize` command. */
+export interface MapGridSnapshot {
+  width: number;
+  height: number;
+  spawn: { x: number; y: number };
+  layers: { name: string; data: string[][] }[];
+}
+
+/** An inverse-recordable edit. Tile/spawn/walkable are per-edit; resize is a
+ * coarse full-grid snapshot (rare op). */
+export type MapHistoryEntry =
+  | { kind: "tile"; x: number; y: number; before: string; after: string }
+  | { kind: "spawn"; before: { x: number; y: number }; after: { x: number; y: number } }
+  | {
+      kind: "walkable";
+      tileId: string;
+      before: Record<string, boolean> | undefined;
+      after: Record<string, boolean> | undefined;
+    }
+  | { kind: "resize"; before: MapGridSnapshot; after: MapGridSnapshot };
+
 export function createMapModel(def: unknown): MapModel | null {
   const result = mapSchema.safeParse(def);
   if (!result.success) return null;
   const model = cloneDef(result.data);
   const groundIndex = Math.max(0, model.layers.findIndex((layer) => layer.name === "ground"));
-  return { def: model, groundIndex, palette: collectIds(model), dirty: false };
+  return { def: model, groundIndex, palette: collectIds(model), dirty: false, history: [], future: [] };
 }
 
 /** Unique tile ids currently on the map (all layers, row-major first-seen order). */
@@ -38,14 +68,16 @@ export function collectTileIds(model: MapModel): string[] {
   return collectIds(model.def);
 }
 
-/** Paint `tileId` onto the ground layer at (x, y). Out-of-bounds is a no-op. */
+/** Paint `tileId` onto the ground layer at (x, y). Out-of-bounds and
+ * same-value paints are no-ops (they never enter the history stack). */
 export function paint(model: MapModel, x: number, y: number, tileId: string): void {
   if (x < 0 || y < 0) return;
   const row = model.def.layers[model.groundIndex]?.data[y];
   if (row === undefined || x >= row.length) return;
   if (row[x] === tileId) return;
+  const before = row[x] ?? "";
   row[x] = tileId;
-  model.dirty = true;
+  recordHistory(model, { kind: "tile", x, y, before, after: tileId });
   if (tileId !== "" && !model.palette.includes(tileId)) model.palette.push(tileId);
 }
 
@@ -56,12 +88,15 @@ export function erase(model: MapModel, x: number, y: number): void {
 }
 
 /** Move the spawn point. Out-of-range coordinates are clamped into the map;
- * non-integer coordinates are rejected (no-op). */
+ * non-integer coordinates and an unchanged spawn are no-ops (no history
+ * entry). */
 export function setSpawn(model: MapModel, x: number, y: number): void {
   if (!Number.isInteger(x) || !Number.isInteger(y)) return;
+  const before = { x: model.def.spawn.x, y: model.def.spawn.y };
   model.def.spawn.x = clampInt(x, 0, model.def.width - 1);
   model.def.spawn.y = clampInt(y, 0, model.def.height - 1);
-  model.dirty = true;
+  if (before.x === model.def.spawn.x && before.y === model.def.spawn.y) return;
+  recordHistory(model, { kind: "spawn", before, after: { x: model.def.spawn.x, y: model.def.spawn.y } });
 }
 
 /**
@@ -74,6 +109,7 @@ export function setSpawn(model: MapModel, x: number, y: number): void {
  */
 export function toggleWalkable(model: MapModel, tileId: string): void {
   if (tileId === "") return;
+  const before = model.def.walkable === undefined ? undefined : { ...model.def.walkable };
   if (model.def.walkable?.[tileId] === false) {
     const walkable = { ...(model.def.walkable ?? {}) };
     delete walkable[tileId];
@@ -85,7 +121,8 @@ export function toggleWalkable(model: MapModel, tileId: string): void {
   } else {
     model.def.walkable = { ...(model.def.walkable ?? {}), [tileId]: false };
   }
-  model.dirty = true;
+  const after = model.def.walkable === undefined ? undefined : { ...model.def.walkable };
+  recordHistory(model, { kind: "walkable", tileId, before, after });
 }
 
 /** Register a palette tile id that may not be on the map yet. No def change. */
@@ -112,11 +149,91 @@ export function serialize(model: MapModel): MapDef | null {
   return cloneDef(result.data);
 }
 
-/** Clear the unsaved-changes flag (call after a successful save). */
+/** Clear the unsaved-changes flag (call after a successful save). Saving is a
+ * checkpoint: both history stacks are cleared, so edits are not undoable
+ * across a save (E9, documented). */
 export function markSaved(model: MapModel): void {
+  model.history = [];
+  model.future = [];
   model.dirty = false;
 }
 
+/** Undo the most recent edit. Returns false when the stack is empty. */
+export function undo(model: MapModel): boolean {
+  const entry = model.history.pop();
+  if (entry === undefined) return false;
+  applyInverse(model, entry);
+  model.future.push(entry);
+  model.dirty = model.history.length > 0;
+  return true;
+}
+
+/** Redo the most recently undone edit. Returns false when there is nothing to
+ * redo (or a new op cleared the redo stack). */
+export function redo(model: MapModel): boolean {
+  const entry = model.future.pop();
+  if (entry === undefined) return false;
+  applyForward(model, entry);
+  model.history.push(entry);
+  model.dirty = model.history.length > 0;
+  return true;
+}
+
+export function canUndo(model: MapModel): boolean {
+  return model.history.length > 0;
+}
+
+export function canRedo(model: MapModel): boolean {
+  return model.future.length > 0;
+}
+
+/** Drop both stacks without touching the def or the dirty flag (the scene
+ * editor's resize path uses this — a coarse cross-model op is not undoable,
+ * but the resize itself IS an unsaved edit and must stay dirty). */
+export function clearHistory(model: MapModel): void {
+  model.history = [];
+  model.future = [];
+}
+
+/** Resize the map (E9): `width`/`height` + every layer (pad with the erase
+ * tile / truncate) + the spawn is clamped into the new bounds (the schema does
+ * not reject an out-of-range spawn, and the engine seeds the player there).
+ * Non-positive/non-integer dimensions and an unchanged size are rejected
+ * (returns false, def untouched). Non-square maps are supported (the schema
+ * allows independent width/height). The op records a full-grid snapshot and is
+ * undoable like any other edit. */
+export function resizeMap(model: MapModel, width: number, height: number): boolean {
+  if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return false;
+  if (width === model.def.width && height === model.def.height) return false;
+  const before = gridSnapshot(model);
+  const fill = eraseTileId(model);
+  model.def.width = width;
+  model.def.height = height;
+  for (const layer of model.def.layers) {
+    layer.data = layer.data.slice(0, height);
+    for (const row of layer.data) {
+      if (row.length > width) {
+        row.length = width;
+      } else {
+        while (row.length < width) row.push(fill);
+      }
+    }
+    while (layer.data.length < height) layer.data.push(new Array<string>(width).fill(fill));
+  }
+  model.def.spawn.x = clampInt(model.def.spawn.x, 0, width - 1);
+  model.def.spawn.y = clampInt(model.def.spawn.y, 0, height - 1);
+  recordHistory(model, { kind: "resize", before, after: gridSnapshot(model) });
+  return true;
+}
+
+/** The map `tiles` record value for a tile id (tile id → asset id, D31). Copied
+ * from `@nengine/render`'s `tiles.ts` rather than imported — the render barrel
+ * pulls pixi into the editor renderer, which is deliberately pixi-free (E9,
+ * D42 #2). */
+export function tileAssetId(model: MapModel, tileId: string): string | undefined {
+  return model.def.tiles?.[tileId];
+}
+
 /**
  * Editor canvas tile color. grass/tilled/water keep the render package's
  * TILE_COLORS values for visual continuity; every other tile id gets a stable
@@ -149,6 +266,76 @@ function eraseTileId(model: MapModel): string {
   return model.palette[0] ?? "";
 }
 
+/** Push an inverse-recordable edit: new ops clear the redo stack, and the
+ * dirty flag follows the stack (dirty = history non-empty). */
+function recordHistory(model: MapModel, entry: MapHistoryEntry): void {
+  model.history.push(entry);
+  model.future = [];
+  model.dirty = true;
+}
+
+function applyInverse(model: MapModel, entry: MapHistoryEntry): void {
+  switch (entry.kind) {
+    case "tile": {
+      const row = model.def.layers[model.groundIndex]?.data[entry.y];
+      if (row !== undefined && row[entry.x] !== undefined) row[entry.x] = entry.before;
+      break;
+    }
+    case "spawn":
+      model.def.spawn = { x: entry.before.x, y: entry.before.y };
+      break;
+    case "walkable":
+      setWalkableRecord(model, entry.before);
+      break;
+    case "resize":
+      applyGridSnapshot(model, entry.before);
+      break;
+  }
+}
+
+function applyForward(model: MapModel, entry: MapHistoryEntry): void {
+  switch (entry.kind) {
+    case "tile": {
+      const row = model.def.layers[model.groundIndex]?.data[entry.y];
+      if (row !== undefined && row[entry.x] !== undefined) row[entry.x] = entry.after;
+      break;
+    }
+    case "spawn":
+      model.def.spawn = { x: entry.after.x, y: entry.after.y };
+      break;
+    case "walkable":
+      setWalkableRecord(model, entry.after);
+      break;
+    case "resize":
+      applyGridSnapshot(model, entry.after);
+      break;
+  }
+}
+
+function setWalkableRecord(model: MapModel, record: Record<string, boolean> | undefined): void {
+  if (record === undefined || Object.keys(record).length === 0) {
+    delete model.def.walkable;
+  } else {
+    model.def.walkable = { ...record };
+  }
+}
+
+function gridSnapshot(model: MapModel): MapGridSnapshot {
+  return {
+    width: model.def.width,
+    height: model.def.height,
+    spawn: { x: model.def.spawn.x, y: model.def.spawn.y },
+    layers: model.def.layers.map((layer) => ({ name: layer.name, data: layer.data.map((row) => [...row]) })),
+  };
+}
+
+function applyGridSnapshot(model: MapModel, snapshot: MapGridSnapshot): void {
+  model.def.width = snapshot.width;
+  model.def.height = snapshot.height;
+  model.def.spawn = { x: snapshot.spawn.x, y: snapshot.spawn.y };
+  model.def.layers = snapshot.layers.map((layer) => ({ name: layer.name, data: layer.data.map((row) => [...row]) }));
+}
+
 function collectIds(def: MapDef): string[] {
   const ids: string[] = [];
   for (const layer of def.layers) {

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

@@ -1,3 +1,5 @@
+import type { DialogueState, ItemDef, ShopDef } from "@nengine/core";
+
 export interface PlayerStats {
   inventory: Record<string, number>;
   gold: number;
@@ -32,6 +34,118 @@ export function inventorySummary(stats: PlayerStats): string {
   return items.length > 0 ? items : "items: —";
 }
 
+export interface SeedOption {
+  id: string;
+  name: string;
+  qty: number;
+}
+
+/** Seed picker options from a snapshot world. With an item catalog (local
+ * mode) only `seed`-kind items qualify; without one (ws mode — the snapshot
+ * carries no item defs) every inventory item is offered and the engine
+ * validates on select-seed. (dev-app ui.ts parity, D35) */
+export function seedListFromSnapshot(world: unknown[], items?: ItemDef[]): SeedOption[] {
+  const stats = playerStatsFromSnapshot(world);
+  const out: SeedOption[] = [];
+  for (const [id, qty] of Object.entries(stats.inventory)) {
+    const def = items?.find((item) => item.id === id);
+    if (items !== undefined) {
+      if (def?.kind !== "seed") continue;
+      out.push({ id, name: def.name, qty });
+    } else {
+      out.push({ id, name: id, qty });
+    }
+  }
+  return out;
+}
+
+export interface ShopRow {
+  itemId: string;
+  name: string;
+  price: number;
+  stock: number | null;
+  owned: number;
+}
+
+export interface ShopRows {
+  shopId: string;
+  buyRows: ShopRow[];
+  sellRows: ShopRow[];
+}
+
+/** Shop panel rows from a shop def (dev-app ui.ts parity, D35). Viewpoint
+ * convention: `buys` = what the shop sells to the player (buyRows), `sells` =
+ * what the shop buys from the player (sellRows). */
+export function shopRowsFromDef(
+  shop: ShopDef,
+  inventory: Record<string, number> | undefined,
+  items?: ItemDef[],
+): ShopRows {
+  const inv: Record<string, number> = inventory ?? {};
+  const nameOf = (itemId: string): string => {
+    const def = items?.find((item) => item.id === itemId);
+    return def?.name ?? itemId;
+  };
+  return {
+    shopId: shop.id,
+    buyRows: shop.buys.map((line) => ({
+      itemId: line.itemId,
+      name: nameOf(line.itemId),
+      price: line.price,
+      stock: null,
+      owned: inv[line.itemId] ?? 0,
+    })),
+    sellRows: shop.sells.map((line) => ({
+      itemId: line.itemId,
+      name: nameOf(line.itemId),
+      price: line.price,
+      stock: line.stock ?? null,
+      owned: inv[line.itemId] ?? 0,
+    })),
+  };
+}
+
+export interface DialogueView {
+  text: string;
+  choices: string[];
+}
+
+export function dialogueChoicesFromNode(state: DialogueState | undefined): DialogueView {
+  if (!state) return { text: "", choices: [] };
+  return { text: state.text, choices: state.choices.map((choice) => choice.text) };
+}
+
+export interface BuildingShop {
+  buildingId: string;
+  shopId: string;
+}
+
+/** The shop a tile falls on, from a snapshot world: the first building entity
+ * with a `shop` field whose footprint contains the tile (dev-app ui.ts parity,
+ * D35). */
+export function buildingShopAt(world: unknown[], tile: { x: number; y: number }): BuildingShop | undefined {
+  for (const entity of world) {
+    if (!isRecord(entity) || entity["type"] !== "building") continue;
+    const shop = entity["shop"];
+    if (typeof shop !== "string" || shop.length === 0) continue;
+    const x = entity["x"];
+    const y = entity["y"];
+    const w = entity["w"];
+    const h = entity["h"];
+    if (typeof x !== "number" || typeof y !== "number" || typeof w !== "number" || typeof h !== "number") continue;
+    if (tile.x >= x && tile.x < x + w && tile.y >= y && tile.y < y + h) {
+      return { buildingId: String(entity["id"]), shopId: shop };
+    }
+  }
+  return undefined;
+}
+
+/** Whether a dialogue is active: the `dialogue:active` sentinel entity is in
+ * the world (dev-app ui.ts parity, D35). */
+export function dialogueActiveInWorld(world: unknown[]): boolean {
+  return world.some((entity) => isRecord(entity) && entity["id"] === "dialogue:active");
+}
+
 function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === "object" && value !== null && !Array.isArray(value);
 }

+ 293 - 0
apps/editor/src/renderer/playtest-panels.ts

@@ -0,0 +1,293 @@
+import type { DialogueState, Engine, InventorySystem, ItemDef, ItemKind, ShopDef, TimeSystem } from "@nengine/core";
+import { DialogueBox, HudBar, InventoryPanel, ShopPanel } from "@nengine/ui";
+import type {
+  DialogueState as NDialogueState,
+  HudBarState,
+  InventoryPanelState,
+  ItemCategory,
+  Season,
+  ShopPanelState,
+} from "@nengine/ui";
+import {
+  buildingShopAt,
+  dialogueActiveInWorld,
+  dialogueChoicesFromNode,
+  playerStatsFromSnapshot,
+  seedListFromSnapshot,
+  shopRowsFromDef,
+} from "./playtest-hud.js";
+
+/**
+ * E8 — the D35 interaction panels (packages/ui) wired onto the playtest
+ * viewport (D42 #1). Thin DOM glue over the engine + local-sync intent: the
+ * four panels (DialogueBox / ShopPanel / InventoryPanel / HudBar) and a seed
+ * picker row mount as absolutely-positioned overlays inside the playtest
+ * container; data flows world-snapshot → pure helpers (playtest-hud.ts) →
+ * panel `update()`; panel events flow back through the injected `intent`
+ * (localSync.intent) or direct system calls. Every overlay click is
+ * stopPropagation-guarded so panel interactions never re-trigger the game's
+ * click-to-interact handler.
+ *
+ * Contracts (D42 #1): `choose` sends `{index}` — the engine's IntentSystem
+ * reads `args["index"]` (the dev-app's `{choice}` key is a latent bug, NOT
+ * ported); inventory `use` calls InventorySystem.use DIRECTLY (the playtest
+ * engine lives in this process — dev's "Use via MCP only" stub is moot);
+ * HudBar takes the REAL time system state (dev hardcodes day 1/spring).
+ */
+export interface PlaytestPanelsOptions {
+  engine: Engine;
+  /** Sends an intent to the local-sync handle (action → engine). */
+  intent: (action: string, args: Record<string, unknown>) => void;
+  /** Status-bar feedback (normal / error). */
+  onStatus: (message: string, isError?: boolean) => void;
+}
+
+export interface PlaytestPanels {
+  /** Full re-render from the world snapshot; `payload` is the latest
+   * `player-intent` IntentResult (drives dialogue/shop state). */
+  refresh(payload?: unknown): void;
+  /** Show/hide the inventory overlay (I key). */
+  toggleInventory(): void;
+  /** Unmount all panels and remove the overlay elements. */
+  dispose(): void;
+}
+
+const CATEGORY_MAP: Partial<Record<ItemKind, ItemCategory>> = {
+  tool: "tool",
+  seed: "seed",
+  crop: "crop",
+  good: "good",
+};
+
+let styleInjected = false;
+
+function injectStyles(): void {
+  if (styleInjected) return;
+  styleInjected = true;
+  const style = document.createElement("style");
+  style.textContent = `
+    .playtest-hudbar { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); z-index: 10; pointer-events: none; }
+    .playtest-seeds { position: absolute; bottom: 8px; right: 8px; z-index: 10; display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+    .playtest-seeds button { font: inherit; font-size: 12px; padding: 4px 10px; border: 1px solid #555; border-radius: 4px; background: #263238; color: #e6e9ec; cursor: pointer; }
+    .playtest-seeds button.selected { border-color: #a5d6a7; background: #1b5e20; }
+    .playtest-dialogue { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 20; width: 460px; max-width: 90%; }
+    .playtest-shop { position: absolute; top: 8px; right: 8px; z-index: 20; max-height: calc(100% - 16px); overflow-y: auto; }
+    .playtest-inventory { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 20; max-height: 90%; overflow-y: auto; }
+    .playtest-inventory-close { display: block; margin: 0 auto 8px; font: inherit; padding: 4px 12px; border: 1px solid #555; border-radius: 4px; background: #37474f; color: #eceff1; cursor: pointer; }
+  `;
+  document.head.appendChild(style);
+}
+
+export function mountPlaytestPanels(container: HTMLElement, opts: PlaytestPanelsOptions): PlaytestPanels {
+  injectStyles();
+
+  const hudBarEl = document.createElement("div");
+  hudBarEl.className = "playtest-hudbar";
+  const seedsEl = document.createElement("div");
+  seedsEl.className = "playtest-seeds";
+  const dialogueEl = document.createElement("div");
+  dialogueEl.className = "playtest-dialogue";
+  dialogueEl.style.display = "none";
+  const shopEl = document.createElement("div");
+  shopEl.className = "playtest-shop";
+  shopEl.style.display = "none";
+  const inventoryEl = document.createElement("div");
+  inventoryEl.className = "playtest-inventory";
+  inventoryEl.style.display = "none";
+
+  // Panel clicks must never reach the game's click-to-interact handler.
+  for (const el of [hudBarEl, seedsEl, dialogueEl, shopEl, inventoryEl]) {
+    el.addEventListener("click", (event) => event.stopPropagation());
+  }
+  container.append(hudBarEl, seedsEl, dialogueEl, shopEl, inventoryEl);
+
+  const dialogueBox = new DialogueBox();
+  dialogueBox.mount(dialogueEl);
+  const shopPanel = new ShopPanel();
+  shopPanel.mount(shopEl);
+  const hudBar = new HudBar();
+  hudBar.mount(hudBarEl);
+  const inventoryPanel = new InventoryPanel();
+  inventoryPanel.mount(inventoryEl);
+
+  const closeInventoryBtn = document.createElement("button");
+  closeInventoryBtn.type = "button";
+  closeInventoryBtn.className = "playtest-inventory-close";
+  closeInventoryBtn.textContent = "Close Inventory";
+  closeInventoryBtn.addEventListener("click", () => {
+    inventoryEl.style.display = "none";
+  });
+  inventoryEl.insertBefore(closeInventoryBtn, inventoryEl.firstChild);
+
+  let dialogueState: DialogueState | undefined;
+  let activeShop: { shopId: string } | undefined;
+  let lastTile: { x: number; y: number } | undefined;
+
+  const statsOf = (snapshot: unknown[]): ReturnType<typeof playerStatsFromSnapshot> =>
+    playerStatsFromSnapshot(snapshot);
+
+  const renderSeeds = (snapshot: unknown[], stats: ReturnType<typeof playerStatsFromSnapshot>, items: ItemDef[]): void => {
+    seedsEl.replaceChildren();
+    for (const option of seedListFromSnapshot(snapshot, items)) {
+      const button = document.createElement("button");
+      button.type = "button";
+      button.textContent = `${option.name} ${option.qty}`;
+      button.title = `Select ${option.name} as the sow seed`;
+      button.classList.toggle("selected", stats.selectedSeed === option.id);
+      button.addEventListener("click", () => {
+        opts.intent("select-seed", { seedItemId: option.id });
+      });
+      seedsEl.appendChild(button);
+    }
+  };
+
+  const renderDialogue = (snapshot: unknown[]): void => {
+    const active = dialogueActiveInWorld(snapshot);
+    dialogueEl.style.display = active ? "" : "none";
+    if (!active) return;
+    const node = dialogueChoicesFromNode(dialogueState);
+    dialogueBox.update({
+      text: node.text.length > 0 ? node.text : "…",
+      choices: node.choices.map((text) => ({ text })),
+      typingSpeedMs: 20,
+    } as NDialogueState);
+  };
+
+  const renderShop = (snapshot: unknown[], stats: ReturnType<typeof playerStatsFromSnapshot>): void => {
+    const shop = activeShop ? opts.engine.registry.get<ShopDef>("shop", activeShop.shopId) : undefined;
+    if (!shop) return;
+    const rows = shopRowsFromDef(shop, stats.inventory, opts.engine.registry.list<ItemDef>("item"));
+    shopPanel.update({
+      shopId: shop.id,
+      shopName: `Shop: ${shop.id}`,
+      sells: rows.sellRows,
+      buys: rows.buyRows,
+      playerGold: stats.gold,
+    } as ShopPanelState);
+  };
+
+  const renderInventory = (): void => {
+    const snapshot = opts.engine.world.snapshot();
+    const items = opts.engine.registry.list<ItemDef>("item");
+    const stats = statsOf(snapshot);
+    const rows = Object.entries(stats.inventory).map(([id, qty]) => {
+      const def = items.find((item) => item.id === id);
+      const category = def ? CATEGORY_MAP[def.kind] : undefined;
+      return {
+        itemId: id,
+        name: def?.name ?? id,
+        qty,
+        category,
+        consumable: def?.kind === "crop" || def?.kind === "seed",
+      };
+    });
+    inventoryPanel.update({ items: rows, activeCategory: "all" } as InventoryPanelState);
+  };
+
+  const refresh = (payload?: unknown): void => {
+    // Dialogue state follows talk/choose intent results (dev onIntentResult parity).
+    if (isRecord(payload)) {
+      const p = payload as { action?: unknown; ok?: unknown; detail?: unknown };
+      if (typeof p.action === "string" && (p.action === "talk" || p.action === "choose") && p.ok === true) {
+        dialogueState = isDialogueState(p.detail) ? p.detail : undefined;
+      }
+      // The shop follows the last CLICK tile. Only interact/talk intents carry
+      // the clicked tile — buy/sell carry the PLAYER's tile (intent.ts), which
+      // must never close the shop mid-transaction.
+      if (typeof p.action === "string" && (p.action === "interact" || p.action === "talk")) {
+        const tile = p2tile(payload);
+        if (tile !== undefined) lastTile = tile;
+      }
+    }
+
+    const snapshot = opts.engine.world.snapshot();
+    const stats = statsOf(snapshot);
+
+    // Shop opens when the last click hit a shop building footprint (dev parity).
+    const shop = lastTile !== undefined ? buildingShopAt(snapshot, lastTile) : undefined;
+    activeShop = shop ? { shopId: shop.shopId } : undefined;
+    shopEl.style.display = activeShop ? "" : "none";
+    if (activeShop) renderShop(snapshot, stats);
+
+    renderSeeds(snapshot, stats, opts.engine.registry.list<ItemDef>("item"));
+    renderDialogue(snapshot);
+
+    const time = opts.engine.systems.get<TimeSystem>("time")?.get();
+    hudBar.update({
+      day: time?.day ?? 1,
+      season: (time?.season ?? "spring") as Season,
+      gold: stats.gold,
+      stamina: { cur: stats.stamina, max: 100 },
+      selectedSeed: stats.selectedSeed,
+    } as HudBarState);
+
+    if (inventoryEl.style.display !== "none") renderInventory();
+  };
+
+  const toggleInventory = (): void => {
+    const visible = inventoryEl.style.display !== "none";
+    inventoryEl.style.display = visible ? "none" : "";
+    if (!visible) renderInventory();
+  };
+
+  dialogueBox.on("choose", (index: number) => {
+    opts.intent("choose", { index });
+  });
+
+  shopPanel.on("buy", (itemId: string, qty: number) => {
+    if (activeShop) opts.intent("buy", { shopId: activeShop.shopId, itemId, qty });
+  });
+  shopPanel.on("sell", (itemId: string, qty: number) => {
+    if (activeShop) opts.intent("sell", { shopId: activeShop.shopId, itemId, qty });
+  });
+  shopPanel.on("close", () => {
+    activeShop = undefined;
+    shopEl.style.display = "none";
+  });
+
+  inventoryPanel.on("use", (itemId: string) => {
+    try {
+      opts.engine.systems.get<InventorySystem>("inventory")?.use(itemId);
+      opts.onStatus(`used ${itemId}`);
+      refresh();
+    } catch (err) {
+      opts.onStatus(err instanceof Error ? err.message : String(err), true);
+    }
+  });
+
+  return {
+    refresh,
+    toggleInventory,
+    dispose: () => {
+      dialogueBox.unmount();
+      shopPanel.unmount();
+      hudBar.unmount();
+      inventoryPanel.unmount();
+      for (const el of [hudBarEl, seedsEl, dialogueEl, shopEl, inventoryEl]) el.remove();
+    },
+  };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function p2tile(payload: Record<string, unknown>): { x: number; y: number } | undefined {
+  const tile = payload["tile"];
+  if (!isRecord(tile)) return undefined;
+  const x = tile["x"];
+  const y = tile["y"];
+  if (typeof x !== "number" || typeof y !== "number") return undefined;
+  return { x, y };
+}
+
+function isDialogueState(value: unknown): value is DialogueState {
+  if (!isRecord(value)) return false;
+  const state = value as { dialogueId?: unknown; nodeId?: unknown; text?: unknown; choices?: unknown };
+  return (
+    typeof state.dialogueId === "string" &&
+    typeof state.nodeId === "string" &&
+    typeof state.text === "string" &&
+    Array.isArray(state.choices)
+  );
+}

+ 52 - 6
apps/editor/src/renderer/playtest.ts

@@ -5,6 +5,8 @@ import type { LocalSyncHandle, SoundPlayer, ViewportHandle } from "@nengine/rend
 import { createPlaytestSession, keydownInput } from "./playtest-session.js";
 import type { PlaytestFlush, PlaytestSession, PlaytestStartResult } from "./playtest-session.js";
 import { inventorySummary, playerStatsFromSnapshot } from "./playtest-hud.js";
+import { mountPlaytestPanels } from "./playtest-panels.js";
+import type { PlaytestPanels } from "./playtest-panels.js";
 
 export interface PlaytestStartOptions {
   root: string;
@@ -16,6 +18,7 @@ export interface PlaytestState {
   viewport: ViewportHandle;
   localSync: LocalSyncHandle;
   sound: SoundPlayer;
+  panels: PlaytestPanels;
   removeListeners: (() => void)[];
 }
 
@@ -68,7 +71,7 @@ export async function startPlaytest(opts: PlaytestStartOptions): Promise<Playtes
     onPhase: (phase) => {
       statusEl.textContent = PHASE_LABELS[phase] ?? "";
       exitButton.disabled = phase !== "playing";
-      feedbackEl.textContent = phase === "playing" ? "playtest running — arrow keys to move, click to interact" : "";
+      feedbackEl.textContent = phase === "playing" ? "playtest running — arrow keys to move, click to interact/talk, I to open inventory" : "";
     },
   });
   return await session.start();
@@ -95,9 +98,21 @@ async function bootEngine(bundle: ContentBundle): Promise<PlaytestState> {
     localSync.setTimeLabel((label) => {
       timeEl.textContent = label;
     });
-    removeListeners.push(engine.events.on("player-intent", () => refreshHud(engine)));
+    const panels = mountPlaytestPanels(gameEl, {
+      engine,
+      intent: (action, args) => localSync.intent(action, args),
+      onStatus: showFeedback,
+    });
+    removeListeners.push(
+      engine.events.on("player-intent", (payload) => {
+        refreshHud(engine);
+        panels.refresh(payload);
+        showIntentFeedback(payload);
+      }),
+    );
     refreshHud(engine);
-    return { engine, viewport, localSync, sound, removeListeners };
+    panels.refresh();
+    return { engine, viewport, localSync, sound, panels, removeListeners };
   } catch (err) {
     viewport?.destroy();
     await engine.stop();
@@ -120,18 +135,24 @@ function bindInput(
   const onClick = (event: MouseEvent): void => {
     const tile = viewport.tileAt(event.clientX, event.clientY);
     if (tile) {
-      localSync.intent("interact", { tile });
-      refreshHud(engine);
+      // E8 (D42 #1): a tile holding an NPC sends `talk` (the editor's local
+      // talk path the dev-app lacks); anything else interacts. An NPC tile
+      // never reaches `interact` — documented.
+      const npc = engine.world.query({ type: "npc" }).find((entity) => entity.x === tile.x && entity.y === tile.y);
+      localSync.intent(npc !== undefined ? "talk" : "interact", { tile });
     }
   };
   gameEl.addEventListener("click", onClick);
   removers.push(() => gameEl.removeEventListener("click", onClick));
   const onKey = (event: KeyboardEvent): void => {
     sound.prime();
+    if (event.key === "i" || event.key === "I") {
+      state?.panels.toggleInventory();
+      return;
+    }
     const input = keydownInput(event.key);
     if (input) {
       localSync.input(input);
-      refreshHud(engine);
     }
   };
   window.addEventListener("keydown", onKey);
@@ -144,12 +165,37 @@ function refreshHud(engine: Engine): void {
   statsEl.textContent = `gold ${stats.gold} · stamina ${stats.stamina} · ${inventorySummary(stats)}`;
 }
 
+let feedbackTimer: number | undefined;
+
+function showFeedback(message: string, isError: boolean = false): void {
+  feedbackEl.textContent = message;
+  feedbackEl.style.color = isError ? "#ef5350" : "#8bc34a";
+  if (feedbackTimer !== undefined) window.clearTimeout(feedbackTimer);
+  feedbackTimer = window.setTimeout(() => {
+    feedbackEl.textContent = "";
+  }, 3000);
+}
+
+function showIntentFeedback(payload: unknown): void {
+  if (!payload || typeof payload !== "object") return;
+  const p = payload as { ok?: unknown; detail?: unknown; error?: unknown; action?: unknown };
+  const action = typeof p.action === "string" ? p.action : "intent";
+  if (p.ok === true) {
+    const detail = typeof p.detail === "string" ? p.detail : `${action} ok`;
+    showFeedback(detail);
+  } else {
+    showFeedback(typeof p.error === "string" ? p.error : `${action} failed`, true);
+  }
+}
+
 async function teardownEngine(): Promise<void> {
   if (state === null) return;
   const current = state;
   state = null;
   current.localSync.destroy();
   for (const remove of current.removeListeners) remove();
+  // Panels read engine systems — dispose before the engine stops.
+  current.panels.dispose();
   current.viewport.destroy();
   await current.engine.stop();
   gameEl.replaceChildren();

+ 146 - 6
apps/editor/src/renderer/scene-editor.ts

@@ -3,15 +3,22 @@ import type { SceneEntity } from "@nengine/core-schema";
 import {
   TILE_SIZE,
   addTileId,
+  canRedo as canRedoMap,
+  canUndo as canUndoMap,
+  clearHistory as clearMapHistory,
   erase,
   isWalkableTile,
   markSaved as markMapSaved,
   paint,
   pointToTile,
+  redo as redoMap,
+  resizeMap,
   serialize as serializeMap,
   setSpawn,
+  tileAssetId,
   tileColor,
   toggleWalkable,
+  undo as undoMap,
 } from "./map-model.js";
 import type { MapModel } from "./map-model.js";
 import {
@@ -19,16 +26,25 @@ import {
   addAnimal,
   addBuilding,
   addNpc,
+  canRedo as canRedoScene,
+  canUndo as canUndoScene,
+  clearHistory as clearSceneHistory,
   describeEntity,
   entityColor,
   entityFootprint,
   entityIndexAt,
   markSaved as markSceneSaved,
   moveEntity,
+  redo as redoScene,
   removeEntity,
+  sceneFitsAfterResize,
   serialize as serializeScene,
+  syncMapSize,
+  undo as undoScene,
 } from "./scene-model.js";
 import type { SceneOpResult, SceneFootprint, SceneModel } from "./scene-model.js";
+import { createTileImageCache, drawTile } from "./asset-images.js";
+import type { TileImageCache } from "./asset-images.js";
 import type { PlaytestFlush } from "./playtest-session.js";
 
 export interface SceneBuildingPaletteEntry {
@@ -47,6 +63,9 @@ export interface SceneEditorOptions {
   buildings: SceneBuildingPaletteEntry[];
   /** Animal ids from the project's animal defs. */
   animals: string[];
+  /** asset id → dataUrl (from `assets:list`, images only). E9 — map tiles
+   * with a mapped asset render the real image in Edit Map mode. */
+  assets?: Record<string, string>;
   onStatus: (message: string, isError?: boolean) => void;
   onSaved?: () => void;
   onFlush?: (collect: () => PlaytestFlush[]) => void;
@@ -100,7 +119,35 @@ export function mountSceneEditor(container: HTMLElement, options: SceneEditorOpt
   save.type = "button";
   save.className = "primary";
   save.addEventListener("click", () => void saveAll());
-  toolbar.append(title, path, modeToggle, save);
+  const undoButton = document.createElement("button");
+  undoButton.type = "button";
+  undoButton.textContent = "Undo";
+  undoButton.title = "Undo the active mode's last edit (Ctrl+Z)";
+  undoButton.addEventListener("click", () => applyUndo());
+  const redoButton = document.createElement("button");
+  redoButton.type = "button";
+  redoButton.textContent = "Redo";
+  redoButton.title = "Redo the active mode's last undone edit (Ctrl+Shift+Z / Ctrl+Y)";
+  redoButton.addEventListener("click", () => applyRedo());
+  const resize = document.createElement("div");
+  resize.className = "map-resize";
+  const widthInput = document.createElement("input");
+  widthInput.type = "number";
+  widthInput.min = "1";
+  widthInput.value = String(options.mapModel.def.width);
+  widthInput.title = "Map width (tiles)";
+  const heightInput = document.createElement("input");
+  heightInput.type = "number";
+  heightInput.min = "1";
+  heightInput.value = String(options.mapModel.def.height);
+  heightInput.title = "Map height (tiles)";
+  const resizeButton = document.createElement("button");
+  resizeButton.type = "button";
+  resizeButton.textContent = "Resize";
+  resizeButton.title = "Resize the map (scene entities are clamped; a building that cannot fit rejects the resize; undo history is cleared)";
+  resizeButton.addEventListener("click", () => applyResize());
+  resize.append(widthInput, "×", heightInput, resizeButton);
+  toolbar.append(title, path, modeToggle, undoButton, redoButton, resize, save);
   root.appendChild(toolbar);
 
   const body = document.createElement("div");
@@ -116,11 +163,23 @@ export function mountSceneEditor(container: HTMLElement, options: SceneEditorOpt
   const wrap = document.createElement("div");
   wrap.className = "map-canvas-wrap";
   const canvas = document.createElement("canvas");
-  canvas.width = options.mapModel.def.width * TILE_SIZE;
-  canvas.height = options.mapModel.def.height * TILE_SIZE;
+  sizeCanvas();
   wrap.appendChild(canvas);
   main.appendChild(wrap);
 
+  function sizeCanvas(): void {
+    canvas.width = options.mapModel.def.width * TILE_SIZE;
+    canvas.height = options.mapModel.def.height * TILE_SIZE;
+  }
+
+  // E9 — real tile assets for Edit Map mode: images load asynchronously; once
+  // any image is ready the canvas and palette re-render.
+  const imageCache: TileImageCache = createTileImageCache(options.assets ?? {}, () => {
+    if (disposed) return;
+    draw();
+    renderPalette();
+  });
+
   const entityList = document.createElement("div");
   entityList.className = "scene-entity-list";
   const listTitle = document.createElement("h4");
@@ -140,6 +199,7 @@ export function mountSceneEditor(container: HTMLElement, options: SceneEditorOpt
     for (const [value, button] of modeButtons) button.classList.toggle("selected", value === next);
     renderPalette();
     draw();
+    updateSave();
   }
 
   function setArm(placement: NonNullable<ActivePlacement>): void {
@@ -275,7 +335,13 @@ export function mountSceneEditor(container: HTMLElement, options: SceneEditorOpt
       if (id === mapSelectedId && mapTool === "paint") swatch.classList.add("selected");
       const chip = document.createElement("span");
       chip.className = "swatch-chip";
-      chip.style.background = tileColor(id);
+      const assetId = tileAssetId(options.mapModel, id);
+      const image = assetId !== undefined ? imageCache.get(assetId) : undefined;
+      if (image !== undefined && image.complete && image.naturalWidth > 0) {
+        chip.replaceChildren(image);
+      } else {
+        chip.style.background = tileColor(id);
+      }
       const label = document.createElement("span");
       label.className = "swatch-label";
       label.textContent = isWalkableTile(options.mapModel, id) ? id : `${id} (blocked)`;
@@ -383,8 +449,7 @@ export function mountSceneEditor(container: HTMLElement, options: SceneEditorOpt
     for (let y = 0; y < options.mapModel.def.height; y++) {
       for (let x = 0; x < options.mapModel.def.width; x++) {
         const tile = data?.[y]?.[x] ?? "";
-        ctx.fillStyle = tileColor(tile);
-        ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
+        drawTile(ctx, imageCache, tile, x, y, (id) => tileAssetId(options.mapModel, id));
       }
     }
   }
@@ -523,8 +588,82 @@ export function mountSceneEditor(container: HTMLElement, options: SceneEditorOpt
     const dirty = options.sceneModel.dirty || options.mapModel.dirty;
     save.textContent = dirty ? "Save *" : "Save";
     save.disabled = !dirty;
+    if (mode === "map") {
+      undoButton.disabled = !canUndoMap(options.mapModel);
+      redoButton.disabled = !canRedoMap(options.mapModel);
+    } else {
+      undoButton.disabled = !canUndoScene(options.sceneModel);
+      redoButton.disabled = !canRedoScene(options.sceneModel);
+    }
+  }
+
+  function applyUndo(): void {
+    const ok = mode === "map" ? undoMap(options.mapModel) : undoScene(options.sceneModel);
+    if (!ok) return;
+    renderEntityList();
+    renderPalette();
+    draw();
+    updateSave();
+  }
+
+  function applyRedo(): void {
+    const ok = mode === "map" ? redoMap(options.mapModel) : redoScene(options.sceneModel);
+    if (!ok) return;
+    renderEntityList();
+    renderPalette();
+    draw();
+    updateSave();
   }
 
+  function applyResize(): void {
+    const width = Number(widthInput.value);
+    const height = Number(heightInput.value);
+    const fits = sceneFitsAfterResize(options.sceneModel, width, height);
+    if (!fits.ok) {
+      options.onStatus(fits.reason, true);
+      return;
+    }
+    if (!resizeMap(options.mapModel, width, height)) {
+      options.onStatus("invalid or unchanged dimensions", true);
+      return;
+    }
+    syncMapSize(options.sceneModel, options.mapModel.def.width, options.mapModel.def.height);
+    // A coarse cross-model op: undo history is cleared for both models (documented).
+    clearMapHistory(options.mapModel);
+    clearSceneHistory(options.sceneModel);
+    widthInput.value = String(options.mapModel.def.width);
+    heightInput.value = String(options.mapModel.def.height);
+    sizeCanvas();
+    renderEntityList();
+    renderPalette();
+    draw();
+    updateSave();
+    options.onStatus(`resized to ${options.mapModel.def.width}×${options.mapModel.def.height} — undo history cleared`);
+  }
+
+  const onKey = (event: KeyboardEvent): void => {
+    const target = event.target;
+    if (
+      target instanceof HTMLInputElement ||
+      target instanceof HTMLTextAreaElement ||
+      (target instanceof HTMLElement && target.isContentEditable)
+    ) {
+      return;
+    }
+    const mod = event.ctrlKey || event.metaKey;
+    if (!mod) return;
+    const key = event.key.toLowerCase();
+    if (key === "z") {
+      event.preventDefault();
+      if (event.shiftKey) applyRedo();
+      else applyUndo();
+    } else if (key === "y") {
+      event.preventDefault();
+      applyRedo();
+    }
+  };
+  window.addEventListener("keydown", onKey);
+
   async function saveAll(): Promise<void> {
     const saved: string[] = [];
     if (options.sceneModel.dirty) {
@@ -647,6 +786,7 @@ export function mountSceneEditor(container: HTMLElement, options: SceneEditorOpt
 
   return () => {
     disposed = true;
+    window.removeEventListener("keydown", onKey);
   };
 }
 

+ 177 - 12
apps/editor/src/renderer/scene-model.ts

@@ -37,8 +37,19 @@ export interface SceneModel {
   mapHeight: number;
   /** buildingId → footprint for overlap checks; npc/animal are 1×1. */
   footprints: Record<string, SceneFootprint>;
+  /** Inverse-command stack (most recent last); non-empty ⇒ dirty. */
+  history: SceneHistoryEntry[];
+  /** Forward-command stack for redo (cleared on every new op). */
+  future: SceneHistoryEntry[];
 }
 
+/** An inverse-recordable entity edit. Entities are snapshotted (deep-copied)
+ * into the entries so later mutations never corrupt the undo state. */
+export type SceneHistoryEntry =
+  | { kind: "add"; index: number; entity: SceneEntity }
+  | { kind: "remove"; index: number; entity: SceneEntity }
+  | { kind: "move"; index: number; before: { x: number; y: number }; after: { x: number; y: number } };
+
 /** Result of a placement/edit operation; rejections carry a status-bar reason. */
 export type SceneOpResult = { ok: true } | { ok: false; reason: string };
 
@@ -54,11 +65,11 @@ export const SELECT_COLOR = "#ffd54f";
 export function createSceneModel(input: unknown, options: SceneModelOptions): SceneModel | null {
   const base = { mapWidth: options.mapWidth, mapHeight: options.mapHeight };
   if (input === undefined) {
-    return { def: { id: options.defaultId ?? DEFAULT_SCENE_ID, entities: [] }, dirty: false, ...base, footprints: cloneFootprints(options.footprints) };
+    return { def: { id: options.defaultId ?? DEFAULT_SCENE_ID, entities: [] }, dirty: false, ...base, footprints: cloneFootprints(options.footprints), history: [], future: [] };
   }
   const result = sceneSchema.safeParse(input);
   if (!result.success) return null;
-  return { def: cloneDef(result.data), dirty: false, ...base, footprints: cloneFootprints(options.footprints) };
+  return { def: cloneDef(result.data), dirty: false, ...base, footprints: cloneFootprints(options.footprints), history: [], future: [] };
 }
 
 /** Place a building. Coords are clamped into `[0, map - footprint]`; a
@@ -80,8 +91,10 @@ export function addBuilding(model: SceneModel, buildingId: string, x: number, y:
   if (duplicate) return fail(`duplicate building "${buildingId}" at (${at.x},${at.y})`);
   const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h);
   if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
-  model.def.entities.push({ type: "building", buildingId, x: at.x, y: at.y });
-  model.dirty = true;
+  const entity: SceneEntity = { type: "building", buildingId, x: at.x, y: at.y };
+  const index = model.def.entities.length;
+  model.def.entities.push(entity);
+  recordHistory(model, { kind: "add", index, entity: cloneEntity(entity) });
   return { ok: true };
 }
 
@@ -111,8 +124,9 @@ export function addNpc(
     ...(opts?.dialogue !== undefined && opts.dialogue.length > 0 ? { dialogue: opts.dialogue } : {}),
     ...(opts?.label !== undefined && opts.label.length > 0 ? { label: opts.label } : {}),
   };
+  const index = model.def.entities.length;
   model.def.entities.push(npc);
-  model.dirty = true;
+  recordHistory(model, { kind: "add", index, entity: cloneEntity(npc) });
   return { ok: true };
 }
 
@@ -125,16 +139,19 @@ export function addAnimal(model: SceneModel, animalId: string, x: number, y: num
   if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
   const hit = overlappingEntity(model, at.x, at.y, 1, 1);
   if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
-  model.def.entities.push({ type: "animal", animalId, x: at.x, y: at.y });
-  model.dirty = true;
+  const animal: SceneEntity = { type: "animal", animalId, x: at.x, y: at.y };
+  const index = model.def.entities.length;
+  model.def.entities.push(animal);
+  recordHistory(model, { kind: "add", index, entity: cloneEntity(animal) });
   return { ok: true };
 }
 
 /** Remove the entity at `index`. Out-of-range indices are rejected. */
 export function removeEntity(model: SceneModel, index: number): SceneOpResult {
-  if (model.def.entities[index] === undefined) return fail(`no entity at index ${index}`);
+  const entity = model.def.entities[index];
+  if (entity === undefined) return fail(`no entity at index ${index}`);
   model.def.entities.splice(index, 1);
-  model.dirty = true;
+  recordHistory(model, { kind: "remove", index, entity: cloneEntity(entity) });
   return { ok: true };
 }
 
@@ -150,9 +167,10 @@ export function moveEntity(model: SceneModel, index: number, x: number, y: numbe
   if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
   const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h, index);
   if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
+  const before = { x: entity.x, y: entity.y };
   entity.x = at.x;
   entity.y = at.y;
-  model.dirty = true;
+  recordHistory(model, { kind: "move", index, before, after: { x: at.x, y: at.y } });
   return { ok: true };
 }
 
@@ -165,11 +183,108 @@ export function serialize(model: SceneModel): SceneDef | null {
   return cloneDef(result.data);
 }
 
-/** Clear the unsaved-changes flag (call after a successful save). */
+/** Clear the unsaved-changes flag (call after a successful save). Saving is a
+ * checkpoint: both history stacks are cleared (E9, documented). */
 export function markSaved(model: SceneModel): void {
+  model.history = [];
+  model.future = [];
   model.dirty = false;
 }
 
+/** Undo the most recent entity edit. Returns false when the stack is empty. */
+export function undo(model: SceneModel): boolean {
+  const entry = model.history.pop();
+  if (entry === undefined) return false;
+  applyInverse(model, entry);
+  model.future.push(entry);
+  model.dirty = model.history.length > 0;
+  return true;
+}
+
+/** Redo the most recently undone edit. Returns false when there is nothing to
+ * redo (or a new op cleared the redo stack). */
+export function redo(model: SceneModel): boolean {
+  const entry = model.future.pop();
+  if (entry === undefined) return false;
+  applyForward(model, entry);
+  model.history.push(entry);
+  model.dirty = model.history.length > 0;
+  return true;
+}
+
+export function canUndo(model: SceneModel): boolean {
+  return model.history.length > 0;
+}
+
+export function canRedo(model: SceneModel): boolean {
+  return model.future.length > 0;
+}
+
+/** Drop both stacks without touching the def or the dirty flag (the scene
+ * editor's resize path uses this — a coarse cross-model op is not undoable,
+ * but the resize itself IS an unsaved edit and must stay dirty). */
+export function clearHistory(model: SceneModel): void {
+  model.history = [];
+  model.future = [];
+}
+
+/** Sync the scene's map bounds after a map resize (E9): updates
+ * `mapWidth`/`mapHeight` and clamps every entity into the new bounds. A
+ * building whose footprint does not fit is left in place (defensive — the UI
+ * must call `sceneFitsAfterResize` before resizing, which also rejects
+ * clamp-induced overlaps; documented). */
+export function syncMapSize(model: SceneModel, width: number, height: number): void {
+  model.mapWidth = width;
+  model.mapHeight = height;
+  for (const entity of model.def.entities) {
+    const footprint = entityFootprint(model, entity);
+    const maxX = width - footprint.w;
+    const maxY = height - footprint.h;
+    if (maxX < 0 || maxY < 0) continue;
+    entity.x = clampInt(entity.x, 0, maxX);
+    entity.y = clampInt(entity.y, 0, maxY);
+  }
+}
+
+/** Whether every entity still fits in a map of the given size (E9): a building
+ * whose footprint exceeds the bounds cannot survive a shrink — the resize must
+ * be rejected, not the entity lost. npc/animal are 1×1 and always fit. */
+export function sceneFitsIn(model: SceneModel, width: number, height: number): SceneOpResult {
+  for (const entity of model.def.entities) {
+    if (entity.type !== "building") continue;
+    const footprint = entityFootprint(model, entity);
+    if (footprint.w > width || footprint.h > height) {
+      return fail(`building "${entity.buildingId}" (${footprint.w}×${footprint.h}) does not fit within ${width}×${height}`);
+    }
+  }
+  return { ok: true };
+}
+
+/** The full resize guard (bug-fix round): `sceneFitsIn` PLUS a simulation of
+ * `syncMapSize`'s clamping — two entities that would be clamped into overlap
+ * would fail the engine's seed-time placement check, so the resize must be
+ * rejected (with an entity-describing reason), not shipped. */
+export function sceneFitsAfterResize(model: SceneModel, width: number, height: number): SceneOpResult {
+  const fit = sceneFitsIn(model, width, height);
+  if (!fit.ok) return fit;
+  const placements: { x: number; y: number; w: number; h: number; label: string }[] = [];
+  for (const entity of model.def.entities) {
+    const footprint = entityFootprint(model, entity);
+    const maxX = width - footprint.w;
+    const maxY = height - footprint.h;
+    if (maxX < 0 || maxY < 0) continue; // uncontainable buildings were rejected above
+    const x = clampInt(entity.x, 0, maxX);
+    const y = clampInt(entity.y, 0, maxY);
+    for (const other of placements) {
+      if (rectsIntersect(x, y, footprint.w, footprint.h, other.x, other.y, other.w, other.h)) {
+        return fail(`resize would overlap ${describeEntity(entity)} with ${other.label}`);
+      }
+    }
+    placements.push({ x, y, w: footprint.w, h: footprint.h, label: describeEntity(entity) });
+  }
+  return { ok: true };
+}
+
 /** Index of the topmost entity whose footprint contains (x, y), or -1. */
 export function entityIndexAt(model: SceneModel, x: number, y: number): number {
   let found = -1;
@@ -257,7 +372,57 @@ function rectsIntersect(
 }
 
 function cloneDef(def: SceneDef): SceneDef {
-  return { id: def.id, entities: def.entities.map((entity) => ({ ...entity })) };
+  return { id: def.id, entities: def.entities.map((entity) => cloneEntity(entity)) };
+}
+
+function cloneEntity(entity: SceneEntity): SceneEntity {
+  return { ...entity };
+}
+
+/** Push an inverse-recordable edit: new ops clear the redo stack, and the
+ * dirty flag follows the stack (dirty = history non-empty). */
+function recordHistory(model: SceneModel, entry: SceneHistoryEntry): void {
+  model.history.push(entry);
+  model.future = [];
+  model.dirty = true;
+}
+
+function applyInverse(model: SceneModel, entry: SceneHistoryEntry): void {
+  switch (entry.kind) {
+    case "add":
+      model.def.entities.splice(entry.index, 1);
+      break;
+    case "remove":
+      model.def.entities.splice(entry.index, 0, cloneEntity(entry.entity));
+      break;
+    case "move": {
+      const entity = model.def.entities[entry.index];
+      if (entity !== undefined) {
+        entity.x = entry.before.x;
+        entity.y = entry.before.y;
+      }
+      break;
+    }
+  }
+}
+
+function applyForward(model: SceneModel, entry: SceneHistoryEntry): void {
+  switch (entry.kind) {
+    case "add":
+      model.def.entities.splice(entry.index, 0, cloneEntity(entry.entity));
+      break;
+    case "remove":
+      model.def.entities.splice(entry.index, 1);
+      break;
+    case "move": {
+      const entity = model.def.entities[entry.index];
+      if (entity !== undefined) {
+        entity.x = entry.after.x;
+        entity.y = entry.after.y;
+      }
+      break;
+    }
+  }
 }
 
 function cloneFootprints(footprints: Record<string, SceneFootprint> | undefined): Record<string, SceneFootprint> {

+ 28 - 0
apps/editor/test/asset-images.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { assetDataUrls } from "../src/renderer/asset-images.js";
+import type { EditorAssetInfo } from "../src/ipc.js";
+
+describe("assetDataUrls", () => {
+  it("keeps image assets with a dataUrl", () => {
+    const assets: EditorAssetInfo[] = [
+      { id: "grass-1", kind: "image", mime: "image/png", size: 100, dataUrl: "data:image/png;base64,AAA" },
+      { id: "water-1", kind: "image", mime: "image/png", size: 200, dataUrl: "data:image/png;base64,BBB" },
+    ];
+    expect(assetDataUrls(assets)).toEqual({
+      "grass-1": "data:image/png;base64,AAA",
+      "water-1": "data:image/png;base64,BBB",
+    });
+  });
+
+  it("skips audio and images without a dataUrl", () => {
+    const assets: EditorAssetInfo[] = [
+      { id: "bgm", kind: "audio", mime: "audio/ogg", size: 500 },
+      { id: "broken", kind: "image", mime: "image/png", size: 100 },
+    ];
+    expect(assetDataUrls(assets)).toEqual({});
+  });
+
+  it("returns an empty record for no assets", () => {
+    expect(assetDataUrls([])).toEqual({});
+  });
+});

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

@@ -222,3 +222,31 @@ describe("roundtrip real content", () => {
     expect(JSON.stringify(out)).toBe(JSON.stringify(def));
   });
 });
+
+describe("any-field coercion losslessness (bug-fix round)", () => {
+  it("keeps leading-zero strings intact — '007' must never become 7 on save", () => {
+    const schema = contentSchemas.behavior;
+    const formSchema = derive("behavior");
+    const def = {
+      id: "t1",
+      when: { op: "eq", path: "player.inventory.pumpkin", value: "007" },
+      fire: [{ action: "set-state", path: "player.label", value: "007" }],
+    };
+    const working = applyValue(formSchema, def);
+    const out = coerceForValidate(formSchema, working);
+    expect(out.when.value).toBe("007");
+    expect(out.fire[0].value).toBe("007");
+    expect(schema.safeParse(out).success).toBe(true);
+  });
+
+  it("still coerces roundtrip-exact input like '42' to a number", () => {
+    const formSchema = derive("behavior");
+    const working = applyValue(formSchema, {
+      id: "t1",
+      when: { op: "eq", path: "player.inventory.pumpkin", value: "42" },
+      fire: [],
+    });
+    const out = coerceForValidate(formSchema, working);
+    expect(out.when.value).toBe(42);
+  });
+});

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

@@ -10,6 +10,9 @@ import {
   TILE_SIZE,
   UNKNOWN_TILE_COLOR,
   addTileId,
+  canRedo,
+  canUndo,
+  clearHistory,
   collectTileIds,
   createMapModel,
   erase,
@@ -17,10 +20,14 @@ import {
   markSaved,
   paint,
   pointToTile,
+  redo,
+  resizeMap,
   serialize,
   setSpawn,
+  tileAssetId,
   tileColor,
   toggleWalkable,
+  undo,
 } from "../src/renderer/map-model.js";
 import type { MapModel } from "../src/renderer/map-model.js";
 
@@ -368,3 +375,213 @@ describe("real map fixture end-to-end", () => {
     expect(await readFile(join(fixturesDir, "farm.yaml"), "utf8")).toBe(text);
   });
 });
+
+describe("undo/redo", () => {
+  it("paint then undo restores the cell, clears dirty, and redo re-applies", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "water");
+    expect(m.dirty).toBe(true);
+    expect(canUndo(m)).toBe(true);
+    expect(undo(m)).toBe(true);
+    expect(m.def.layers[0].data[0][0]).toBe("grass");
+    expect(m.dirty).toBe(false);
+    expect(canUndo(m)).toBe(false);
+    expect(redo(m)).toBe(true);
+    expect(m.def.layers[0].data[0][0]).toBe("water");
+    expect(m.dirty).toBe(true);
+  });
+
+  it("erase records its actual write (before/after), not the erase intent", () => {
+    const m = model(makeDef());
+    paint(m, 2, 2, "water");
+    markSaved(m);
+    erase(m, 2, 2);
+    expect(m.def.layers[0].data[2][2]).toBe("grass");
+    expect(undo(m)).toBe(true);
+    expect(m.def.layers[0].data[2][2]).toBe("water");
+  });
+
+  it("setSpawn undo restores the previous (clamped) coordinates", () => {
+    const m = model(makeDef());
+    setSpawn(m, 3, 3);
+    expect(m.def.spawn).toEqual({ x: 3, y: 3 });
+    expect(undo(m)).toBe(true);
+    expect(m.def.spawn).toEqual({ x: 1, y: 1 });
+  });
+
+  it("toggleWalkable undo restores the record entry (and deletion restores the false entry)", () => {
+    const m = model(makeDef());
+    toggleWalkable(m, "grass"); // absent → false
+    expect(m.def.walkable).toEqual({ grass: false });
+    expect(undo(m)).toBe(true);
+    expect(m.def.walkable).toBeUndefined();
+    toggleWalkable(m, "grass");
+    toggleWalkable(m, "grass"); // false → deleted
+    expect(m.def.walkable).toBeUndefined();
+    expect(undo(m)).toBe(true);
+    expect(m.def.walkable).toEqual({ grass: false });
+  });
+
+  it("walkable undo restores an earlier unrelated entry of a multi-entry record", () => {
+    const m = model(makeDef({ walkable: { water: false } }));
+    toggleWalkable(m, "grass");
+    undo(m);
+    expect(m.def.walkable).toEqual({ water: false });
+  });
+
+  it("no-ops never enter the stack: same-value paint, same spawn, out-of-bounds paint", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "grass"); // same value
+    paint(m, 99, 99, "water"); // out of bounds
+    setSpawn(m, 1, 1); // unchanged (clamped to same)
+    expect(m.history.length).toBe(0);
+    expect(m.dirty).toBe(false);
+  });
+
+  it("ops unwind LIFO and a new op clears the redo stack", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "water");
+    paint(m, 1, 1, "water");
+    undo(m);
+    undo(m);
+    expect(redo(m)).toBe(true);
+    paint(m, 3, 3, "water"); // new op
+    expect(canRedo(m)).toBe(false);
+    expect(redo(m)).toBe(false);
+  });
+
+  it("markSaved is a checkpoint: it clears both stacks and dirty", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "water");
+    undo(m);
+    markSaved(m);
+    expect(m.history.length).toBe(0);
+    expect(m.future.length).toBe(0);
+    expect(m.dirty).toBe(false);
+    expect(canUndo(m)).toBe(false);
+    expect(canRedo(m)).toBe(false);
+  });
+
+  it("undoing everything returns to the saved state (Save button clears)", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "water");
+    paint(m, 1, 1, "water");
+    setSpawn(m, 2, 2);
+    expect(m.dirty).toBe(true);
+    while (undo(m)) {
+      // drain
+    }
+    expect(m.dirty).toBe(false);
+    expect(m.def.spawn).toEqual({ x: 1, y: 1 });
+    expect(m.def.layers[0].data[0][0]).toBe("grass");
+    expect(m.def.layers[0].data[1][1]).toBe("grass");
+  });
+
+  it("palette is additive and does not roll back on undo", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "rock");
+    expect(m.palette).toContain("rock");
+    undo(m);
+    expect(m.palette).toContain("rock");
+  });
+
+  it("undo/redo return false on empty stacks", () => {
+    const m = model(makeDef());
+    expect(undo(m)).toBe(false);
+    expect(redo(m)).toBe(false);
+  });
+});
+
+describe("resizeMap", () => {
+  it("grows width/height padding rows and columns with the erase tile", () => {
+    const m = model(makeDef());
+    expect(resizeMap(m, 6, 5)).toBe(true);
+    expect(m.def.width).toBe(6);
+    expect(m.def.height).toBe(5);
+    expect(m.def.layers[0].data.length).toBe(5);
+    for (const row of m.def.layers[0].data) expect(row.length).toBe(6);
+    expect(m.def.layers[0].data[4].every((tile) => tile === DEFAULT_ERASE_TILE)).toBe(true);
+    expect(serialize(m)).not.toBeNull();
+  });
+
+  it("shrinks by truncating and keeps non-square shapes", () => {
+    const m = model(makeDef({ width: 8, height: 10, layers: [{ name: "ground", data: grid(8, 10) }] }));
+    expect(resizeMap(m, 20, 3)).toBe(true);
+    expect(m.def.width).toBe(20);
+    expect(m.def.height).toBe(3);
+    expect(m.def.layers[0].data.length).toBe(3);
+    for (const row of m.def.layers[0].data) expect(row.length).toBe(20);
+    expect(serialize(m)).not.toBeNull();
+  });
+
+  it("resizes every layer, not just the ground layer", () => {
+    const m = model(
+      makeDef({ layers: [{ name: "ground", data: grid(4, 4) }, { name: "overlay", data: grid(4, 4, "") }] }),
+    );
+    expect(resizeMap(m, 6, 6)).toBe(true);
+    for (const layer of m.def.layers) {
+      expect(layer.data.length).toBe(6);
+      for (const row of layer.data) expect(row.length).toBe(6);
+    }
+  });
+
+  it("clamps an out-of-range spawn into the new bounds", () => {
+    const m = model(makeDef({ width: 12, height: 12, spawn: { x: 10, y: 10 }, layers: [{ name: "ground", data: grid(12, 12) }] }));
+    expect(resizeMap(m, 4, 4)).toBe(true);
+    expect(m.def.spawn).toEqual({ x: 3, y: 3 });
+  });
+
+  it("rejects non-positive/non-integer dimensions and unchanged sizes, leaving the def untouched", () => {
+    const m = model(makeDef());
+    const before = JSON.stringify(m.def);
+    expect(resizeMap(m, 0, 4)).toBe(false);
+    expect(resizeMap(m, 4, -1)).toBe(false);
+    expect(resizeMap(m, 4.5, 4)).toBe(false);
+    expect(resizeMap(m, Number.NaN, 4)).toBe(false);
+    expect(resizeMap(m, 4, 4)).toBe(false);
+    expect(JSON.stringify(m.def)).toBe(before);
+    expect(m.history.length).toBe(0);
+  });
+
+  it("resize is undoable/redoable via the full-grid snapshot", () => {
+    const m = model(makeDef({ width: 8, height: 8, spawn: { x: 6, y: 6 }, layers: [{ name: "ground", data: grid(8, 8) }] }));
+    expect(resizeMap(m, 4, 4)).toBe(true);
+    expect(undo(m)).toBe(true);
+    expect(m.def.width).toBe(8);
+    expect(m.def.height).toBe(8);
+    expect(m.def.spawn).toEqual({ x: 6, y: 6 });
+    expect(m.def.layers[0].data.length).toBe(8);
+    expect(m.def.layers[0].data[0].length).toBe(8);
+    expect(redo(m)).toBe(true);
+    expect(m.def.width).toBe(4);
+    expect(serialize(m)).not.toBeNull();
+  });
+});
+
+describe("tileAssetId", () => {
+  it("returns the asset id for a mapped tile and undefined otherwise", () => {
+    const m = model(makeDef({ tiles: { grass: "grass-1", water: "water-1" } }));
+    expect(tileAssetId(m, "grass")).toBe("grass-1");
+    expect(tileAssetId(m, "sand")).toBeUndefined();
+  });
+
+  it("returns undefined without a tiles record", () => {
+    expect(tileAssetId(model(makeDef()), "grass")).toBeUndefined();
+  });
+});
+
+describe("clearHistory (bug-fix round)", () => {
+  it("drops both stacks but keeps the dirty flag — an edit is an unsaved edit", () => {
+    const m = model(makeDef());
+    paint(m, 0, 0, "water");
+    clearHistory(m);
+    expect(m.history.length).toBe(0);
+    expect(m.future.length).toBe(0);
+    expect(m.dirty).toBe(true); // the painted tile is still unsaved
+    expect(canUndo(m)).toBe(false);
+    expect(serialize(m)?.layers[0].data[0][0]).toBe("water");
+    // markSaved is the only way back to clean.
+    markSaved(m);
+    expect(m.dirty).toBe(false);
+  });
+});

+ 105 - 1
apps/editor/test/playtest-hud.test.ts

@@ -1,5 +1,13 @@
 import { describe, expect, it } from "vitest";
-import { inventorySummary, playerStatsFromSnapshot } from "../src/renderer/playtest-hud.js";
+import {
+  buildingShopAt,
+  dialogueActiveInWorld,
+  dialogueChoicesFromNode,
+  inventorySummary,
+  playerStatsFromSnapshot,
+  seedListFromSnapshot,
+  shopRowsFromDef,
+} from "../src/renderer/playtest-hud.js";
 
 describe("playerStatsFromSnapshot", () => {
   it("reads gold, stamina, seed and inventory from the player entity", () => {
@@ -40,3 +48,99 @@ describe("inventorySummary", () => {
     expect(inventorySummary({ inventory: {}, gold: 0, stamina: 0, selectedSeed: null })).toBe("items: —");
   });
 });
+
+describe("seedListFromSnapshot", () => {
+  const world = [
+    { id: "player", type: "player", inventory: { "carrot-seed": 2, carrot: 3, hoe: 1 } },
+  ];
+  const items: Array<{ id: string; name: string; kind: string }> = [
+    { id: "carrot-seed", name: "Carrot Seeds", kind: "seed" },
+    { id: "carrot", name: "Carrot", kind: "material" },
+    { id: "hoe", name: "Hoe", kind: "tool" },
+  ];
+
+  it("keeps only seed-kind items when an item catalog is given", () => {
+    expect(seedListFromSnapshot(world, items as never)).toEqual([{ id: "carrot-seed", name: "Carrot Seeds", qty: 2 }]);
+  });
+
+  it("offers every inventory item without a catalog (ws parity)", () => {
+    expect(seedListFromSnapshot(world)).toEqual([
+      { id: "carrot-seed", name: "carrot-seed", qty: 2 },
+      { id: "carrot", name: "carrot", qty: 3 },
+      { id: "hoe", name: "hoe", qty: 1 },
+    ]);
+  });
+
+  it("returns an empty list for an empty inventory", () => {
+    expect(seedListFromSnapshot([])).toEqual([]);
+  });
+});
+
+describe("shopRowsFromDef", () => {
+  it("maps buys/sells with the shop-viewpoint convention and names from the catalog", () => {
+    const rows = shopRowsFromDef(
+      {
+        id: "general-store",
+        name: "General Store",
+        buys: [{ itemId: "carrot-seed", price: 20 }],
+        sells: [{ itemId: "carrot", price: 15, stock: 5 }],
+      } as never,
+      { carrot: 2 },
+      [
+        { id: "carrot-seed", name: "Carrot Seeds", kind: "seed" },
+        { id: "carrot", name: "Carrot", kind: "material" },
+      ] as never,
+    );
+    expect(rows.shopId).toBe("general-store");
+    expect(rows.buyRows).toEqual([{ itemId: "carrot-seed", name: "Carrot Seeds", price: 20, stock: null, owned: 0 }]);
+    expect(rows.sellRows).toEqual([{ itemId: "carrot", name: "Carrot", price: 15, stock: 5, owned: 2 }]);
+  });
+
+  it("falls back to the item id without a catalog and handles empty lines", () => {
+    const rows = shopRowsFromDef({ id: "s", buys: [], sells: [{ itemId: "egg", price: 40 }] } as never, undefined);
+    expect(rows.buyRows).toEqual([]);
+    expect(rows.sellRows).toEqual([{ itemId: "egg", name: "egg", price: 40, stock: null, owned: 0 }]);
+  });
+});
+
+describe("dialogueChoicesFromNode", () => {
+  it("maps text and choice labels", () => {
+    const view = dialogueChoicesFromNode({
+      dialogueId: "mayor",
+      nodeId: "n1",
+      text: "Hello!",
+      choices: [{ text: "Hi", to: "n2" }, { text: "Bye", to: "n3" }],
+    });
+    expect(view.text).toBe("Hello!");
+    expect(view.choices).toEqual(["Hi", "Bye"]);
+  });
+
+  it("returns empty for an absent dialogue state", () => {
+    expect(dialogueChoicesFromNode(undefined)).toEqual({ text: "", choices: [] });
+  });
+});
+
+describe("buildingShopAt", () => {
+  const world = [
+    { id: "building:shed:2:3", type: "building", x: 2, y: 3, w: 2, h: 2, shop: "general-store" },
+    { id: "building:barn:0:0", type: "building", x: 0, y: 0, w: 1, h: 1 },
+  ];
+
+  it("finds the shop whose footprint contains the tile", () => {
+    expect(buildingShopAt(world, { x: 3, y: 4 })).toEqual({ buildingId: "building:shed:2:3", shopId: "general-store" });
+  });
+
+  it("misses outside the footprint, on shopless buildings and on empty world", () => {
+    expect(buildingShopAt(world, { x: 4, y: 4 })).toBeUndefined();
+    expect(buildingShopAt(world, { x: 0, y: 0 })).toBeUndefined();
+    expect(buildingShopAt([], { x: 0, y: 0 })).toBeUndefined();
+  });
+});
+
+describe("dialogueActiveInWorld", () => {
+  it("detects the dialogue:active sentinel entity", () => {
+    expect(dialogueActiveInWorld([{ id: "dialogue:active", type: "dialogue" }])).toBe(true);
+    expect(dialogueActiveInWorld([{ id: "player", type: "player" }])).toBe(false);
+    expect(dialogueActiveInWorld([])).toBe(false);
+  });
+});

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

@@ -12,6 +12,9 @@ import {
   addAnimal,
   addBuilding,
   addNpc,
+  canRedo,
+  canUndo,
+  clearHistory,
   createSceneModel,
   entityAt,
   entityColor,
@@ -19,8 +22,13 @@ import {
   entityIndexAt,
   markSaved,
   moveEntity,
+  redo,
   removeEntity,
+  sceneFitsAfterResize,
+  sceneFitsIn,
   serialize,
+  syncMapSize,
+  undo,
 } from "../src/renderer/scene-model.js";
 import type { SceneFootprint, SceneModel } from "../src/renderer/scene-model.js";
 
@@ -473,3 +481,158 @@ describe("real scene fixture end-to-end", () => {
     expect(await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8")).toBe(sceneText);
   });
 });
+
+describe("undo/redo", () => {
+  it("addBuilding/addNpc/addAnimal undo removes the entity and redo restores it", () => {
+    const m = model();
+    addBuilding(m, "house", 4, 4);
+    addNpc(m, "mayor", 0, 0, { dialogue: "intro" });
+    addAnimal(m, "chicken", 7, 7);
+    expect(entities(m).length).toBe(3);
+    expect(undo(m)).toBe(true);
+    expect(entities(m).length).toBe(2);
+    expect(undo(m)).toBe(true);
+    expect(entities(m).length).toBe(1);
+    expect(undo(m)).toBe(true);
+    expect(entities(m).length).toBe(0);
+    expect(m.dirty).toBe(false);
+    expect(redo(m)).toBe(true);
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
+    expect(redo(m)).toBe(true);
+    expect(entities(m)[1]).toEqual({ type: "npc", npcId: "mayor", x: 0, y: 0, dialogue: "intro" });
+    expect(m.dirty).toBe(true);
+  });
+
+  it("removeEntity undo re-inserts the snapshot at the original index", () => {
+    const m = model();
+    addBuilding(m, "house", 4, 4);
+    addNpc(m, "mayor", 0, 0);
+    expect(removeEntity(m, 0)).toEqual({ ok: true });
+    expect(entities(m).map((e) => e.type)).toEqual(["npc"]);
+    expect(undo(m)).toBe(true);
+    expect(entities(m).map((e) => e.type)).toEqual(["building", "npc"]);
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
+  });
+
+  it("history entries snapshot entities: a later move never corrupts redo", () => {
+    const m = model();
+    addBuilding(m, "house", 4, 4); // add entry snapshots house at (4,4)
+    expect(moveEntity(m, 0, 7, 7)).toEqual({ ok: true }); // live house → (7,7)
+    expect(undo(m)).toBe(true); // undo the move
+    expect(undo(m)).toBe(true); // undo the add
+    expect(entities(m)).toHaveLength(0);
+    expect(redo(m)).toBe(true); // redo the add — restores the SNAPSHOT coords, not (7,7)
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
+  });
+
+  it("moveEntity undo restores the previous coordinates", () => {
+    const m = model();
+    addNpc(m, "mayor", 2, 2);
+    expect(moveEntity(m, 0, 8, 8)).toEqual({ ok: true });
+    expect(entities(m)[0].x).toBe(8);
+    expect(undo(m)).toBe(true);
+    expect(entities(m)[0]).toEqual({ x: 2, y: 2, type: "npc", npcId: "mayor" });
+  });
+
+  it("rejected ops never enter the stack", () => {
+    const m = model();
+    addBuilding(m, "house", 4, 4);
+    expect(addBuilding(m, "house", 4, 4)).toEqual({ ok: false, reason: expect.stringContaining("duplicate") });
+    expect(addNpc(m, "mayor", 0, 0));
+    expect(addNpc(m, "mayor", 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("duplicate npc") });
+    expect(moveEntity(m, 99, 0, 0)).toEqual({ ok: false, reason: expect.stringContaining("index") });
+    expect(m.history.length).toBe(2); // only the two successful adds
+  });
+
+  it("markSaved is a checkpoint and undo-all returns clean", () => {
+    const m = model();
+    addNpc(m, "mayor", 0, 0);
+    undo(m);
+    markSaved(m);
+    expect(m.history.length).toBe(0);
+    expect(m.future.length).toBe(0);
+    expect(m.dirty).toBe(false);
+    addNpc(m, "mia", 1, 1);
+    addNpc(m, "cow", 2, 2);
+    while (undo(m)) {
+      // drain
+    }
+    expect(m.dirty).toBe(false);
+    expect(entities(m).length).toBe(0);
+  });
+});
+
+describe("syncMapSize", () => {
+  it("updates bounds and clamps entities into the new size", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 8, 8); // 2×2 at 8,8 → fits in 10×10
+    addNpc(m, "mayor", 9, 0);
+    syncMapSize(m, 5, 5);
+    expect(m.mapWidth).toBe(5);
+    expect(m.mapHeight).toBe(5);
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 3, y: 3 });
+    expect(entities(m)[1]).toEqual({ type: "npc", npcId: "mayor", x: 4, y: 0 });
+  });
+
+  it("leaves a building whose footprint cannot fit in place (defensive — sceneFitsIn guards first)", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 4, 4);
+    syncMapSize(m, 1, 1);
+    expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
+  });
+});
+
+describe("sceneFitsIn", () => {
+  it("rejects a size that cannot contain a placed building footprint", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 4, 4); // 2×2
+    expect(sceneFitsIn(m, 10, 10)).toEqual({ ok: true });
+    expect(sceneFitsIn(m, 2, 2)).toEqual({ ok: true });
+    const result = sceneFitsIn(m, 1, 1);
+    expect(result).toEqual({ ok: false, reason: expect.stringContaining('building "house"') });
+    expect(result).toEqual({ ok: false, reason: expect.stringContaining("1×1") });
+  });
+
+  it("always fits npc/animal (1×1)", () => {
+    const m = model(10, 10);
+    addNpc(m, "mayor", 9, 9);
+    expect(addAnimal(m, "chicken", 8, 8)).toEqual({ ok: true });
+    expect(sceneFitsIn(m, 1, 1)).toEqual({ ok: true });
+  });
+});
+
+describe("clearHistory + sceneFitsAfterResize (bug-fix round)", () => {
+  it("clearHistory keeps the dirty flag — the resize is an unsaved edit", () => {
+    const m = model();
+    addNpc(m, "mayor", 0, 0);
+    clearHistory(m);
+    expect(m.history.length).toBe(0);
+    expect(m.dirty).toBe(true);
+    expect(canUndo(m)).toBe(false);
+  });
+
+  it("sceneFitsAfterResize rejects a shrink whose clamping would overlap buildings", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 0, 0); // 2×2
+    addBuilding(m, "shed", 3, 0); // 2×2 — clamp target (1,0) would overlap the house
+    const result = sceneFitsAfterResize(m, 3, 3);
+    expect(result).toEqual({ ok: false, reason: expect.stringContaining("would overlap") });
+    // per-building fit still passes at 3×3 (each 2×2 fits) — the overlap
+    // simulation is what rejects it
+    expect(sceneFitsIn(m, 3, 3)).toEqual({ ok: true });
+  });
+
+  it("sceneFitsAfterResize passes when clamped entities stay apart", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 0, 0);
+    addBuilding(m, "shed", 5, 0);
+    addNpc(m, "mayor", 9, 9);
+    expect(sceneFitsAfterResize(m, 4, 4)).toEqual({ ok: true });
+  });
+
+  it("sceneFitsAfterResize still rejects uncontainable buildings", () => {
+    const m = model(10, 10);
+    addBuilding(m, "house", 0, 0); // 2×2
+    expect(sceneFitsAfterResize(m, 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("does not fit") });
+  });
+});

+ 2 - 1
apps/editor/tsconfig.renderer.json

@@ -6,7 +6,8 @@
     "noEmit": true,
     "paths": {
       "@nengine/core-schema": ["../../packages/core/src/schema/index.ts"],
-      "@nengine/core-systems": ["../../packages/core/src/systems/index.ts"]
+      "@nengine/core-systems": ["../../packages/core/src/systems/index.ts"],
+      "@nengine/ui": ["../../packages/ui/src/index.ts"]
     }
   },
   "include": ["src/renderer/**/*.ts", "src/shim/**/*.ts", "src/ipc.ts", "src/project-yaml.ts"]

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

@@ -31,6 +31,7 @@ export default defineConfig(({ mode }) => {
       alias: {
         "@nengine/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
         "@nengine/render": fileURLToPath(new URL("../../packages/render/src/index.ts", import.meta.url)),
+        "@nengine/ui": fileURLToPath(new URL("../../packages/ui/src/index.ts", import.meta.url)),
         "@nengine/core-schema": fileURLToPath(new URL("../../packages/core/src/schema/index.ts", import.meta.url)),
         "@nengine/core-systems": fileURLToPath(new URL("../../packages/core/src/systems/index.ts", import.meta.url)),
         ...shimAliases,

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

@@ -189,6 +189,7 @@ export async function compileMods(modsDir: string, enabledMods?: string[]): Prom
   const mods: Record<string, BundledMod> = {};
   const failures: string[] = [];
   for (const name of entries) {
+    if (name.startsWith(".")) continue; // dot dirs (.git, …) are never Mods
     const dir = resolve(modsDir, name);
     if (!(await isDirectory(dir))) continue;
     const fallbackId = basename(dir);

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

@@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
 import { join, resolve } from "node:path";
 import { describe, expect, it } from "vitest";
 import { assetByteSize, createEngine, type ContentBundle, type IntentSystem, type TimeSystem } from "@nengine/core";
-import { DEFAULT_OUT_DIR, compileBundle, packGame, parsePackArgs, splitBundleAssets, USAGE } from "../src/index.js";
+import { DEFAULT_OUT_DIR, compileBundle, compileMods, packGame, parsePackArgs, splitBundleAssets, USAGE } from "../src/index.js";
 import { DEFAULT_PRODUCT_NAME, generateDesktop } from "../src/desktop.js";
 
 const repoRoot = resolve(process.cwd(), "../..");
@@ -456,3 +456,22 @@ describe("packGame integration", () => {
     }
   });
 });
+
+describe("compileMods dot dirs (bug-fix round)", () => {
+  it("skips dot directories like .git — they are never Mods", async () => {
+    const tmp = await mkdtemp(join(tmpdir(), "nengine-pack-dotdir-"));
+    try {
+      await mkdir(join(tmp, ".git"), { recursive: true });
+      await mkdir(join(tmp, "real-mod"), { recursive: true });
+      await writeFile(
+        join(tmp, "real-mod", "index.mjs"),
+        "export default { manifest: { id: 'real-mod', version: '1.0.0' }, hooks: {} };",
+        "utf8",
+      );
+      const mods = await compileMods(tmp);
+      expect(Object.keys(mods)).toEqual(["real-mod"]);
+    } finally {
+      await rm(tmp, { recursive: true, force: true });
+    }
+  });
+});

Plik diff jest za duży
+ 0 - 0
docs/decisions.md


Plik diff jest za duży
+ 6 - 4
docs/editor.md


+ 12 - 0
packages/core/src/content/loader.ts

@@ -123,6 +123,7 @@ export class ContentLoader {
       ? opts.pollIntervalMs
       : 250;
     let lastAssetKeys = new Set(this.registry.assets.keys());
+    let lastAssetSizes = new Map<string, number>();
     let lastKindsSnapshot = new Map<ContentKind, number>();
     void Promise.resolve().then(async () => {
       for (const k of contentKinds) {
@@ -147,9 +148,20 @@ export class ContentLoader {
             }
           }
         }
+        // Same keys ≠ same bytes: a same-name file whose CONTENT changed
+        // (size differs) must also trigger a reload. The first poll has no
+        // size baseline (it primes instead).
+        if (!assetChanged && lastAssetSizes.size > 0) {
+          for (const [id, entry] of Object.entries(assetIndex)) {
+            if (lastAssetSizes.get(id) !== entry.size) { assetChanged = true; break; }
+          }
+        }
         if (assetChanged) {
           lastAssetKeys = currentKeys;
+          lastAssetSizes = new Map(Object.entries(assetIndex).map(([id, entry]) => [id, entry.size]));
           void this.reloadAssets(dir);
+        } else if (lastAssetSizes.size === 0) {
+          lastAssetSizes = new Map(Object.entries(assetIndex).map(([id, entry]) => [id, entry.size]));
         }
         for (const k of contentKinds) {
           const mtime = await this.kindMtime(dir, k);

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

@@ -269,7 +269,7 @@ async function modDirsOf(modsDir: string): Promise<string[]> {
     return [];
   }
   return entries
-    .filter((e) => e.isDirectory())
+    .filter((e) => e.isDirectory() && !e.name.startsWith("."))
     .map((e) => e.name)
     .sort();
 }

+ 12 - 0
packages/core/src/systems/behavior-runtime.ts

@@ -1,6 +1,8 @@
 import type { TimeSystem } from "./time.js";
 import type { Entity, World } from "../world/world.js";
 import type { WorldEventBus } from "../world/event-bus.js";
+import type { SystemsHost } from "./host.js";
+import type { InventorySystem } from "./inventory.js";
 import type { ConditionExpr, TriggerAction } from "../schema/index.js";
 
 export interface EvalContext {
@@ -11,6 +13,7 @@ export interface BehaviorRuntimeCtx {
   world: World;
   events?: WorldEventBus | undefined;
   time?: TimeSystem | undefined;
+  systems?: SystemsHost | undefined;
   payload?: unknown;
   label?: string | undefined;
 }
@@ -130,6 +133,15 @@ function runAction(ctx: BehaviorRuntimeCtx, action: TriggerAction): void {
       ctx.events?.emit(action.event, action.payload);
       break;
     case "give-item": {
+      // Durable items must land in `inventoryInstances` (they can't live in
+      // the flat inventory — durability queries/use would silently miss them).
+      // The inventory system is the single grant path; the flat write below is
+      // only a fallback for bare-system test setups without it.
+      const inventorySystem = ctx.systems?.get<InventorySystem>("inventory");
+      if (inventorySystem) {
+        inventorySystem.give(action.itemId, action.qty);
+        break;
+      }
       const player = ctx.world.get("player");
       if (!player) {
         throw new Error(`${label} give-item: player entity not initialized`);

+ 13 - 0
packages/core/src/systems/crafting.ts

@@ -1,4 +1,6 @@
 import type { System, SystemInitCtx } from "./system.js";
+import type { SystemsHost } from "./host.js";
+import type { InventorySystem } from "./inventory.js";
 import type { World, Entity } from "../world/world.js";
 import type { WorldEventBus } from "../world/event-bus.js";
 import type { ContentRegistry } from "../content/registry.js";
@@ -20,11 +22,13 @@ export class CraftingSystem implements System {
   private world: World | undefined;
   private registry: ContentRegistry | undefined;
   private events: WorldEventBus | undefined;
+  private systems: SystemsHost | undefined;
 
   init(ctx: SystemInitCtx): void {
     this.world = ctx.world;
     this.registry = ctx.registry;
     this.events = ctx.events;
+    this.systems = ctx.systems;
     const player = this.world.get("player");
     if (!player) {
       throw new Error("CraftingSystem: no player entity");
@@ -89,6 +93,15 @@ export class CraftingSystem implements System {
   }
 
   private giveItem(itemId: string, qty: number): void {
+    // Durable items must land in `inventoryInstances` (flat writes would make
+    // them undamageable and invisible to durability queries). The inventory
+    // system is the single grant path; the flat write below is only a
+    // fallback for bare-system test setups without it.
+    const inventorySystem = this.systems?.get<InventorySystem>("inventory");
+    if (inventorySystem) {
+      inventorySystem.give(itemId, qty);
+      return;
+    }
     const inv = this.inventory();
     const total = (inv[itemId] ?? 0) + qty;
     inv[itemId] = total;

+ 4 - 1
packages/core/src/systems/dialogue.ts

@@ -1,4 +1,5 @@
 import type { System, SystemInitCtx } from "./system.js";
+import type { SystemsHost } from "./host.js";
 import type { TimeSystem } from "./time.js";
 import type { World } from "../world/world.js";
 import type { WorldEventBus } from "../world/event-bus.js";
@@ -56,12 +57,14 @@ export class DialogueSystem implements System {
   private registry: ContentRegistry | undefined;
   private events: WorldEventBus | undefined;
   private time: TimeSystem | undefined;
+  private systems: SystemsHost | undefined;
 
   init(ctx: SystemInitCtx): void {
     this.world = ctx.world;
     this.registry = ctx.registry;
     this.events = ctx.events;
     this.time = ctx.systems?.get<TimeSystem>("time");
+    this.systems = ctx.systems;
   }
 
   evaluate(condition: ConditionExpr, ctx: EvalContext): boolean {
@@ -169,7 +172,7 @@ export class DialogueSystem implements System {
   }
 
   private runtimeCtx(payload?: unknown): BehaviorRuntimeCtx {
-    return { world: this.requireWorld(), events: this.events, time: this.time, payload, label: "dialogue" };
+    return { world: this.requireWorld(), events: this.events, time: this.time, systems: this.systems, payload, label: "dialogue" };
   }
 
   private requireWorld(): World {

+ 13 - 0
packages/core/src/systems/economy.ts

@@ -1,4 +1,6 @@
 import type { System, SystemInitCtx } from "./system.js";
+import type { SystemsHost } from "./host.js";
+import type { InventorySystem } from "./inventory.js";
 import type { World, Entity } from "../world/world.js";
 import type { WorldEventBus } from "../world/event-bus.js";
 import type { ContentRegistry } from "../content/registry.js";
@@ -38,11 +40,13 @@ export class EconomySystem implements System {
   private world: World | undefined;
   private registry: ContentRegistry | undefined;
   private events: WorldEventBus | undefined;
+  private systems: SystemsHost | undefined;
 
   init(ctx: SystemInitCtx): void {
     this.world = ctx.world;
     this.registry = ctx.registry;
     this.events = ctx.events;
+    this.systems = ctx.systems;
     const player = this.world.get("player");
     if (!player) {
       throw new Error("EconomySystem: no player entity");
@@ -128,6 +132,15 @@ export class EconomySystem implements System {
   }
 
   private addItems(itemId: string, qty: number): void {
+    // Durable items must land in `inventoryInstances` (flat writes would make
+    // them undamageable and invisible to durability queries). The inventory
+    // system is the single grant path; the flat write below is only a
+    // fallback for bare-system test setups without it.
+    const inventorySystem = this.systems?.get<InventorySystem>("inventory");
+    if (inventorySystem) {
+      inventorySystem.give(itemId, qty);
+      return;
+    }
     const player = this.player();
     const inventory = this.inventory(player);
     const total = (inventory[itemId] ?? 0) + qty;

+ 3 - 1
packages/core/src/systems/growth.ts

@@ -189,8 +189,10 @@ export class GrowthSystem implements System {
     if (!stage) return;
     if (stage.feedRequired && !animal.fed) return;
     if (stageIndex === def.stages.length - 1) {
-      this.emitAnimalProduce(animal, stageIndex);
+      // Reset the fed state BEFORE the event fires — handlers must read the
+      // post-produce animal (state-updated-then-emit convention).
       animal.fed = false;
+      this.emitAnimalProduce(animal, stageIndex);
       return;
     }
     const daysInStage = (animal.daysInStage ?? 0) + 1;

+ 9 - 1
packages/core/src/systems/host.ts

@@ -23,7 +23,15 @@ export class SystemsHost {
 
   emit(event: string, payload: unknown): void {
     for (const system of this.systems.values()) {
-      system.on?.(event, payload);
+      try {
+        system.on?.(event, payload);
+      } catch (err) {
+        // One system's event handler must not break the rest of the chain: a
+        // throw would propagate into the EMITTING system's step — e.g. a bad
+        // trigger action (content bug) during day-start would skip every later
+        // system's step for the whole tick (growth frozen while time advances).
+        console.error(`system ${system.id} on(${event}) failed:`, err);
+      }
     }
   }
 

+ 19 - 5
packages/core/src/systems/inventory.ts

@@ -114,12 +114,23 @@ export class InventorySystem implements System {
   }
 
   use(itemId: string): void {
-    const def = this.registry?.get<{ effects?: { consumable?: { stamina?: number | undefined; gold?: number | undefined } | undefined } | undefined }>("item", itemId);
+    const def = this.registry?.get<{ durability?: { max: number } | number | undefined; effects?: { consumable?: { stamina?: number | undefined; gold?: number | undefined } | undefined } | undefined }>("item", itemId);
     const effects = def?.effects?.consumable;
     if (!effects) {
       throw new Error(`inventory.use: ${itemId} is not consumable`);
     }
-    this.take(itemId, 1);
+    const durability = def?.durability;
+    const hasDurability = typeof durability === "object" && durability !== null
+      ? typeof durability.max === "number"
+      : typeof durability === "number";
+    // Consume one unit: a durable item decrements its durability (removed at
+    // 0 — the documented "use() decrements, removes at 0" contract), any other
+    // item is taken from the flat inventory. Nothing is consumed when the
+    // player has none — the effects must NOT apply and nothing emits.
+    const consumed = hasDurability ? this.damage(itemId, 1) : this.take(itemId, 1) > 0;
+    if (!consumed) {
+      throw new Error(`inventory.use: no ${itemId} in inventory`);
+    }
     const player = this.player() as Entity & Record<string, unknown>;
     const rawStamina = player["stamina"];
     const curStamina = typeof rawStamina === "number" ? rawStamina : 0;
@@ -145,11 +156,13 @@ export class InventorySystem implements System {
     return { max: first.max, current: first.current };
   }
 
-  damage(itemId: string, amount: number): void {
+  /** Damage one instance of a durable item. Returns whether any instance was
+   * actually damaged (false when the player has none). */
+  damage(itemId: string, amount: number): boolean {
     this.requireQty(amount);
     const instances = this.inventoryInstances();
     const arr = instances[itemId];
-    if (!arr || arr.length === 0) return;
+    if (!arr || arr.length === 0) return false;
     for (let i = 0; i < arr.length; i++) {
       const item = arr[i];
       if (!item) continue;
@@ -162,9 +175,10 @@ export class InventorySystem implements System {
           }
           this.events?.emit("item-break", { itemId });
         }
-        return;
+        return true;
       }
     }
+    return false;
   }
 
   private inventory(): Record<string, number> {

+ 17 - 1
packages/core/src/systems/planting.ts

@@ -1,4 +1,6 @@
 import type { System, SystemInitCtx } from "./system.js";
+import type { SystemsHost } from "./host.js";
+import type { InventorySystem } from "./inventory.js";
 import type { Entity } from "../world/world.js";
 import type { World } from "../world/world.js";
 import type { WorldEventBus } from "../world/event-bus.js";
@@ -28,12 +30,14 @@ export class PlantingSystem implements System {
   private registry: ContentRegistry | undefined;
   private events: WorldEventBus | undefined;
   private rng: { next(): number } = { next: () => Math.random() };
+  private systems: SystemsHost | undefined;
 
   init(ctx: SystemInitCtx): void {
     this.world = ctx.world;
     this.registry = ctx.registry;
     this.events = ctx.events;
     this.rng = ctx.rng ?? { next: () => Math.random() };
+    this.systems = ctx.systems;
   }
 
   till(x: number, y: number): PlotEntity {
@@ -108,12 +112,15 @@ export class PlantingSystem implements System {
     }
     const drops = rollDrops(crop.drops, this.rng);
     this.grantDrops(drops);
-    this.events?.emit("crop-harvest", { plotId: plot.id, x, y, cropItemId, drops });
+    // The world is mutated BEFORE the event fires: every other event follows
+    // the "state updated, then emit" convention, and crop-harvest handlers
+    // (save.snapshot, behavior hooks) must read the post-harvest plot.
     delete plot.cropItemId;
     delete plot.stageIndex;
     delete plot.daysInStage;
     delete plot.spriteRef;
     plot.watered = false;
+    this.events?.emit("crop-harvest", { plotId: plot.id, x, y, cropItemId, drops });
     return drops;
   }
 
@@ -152,6 +159,15 @@ export class PlantingSystem implements System {
   }
 
   private grantDrops(drops: CropDrop[]): void {
+    // Durable items must land in `inventoryInstances` (flat writes would make
+    // them undamageable and invisible to durability queries). The inventory
+    // system is the single grant path; the flat write below is only a
+    // fallback for bare-system test setups without it.
+    const inventorySystem = this.systems?.get<InventorySystem>("inventory");
+    if (inventorySystem) {
+      for (const drop of drops) inventorySystem.give(drop.itemId, drop.qty);
+      return;
+    }
     const player = this.world?.get("player");
     if (!player) return;
     let inventory: Record<string, number>;

+ 4 - 1
packages/core/src/systems/trigger.ts

@@ -1,4 +1,5 @@
 import type { System, SystemInitCtx } from "./system.js";
+import type { SystemsHost } from "./host.js";
 import type { TimeSystem } from "./time.js";
 import type { World, Entity } from "../world/world.js";
 import type { WorldEventBus } from "../world/event-bus.js";
@@ -38,6 +39,7 @@ export class TriggerSystem implements System {
   private registry: ContentRegistry | undefined;
   private events: WorldEventBus | undefined;
   private time: TimeSystem | undefined;
+  private systems: SystemsHost | undefined;
   private matching = false;
 
   init(ctx: SystemInitCtx): void {
@@ -45,6 +47,7 @@ export class TriggerSystem implements System {
     this.registry = ctx.registry;
     this.events = ctx.events;
     this.time = ctx.systems?.get<TimeSystem>("time");
+    this.systems = ctx.systems;
   }
 
   evaluate(condition: ConditionExpr, ctx: TriggerEvalCtx): boolean {
@@ -177,7 +180,7 @@ export class TriggerSystem implements System {
   }
 
   private runtimeCtx(payload?: unknown): BehaviorRuntimeCtx {
-    return { world: this.requireWorld(), events: this.events, time: this.time, payload, label: "trigger" };
+    return { world: this.requireWorld(), events: this.events, time: this.time, systems: this.systems, payload, label: "trigger" };
   }
 
   private requireWorld(): World {

+ 326 - 0
packages/core/test/bughunt-regressions.test.ts

@@ -0,0 +1,326 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { describe, expect, it } from "vitest";
+import {
+  ContentLoader,
+  ContentRegistry,
+  createEngine,
+  EconomySystem,
+  GrowthSystem,
+  InventorySystem,
+  PlantingSystem,
+  SystemsHost,
+  TriggerSystem,
+  World,
+  WorldEventBus,
+  type Entity,
+  type System,
+  type TimeSystem,
+} from "../src/index.js";
+
+/**
+ * Regression tests for the 2026-08-14 full-repo bug-fix round:
+ * - inventory.use with an empty inventory must not apply effects / emit
+ * - use() on a durable item decrements (removes at 0), never the whole stack
+ * - grant paths (economy/behavior/planting) route durable items into instances
+ * - a throwing system event handler must not break the tick chain
+ * - crop-harvest / animal-produce fire AFTER the world state is updated
+ * - watch() reloads assets whose same-name file CONTENT changed
+ */
+
+function bareSetup() {
+  const world = new World();
+  const registry = new ContentRegistry();
+  const events = new WorldEventBus();
+  const host = new SystemsHost();
+  world.create({ id: "player", type: "player", x: 0, y: 0, gold: 100, stamina: 50 });
+  registry.register("time", {
+    id: "default",
+    dayLengthTicks: 480,
+    seasons: [{ name: "spring", dayCount: 14 }],
+    start: { day: 1, season: "spring" },
+  });
+  const inv = new InventorySystem();
+  const plant = new PlantingSystem();
+  const growth = new GrowthSystem();
+  const eco = new EconomySystem();
+  const trigger = new TriggerSystem();
+  for (const system of [inv, plant, growth, eco, trigger]) host.register(system);
+  const ctx = { world, registry, events, systems: host };
+  inv.init(ctx);
+  plant.init(ctx);
+  growth.init(ctx);
+  eco.init(ctx);
+  trigger.init(ctx);
+  return { world, registry, events, host, inv, plant, growth, eco, trigger };
+}
+
+describe("inventory.use (bug-fix round)", () => {
+  it("throws on an empty inventory and applies NO effects / emits nothing", () => {
+    const { registry, events, inv, world } = bareSetup();
+    registry.register("item", {
+      id: "pumpkin",
+      name: "Pumpkin",
+      kind: "crop",
+      effects: { consumable: { stamina: 15 } },
+    });
+    const consumed: unknown[] = [];
+    events.on("item-consume", (p) => consumed.push(p));
+    const player = world.get("player") as Entity & Record<string, unknown>;
+    expect(() => inv.use("pumpkin")).toThrow(/no pumpkin in inventory/);
+    expect(player["stamina"]).toBe(50);
+    expect(consumed).toEqual([]);
+  });
+
+  it("decrements a durable item's durability instead of removing the whole instance", () => {
+    const { registry, inv, world } = bareSetup();
+    registry.register("item", {
+      id: "hoe",
+      name: "Hoe",
+      kind: "tool",
+      durability: { max: 100 },
+      effects: { consumable: { stamina: 10 } },
+    });
+    inv.give("hoe", 1);
+    inv.use("hoe");
+    expect(inv.count("hoe")).toBe(1); // the instance is still there
+    // "use() decrements, removes at 0" — one use = one durability point.
+    expect(inv.durability("hoe")).toEqual({ max: 100, current: 99 });
+    const player = world.get("player") as Entity & Record<string, unknown>;
+    expect(player["stamina"]).toBe(60);
+  });
+
+  it("removes the instance when durability reaches 0 and emits item-break", () => {
+    const { registry, events, inv } = bareSetup();
+    registry.register("item", {
+      id: "hoe",
+      name: "Hoe",
+      kind: "tool",
+      durability: { max: 100 },
+      effects: { consumable: { stamina: 10 } },
+    });
+    const broken: unknown[] = [];
+    events.on("item-break", (p) => broken.push(p));
+    inv.give("hoe", 1);
+    inv.damage("hoe", 99);
+    inv.use("hoe"); // 1 remaining durability consumed → instance breaks
+    expect(inv.count("hoe")).toBe(0);
+    expect(inv.durability("hoe")).toBeNull();
+    expect(broken).toEqual([{ itemId: "hoe" }]);
+  });
+
+  it("damage returns whether an instance was actually damaged", () => {
+    const { inv } = bareSetup();
+    expect(inv.damage("hoe", 1)).toBe(false);
+  });
+});
+
+describe("durable grants route through inventory instances (bug-fix round)", () => {
+  it("economy.buy of a durable item lands in inventoryInstances", () => {
+    const { registry, inv, eco } = bareSetup();
+    registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 } });
+    registry.register("shop", {
+      id: "tools",
+      name: "Tools",
+      sells: [{ itemId: "hoe", price: 50 }],
+      buys: [],
+    });
+    eco.buy("tools", "hoe", 1);
+    expect(inv.count("hoe")).toBe(1);
+    expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 });
+  });
+
+  it("trigger give-item of a durable item lands in inventoryInstances", () => {
+    const { registry, inv, trigger } = bareSetup();
+    registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 } });
+    registry.register("behavior", {
+      id: "grant-hoe",
+      when: { op: "exists", path: "player" },
+      fire: [{ action: "give-item", itemId: "hoe", qty: 1 }],
+    });
+    trigger.fire("grant-hoe");
+    expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 });
+  });
+});
+
+describe("event chain resilience (bug-fix round)", () => {
+  it("a throwing system event handler does not break the rest of the chain", async () => {
+    const seen: string[] = [];
+    const thrower: System = {
+      id: "thrower",
+      init() {},
+      on() {
+        throw new Error("boom");
+      },
+    };
+    const listener: System = {
+      id: "listener",
+      init() {},
+      on(event) {
+        if (event === "day-start") seen.push("day-start");
+      },
+    };
+    const engine = createEngine({ contentDir: repoContent, modsDir: repoMods, systems: [thrower, listener] });
+    await engine.start();
+    try {
+      expect(() => engine.tick(480)).not.toThrow();
+      expect(seen).toContain("day-start");
+    } finally {
+      await engine.stop();
+    }
+  });
+
+  it("a bad trigger action during day-start does not freeze the tick chain", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "nengine-badtrigger-"));
+    try {
+      await writeFile(
+        join(dir, "time.yaml"),
+        ["id: default", "dayLengthTicks: 480", "seasons:", "  - name: spring", "    dayCount: 14", "start:", "  day: 1", "  season: spring"].join("\n"),
+      );
+      await writeFile(
+        join(dir, "weather.yaml"),
+        ["id: default", "seasons:", "  - season: spring", "    weathers:", "      - id: sunny", "        weight: 100"].join("\n"),
+      );
+      await mkdir(join(dir, "maps"), { recursive: true });
+      await writeFile(
+        join(dir, "maps", "farm.yaml"),
+        ["id: farm", "width: 2", "height: 2", "tilesetRef: ts", "spawn:", "  x: 0", "  y: 0", "layers:", "  - name: ground", "    data:", "      - [grass, grass]", "      - [grass, grass]"].join("\n"),
+      );
+      await mkdir(join(dir, "behaviors"), { recursive: true });
+      await writeFile(
+        join(dir, "behaviors", "bad-trigger.yaml"),
+        [
+          "id: bad-trigger",
+          "when:",
+          "  op: gte",
+          "  path: time.day",
+          "  value: 1",
+          "fire:",
+          "  - action: add",
+          "    path: player.stamina",
+          "    value: 1",
+        ].join("\n"),
+      );
+      const seen: string[] = [];
+      const listener: System = {
+        id: "listener",
+        init() {},
+        on(event) {
+          if (event === "day-start") seen.push("day-start");
+        },
+      };
+      const engine = createEngine({ contentDir: dir, systems: [listener] });
+      await engine.start();
+      try {
+        // Make the add target a string so the action throws.
+        const player = engine.world.get("player") as Entity & Record<string, unknown>;
+        player["stamina"] = "high";
+        // Previously the throw aborted time.step mid-emit, skipping every
+        // later system step AND re-throwing out of engine.tick.
+        expect(() => engine.tick(480)).not.toThrow();
+        expect(seen).toContain("day-start");
+        // The engine keeps ticking afterwards.
+        expect(() => engine.tick(480)).not.toThrow();
+      } finally {
+        await engine.stop();
+      }
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe("state-updated-then-emit (bug-fix round)", () => {
+  it("crop-harvest fires AFTER the plot is cleared", () => {
+    const { registry, world, events, plant, inv } = bareSetup();
+    registry.register("crop", {
+      id: "carrot",
+      name: "Carrot",
+      stages: [
+        { name: "sprout", days: 1, waterRequired: false },
+        { name: "grown", days: 1, waterRequired: false },
+      ],
+      seasons: ["spring"],
+      drops: [{ itemId: "carrot", qty: 1 }],
+    });
+    registry.register("item", { id: "carrot", name: "Carrot", kind: "crop" });
+    world.create({ id: "plot:0:0", type: "plot", x: 0, y: 0, tilled: true, cropItemId: "carrot", stageIndex: 1, daysInStage: 1 });
+    let cropItemIdAtEvent: unknown = "unset";
+    events.on("crop-harvest", () => {
+      const plot = world.get("plot:0:0");
+      cropItemIdAtEvent = plot?.["cropItemId"];
+    });
+    plant.harvest(0, 0);
+    expect(cropItemIdAtEvent).toBeUndefined();
+    expect(inv.count("carrot")).toBe(1);
+  });
+
+  it("animal-produce fires AFTER the fed state is reset", () => {
+    const { registry, world, events, growth } = bareSetup();
+    registry.register("animal", {
+      id: "cow",
+      name: "Cow",
+      stages: [
+        { name: "calf", days: 1, feedRequired: true },
+        { name: "grown", days: 1, feedRequired: true },
+      ],
+    });
+    const animal = world.create({
+      id: "animal:cow:1",
+      type: "animal",
+      animalId: "cow",
+      x: 1,
+      y: 1,
+      stageIndex: 1,
+      fed: true,
+    });
+    let fedAtEvent: unknown = "unset";
+    events.on("animal-produce", () => {
+      fedAtEvent = world.get("animal:cow:1")?.["fed"];
+    });
+    growth.step(480); // a full day — produce only advances on day boundaries
+    expect(fedAtEvent).toBe(false);
+    expect(world.get("animal:cow:1")?.["fed"]).toBe(false);
+    void animal;
+  });
+});
+
+describe("watch() same-name content changes (bug-fix round)", () => {
+  it("reloads an asset whose file content changed under the same name", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "nengine-watchsize-"));
+    try {
+      const registry = new ContentRegistry();
+      const loader = new ContentLoader(registry);
+      const stop = loader.watch(dir);
+      await mkdir(join(dir, "assets"), { recursive: true });
+      await mkdir(join(dir, "maps"), { recursive: true });
+      await writeFile(
+        join(dir, "maps", "farm.yaml"),
+        ["id: farm", "width: 2", "height: 2", "tilesetRef: ts", "spawn:", "  x: 0", "  y: 0", "layers:", "  - name: ground", "    data:", "      - [grass, grass]", "      - [grass, grass]"].join("\n"),
+      );
+      const bytes = Buffer.from("AAAA", "utf8");
+      await writeFile(join(dir, "assets", "icon.png"), bytes);
+      await loader.load(dir);
+      const before = registry.assets.get("icon");
+      expect(before?.data).toBe(Buffer.from("AAAA", "utf8").toString("base64"));
+      // Overwrite the same file with DIFFERENT bytes (same name, new size).
+      await writeFile(join(dir, "assets", "icon.png"), Buffer.from("BBBBBBBB", "utf8"));
+      await waitFor(() => registry.assets.get("icon")?.data === Buffer.from("BBBBBBBB", "utf8").toString("base64"), 3000);
+      stop();
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});
+
+const repoContent = join(process.cwd(), "../..", "content");
+const repoMods = join(process.cwd(), "../..", "mods");
+
+async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
+  const start = Date.now();
+  while (!predicate()) {
+    if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout");
+    await new Promise((resolve) => setTimeout(resolve, 20));
+  }
+}

+ 33 - 1
packages/mcp/src/authoring.ts

@@ -7,6 +7,14 @@ import { errorResult, requireContentDir, textResult } from "./result.js";
 
 export type AuthoringResult = { ok: true; def: ContentDef<ContentKind> } | { ok: false; message: string };
 
+/** Content ids become filenames under `content/<kindDir>/` — a path separator
+ * or "."/".." would escape the content dir (arbitrary path write via a
+ * crafted id, all 18 kinds affected). Enforced at the authoring-layer choke
+ * point (create + update). */
+export function isSafeContentId(id: string): boolean {
+  return id.length > 0 && !id.includes("/") && !id.includes("\\") && id !== "." && id !== "..";
+}
+
 export function validateDef(kind: ContentKind, raw: unknown, tool: string): AuthoringResult {
   if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
     return { ok: false, message: `${tool}: def must be a JSON object` };
@@ -24,6 +32,9 @@ export function validateDef(kind: ContentKind, raw: unknown, tool: string): Auth
 }
 
 export async function writeContentFile(engine: Engine, kind: ContentKind, def: ContentDef<ContentKind>): Promise<void> {
+  if (!isSafeContentId(def.id)) {
+    throw new Error(`unsafe content id "${def.id}" — ids must not contain path separators`);
+  }
   const dir = resolve(requireContentDir(engine, kind), contentDirNames[kind]);
   await mkdir(dir, { recursive: true });
   await writeFile(resolve(dir, `${def.id}.yaml`), stringify(def), "utf8");
@@ -46,11 +57,29 @@ export async function updateContentFile(
   if (!existing) {
     return { ok: false, message: `${options.tool}.update: ${options.errorNoun} not found: ${id}` };
   }
+  if (!isSafeContentId(id)) {
+    return { ok: false, message: `${options.tool}.update: unsafe content id "${id}"` };
+  }
+  const patchId = patch["id"];
+  if (patchId !== undefined && patchId !== id) {
+    return {
+      ok: false,
+      message: `${options.tool}.update: renaming via patch.id is not supported — delete and create instead (patch id "${String(patchId)}" ≠ "${id}")`,
+    };
+  }
   const merged = { ...existing, ...patch, id };
   const parsed = validateDef(kind, merged, `${options.tool}.update`);
   if (!parsed.ok) return parsed;
   engine.registry.remove(kind, id);
-  await writeContentFile(engine, kind, parsed.def);
+  try {
+    await writeContentFile(engine, kind, parsed.def);
+  } catch (err) {
+    // Rollback: the write failed (permissions/disk/…) — the old file is still
+    // on disk, so the live registry entry must not be lost either. Restore it
+    // and rethrow (the caller surfaces the write error).
+    engine.registry.register(kind, existing);
+    throw err;
+  }
   return parsed;
 }
 
@@ -97,6 +126,9 @@ export function authoringTools(engine: Engine, kind: ContentKind, options: Autho
       handler: async (args) => {
         const parsed = validateDef(kind, args["def"], `${options.tool}.create`);
         if (!parsed.ok) return errorResult(parsed.message);
+        if (!isSafeContentId(parsed.def.id)) {
+          return errorResult(`${options.tool}.create: unsafe content id "${parsed.def.id}" — ids must not contain path separators`);
+        }
         await writeContentFile(engine, kind, parsed.def);
         return textResult(parsed.def);
       },

+ 3 - 2
packages/mcp/src/gateway.ts

@@ -78,12 +78,13 @@ export class McpGateway {
     if (!resolved) {
       return errorResult(this.notFoundMessage(name, "tool"));
     }
-    const missing = (resolved.tool.inputSchema.required ?? []).filter((key) => !(key in args));
+    const argsIn = args ?? {};
+    const missing = (resolved.tool.inputSchema.required ?? []).filter((key) => !(key in argsIn));
     if (missing.length > 0) {
       return errorResult(`tool ${name}: missing required arguments: ${missing.join(", ")}`);
     }
     try {
-      return await resolved.tool.handler(args);
+      return await resolved.tool.handler(argsIn);
     } catch (err) {
       return errorResult(err instanceof Error ? err.message : String(err));
     }

+ 165 - 0
packages/mcp/test/bughunt-regressions.test.ts

@@ -0,0 +1,165 @@
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createEngine } from "@nengine/core";
+import { createEngineNamespaces, McpGateway } from "../src/index.js";
+
+/**
+ * Regression tests for the 2026-08-14 full-repo bug-fix round:
+ * - content ids must never escape the content dir (path traversal)
+ * - a failed update write rolls the registry entry back
+ * - patch.id renames are rejected loudly, not silently ignored
+ * - callTool with undefined args returns a clean error result
+ */
+
+const TIME_YAML = [
+  "id: default",
+  "dayLengthTicks: 480",
+  "seasons:",
+  "  - { name: spring, dayCount: 28 }",
+  "start: { day: 1, season: spring }",
+  "",
+].join("\n");
+
+const FARM_YAML = [
+  "id: farm",
+  "width: 2",
+  "height: 2",
+  'tilesetRef: "tiles"',
+  "spawn: { x: 0, y: 0 }",
+  "layers:",
+  "  - name: ground",
+  "    data:",
+  "      - [a, b]",
+  "      - [b, a]",
+  "",
+].join("\n");
+
+interface Setup {
+  engine: ReturnType<typeof createEngine>;
+  gateway: McpGateway;
+  tmp: string;
+  close: () => Promise<void>;
+}
+
+async function setup(): Promise<Setup> {
+  const tmp = await mkdtemp(join(tmpdir(), "farm-bughunt-"));
+  await mkdir(join(tmp, "maps"), { recursive: true });
+  await writeFile(join(tmp, "time.yaml"), TIME_YAML);
+  await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
+
+  const engine = createEngine({ contentDir: tmp });
+  const gateway = new McpGateway();
+  for (const namespace of createEngineNamespaces(engine, { gateway })) {
+    gateway.register(namespace);
+  }
+  await engine.start();
+  return {
+    engine,
+    gateway,
+    tmp,
+    close: async () => {
+      await engine.stop();
+      await rm(tmp, { recursive: true, force: true });
+    },
+  };
+}
+
+async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
+  ok: boolean;
+  text: string;
+}> {
+  const result = await gateway.callTool(name, args);
+  return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
+}
+
+describe("authoring path traversal (bug-fix round)", () => {
+  it("item.create rejects an id with path separators — nothing is written outside content/", async () => {
+    const { gateway, tmp, close } = await setup();
+    try {
+      const result = await callText(gateway, "item.create", {
+        def: { id: "../../escape", name: "Escape", kind: "tool", stackable: false },
+      });
+      expect(result.ok).toBe(false);
+      expect(result.text).toMatch(/unsafe content id/);
+      expect(existsSync(join(tmp, "..", "escape.yaml"))).toBe(false);
+      expect(existsSync(join(tmp, "..", "escape"))).toBe(false);
+    } finally {
+      await close();
+    }
+  });
+
+  it("item.update rejects an unsafe target id", async () => {
+    const { gateway, close } = await setup();
+    try {
+      const result = await callText(gateway, "item.update", {
+        id: "../../escape",
+        patch: { name: "X" },
+      });
+      expect(result.ok).toBe(false);
+      // The traversal id can never be registered (create is guarded), so the
+      // update is rejected either as not-found or as unsafe — never written.
+      expect(result.text).toMatch(/unsafe content id|not found/);
+    } finally {
+      await close();
+    }
+  });
+
+  it("patch.id renames are rejected loudly, never silently ignored", async () => {
+    const { gateway, engine, close } = await setup();
+    try {
+      await callText(gateway, "item.create", {
+        def: { id: "hoe", name: "Hoe", kind: "tool", stackable: false },
+      });
+      const result = await callText(gateway, "item.update", {
+        id: "hoe",
+        patch: { id: "renamed-hoe", name: "Renamed" },
+      });
+      expect(result.ok).toBe(false);
+      expect(result.text).toMatch(/renaming via patch.id is not supported/);
+      // the original is untouched
+      expect(engine.registry.get("item", "hoe")?.name).toBe("Hoe");
+    } finally {
+      await close();
+    }
+  });
+});
+
+describe("update write-failure rollback (bug-fix round)", () => {
+  it("a failed write leaves the live registry entry intact (old file + old def)", async () => {
+    const { gateway, engine, tmp, close } = await setup();
+    try {
+      await callText(gateway, "item.create", {
+        def: { id: "hoe", name: "Hoe", kind: "tool", stackable: false },
+      });
+      // Replace the item file with a DIRECTORY so the rewrite fails (EISDIR).
+      await rm(join(tmp, "items", "hoe.yaml"));
+      await mkdir(join(tmp, "items", "hoe.yaml"));
+      const result = await callText(gateway, "item.update", {
+        id: "hoe",
+        patch: { name: "Renamed" },
+      });
+      expect(result.ok).toBe(false);
+      expect(result.text).toMatch(/EISDIR|illegal operation|directory/i);
+      // The registry entry survived the failed write.
+      expect(engine.registry.get("item", "hoe")?.name).toBe("Hoe");
+    } finally {
+      await close();
+    }
+  });
+});
+
+describe("gateway.callTool argument handling (bug-fix round)", () => {
+  it("undefined args return a clean error result, not a raw TypeError", async () => {
+    const { gateway, close } = await setup();
+    try {
+      const result = await gateway.callTool("item.get", undefined as never);
+      expect(result.isError).toBe(true);
+      expect(result.content[0]?.text).toMatch(/missing required arguments/);
+    } finally {
+      await close();
+    }
+  });
+});

+ 76 - 26
packages/render/src/sound.ts

@@ -72,9 +72,12 @@ export function createSoundPlayer(audioContextFactory?: () => AudioContext | und
   let destroyed = false;
   const buffers = new Map<string, AudioBuffer>();
   let bgmId: string | null = null;
-  let bgmGain: { gain: { setValueAtTime(t: number, v: number): void; linearRampToValueAtTime(t: number, v: number): void; value?: number }; connect(to: any): void } | null = null;
+  let bgmGain: GainNode | null = null;
   let bgmSource: { stop(): void; disconnect?: () => void } | null = null;
   let bgmVolumeTarget = 0.6;
+  /** True while an async BGM asset decode/fetch is in flight — a stopBgm or
+   * destroy cancels it so a stale source never starts after the stop. */
+  let pendingDecode = false;
 
   function context(): AudioContext | undefined {
     if (destroyed) return undefined;
@@ -185,12 +188,12 @@ export function createSoundPlayer(audioContextFactory?: () => AudioContext | und
   function playBgm(soundId: string, opts?: { fadeMs?: number }): void {
     const def = sounds.get(soundId);
     if (!def) return;
-    const fadeMs = opts?.fadeMs ?? (def as any).fadeMs ?? 200;
+    const fadeMs = opts?.fadeMs ?? def.fadeMs ?? 200;
     const ac = context();
     if (!ac) return;
 
     if (bgmSource && bgmGain) {
-      const currentValue = bgmGain.gain.value ?? 0;
+      const currentValue = bgmGain.gain.value;
       bgmGain.gain.setValueAtTime(currentValue, ac.currentTime);
       bgmGain.gain.linearRampToValueAtTime(0, ac.currentTime + fadeMs / 1000);
       const oldSource = bgmSource;
@@ -201,15 +204,20 @@ export function createSoundPlayer(audioContextFactory?: () => AudioContext | und
     }
 
     if (!bgmGain) {
-      const gainNode = ac.createGain();
-      bgmGain = { gain: gainNode.gain, connect: (to: any) => gainNode.connect(to) };
-      bgmGain.connect(ac.destination);
+      bgmGain = ac.createGain();
       bgmGain.gain.setValueAtTime(0, ac.currentTime);
+      bgmGain.connect(ac.destination);
     }
 
-    const targetVolume = clamp((def.volume as number | undefined) ?? bgmVolumeTarget, 0, 1);
+    const targetVolume = clamp(def.volume ?? bgmVolumeTarget, 0, 1);
     bgmVolumeTarget = targetVolume;
 
+    // `bgmId` only ever reflects an ACTUALLY-STARTED source: a def whose asset
+    // is missing/unfetchable/undecodable never claims the BGM slot, and a
+    // pending async decode is cancelled by stopBgm/destroy. Otherwise a silent
+    // "current BGM" id would stick forever (stopBgm could not clear it — the
+    // source never existed).
+    let started = false;
     const srcId = soundSrcAssetId(def);
     try {
       if (srcId !== undefined) {
@@ -220,27 +228,54 @@ export function createSoundPlayer(audioContextFactory?: () => AudioContext | und
             const source = ac.createBufferSource();
             source.buffer = cached;
             source.loop = true;
-            source.connect(bgmGain as any);
+            source.connect(bgmGain);
             source.start();
             bgmSource = { stop: () => source.stop(), disconnect: () => source.disconnect() };
-          } else if (asset.data !== undefined) {
-            const bytes = base64ToArrayBuffer(asset.data);
-            void ac.decodeAudioData(bytes).then((buffer) => {
-              buffers.set(srcId, buffer);
-              const source = ac.createBufferSource();
-              source.buffer = buffer;
-              source.loop = true;
-              source.connect(bgmGain as any);
-              source.start();
-              bgmSource = { stop: () => source.stop(), disconnect: () => source.disconnect() };
-            }).catch(() => {});
+            started = true;
+          } else {
+            const startFromDecoded = (bytes: ArrayBuffer): void => {
+              pendingDecode = true;
+              void ac
+                .decodeAudioData(bytes)
+                .then((buffer) => {
+                  buffers.set(srcId, buffer);
+                  if (!pendingDecode || destroyed || !bgmGain) {
+                    pendingDecode = false;
+                    return;
+                  }
+                  pendingDecode = false;
+                  const source = ac.createBufferSource();
+                  source.buffer = buffer;
+                  source.loop = true;
+                  source.connect(bgmGain);
+                  source.start();
+                  bgmSource = { stop: () => source.stop(), disconnect: () => source.disconnect() };
+                  bgmId = soundId;
+                  bgmGain.gain.setValueAtTime(bgmGain.gain.value, ac.currentTime);
+                  bgmGain.gain.linearRampToValueAtTime(targetVolume, ac.currentTime + fadeMs / 1000);
+                })
+                .catch(() => {
+                  pendingDecode = false;
+                });
+            };
+            if (asset.data !== undefined) {
+              startFromDecoded(base64ToArrayBuffer(asset.data));
+            } else if (asset.file !== undefined && assetsBase !== undefined) {
+              pendingDecode = true;
+              fetch(resolveAssetUrl(assetsBase, asset.file))
+                .then((res) => {
+                  if (!res.ok) throw new Error(`failed to fetch audio asset ${asset.file}: ${res.status}`);
+                  return res.arrayBuffer();
+                })
+                .then(startFromDecoded)
+                .catch(() => {});
+            }
           }
         }
       } else {
         const params = soundToAudioParams(def);
         if (params) {
           const osc = ac.createOscillator();
-          const gainForParams = bgmGain!;
           osc.type = params.wave;
           osc.frequency.setValueAtTime(params.freq, ac.currentTime);
           if (params.slideTo !== undefined) {
@@ -250,7 +285,7 @@ export function createSoundPlayer(audioContextFactory?: () => AudioContext | und
           // simple chord progression via scheduled frequency transitions at
           // fixed intervals, but never prevent the fade-out stop timer from
           // being the authoritative owner of bgmSource/bgmId state.
-          osc.connect(gainForParams as any);
+          osc.connect(bgmGain);
           osc.start();
           let interval: ReturnType<typeof setInterval> | undefined;
           const duration = Math.max(150, Math.round(params.durationMs || 800));
@@ -273,22 +308,36 @@ export function createSoundPlayer(audioContextFactory?: () => AudioContext | und
             },
             disconnect: () => { try { osc.disconnect(); } catch {} },
           };
+          started = true;
         }
       }
     } catch {
       // never crash the viewport on playback
     }
 
-    bgmGain.gain.setValueAtTime(bgmGain.gain.value ?? 0, ac.currentTime);
-    bgmGain.gain.linearRampToValueAtTime(targetVolume, ac.currentTime + fadeMs / 1000);
-    bgmId = soundId;
+    if (started) {
+      bgmGain.gain.setValueAtTime(bgmGain.gain.value, ac.currentTime);
+      bgmGain.gain.linearRampToValueAtTime(targetVolume, ac.currentTime + fadeMs / 1000);
+      bgmId = soundId;
+    } else {
+      // Nothing started (and none is pending decode): the previous source is
+      // already fading out — a stale id must not survive to claim a BGM that
+      // is not playing.
+      bgmId = null;
+    }
   }
 
   function stopBgm(fadeMs: number = 200): void {
-    if (!bgmSource || !bgmGain) return;
+    pendingDecode = false;
+    if (!bgmSource || !bgmGain) {
+      // Nothing is actually playing — clear any stale id (e.g. a playBgm whose
+      // asset never started). Nothing to fade out.
+      bgmId = null;
+      return;
+    }
     const ac = ctx;
     if (!ac) return;
-    bgmGain.gain.setValueAtTime(bgmGain.gain.value ?? 0, ac.currentTime);
+    bgmGain.gain.setValueAtTime(bgmGain.gain.value, ac.currentTime);
     bgmGain.gain.linearRampToValueAtTime(0, ac.currentTime + fadeMs / 1000);
     const source = bgmSource;
     setTimeout(() => {
@@ -348,6 +397,7 @@ export function createSoundPlayer(audioContextFactory?: () => AudioContext | und
     },
     destroy(): void {
       destroyed = true;
+      pendingDecode = false;
       if (bgmSource) {
         try { bgmSource.stop(); } catch {}
         try { bgmSource.disconnect?.(); } catch {}

+ 8 - 0
packages/render/src/sync/local.ts

@@ -51,6 +51,12 @@ export function connectLocal(engine: Engine, viewport: ViewportHandle, opts?: Lo
   const unsubscribeSound = engine.events.on("sound-play", (payload) => {
     sound?.onEvent({ event: "sound-play", payload });
   });
+  const unsubscribeBgmPlay = engine.events.on("bgm-play", (payload) => {
+    sound?.onEvent({ event: "bgm-play", payload });
+  });
+  const unsubscribeBgmStop = engine.events.on("bgm-stop", (payload) => {
+    sound?.onEvent({ event: "bgm-stop", payload });
+  });
   const unsubscribeParticle = engine.events.on("particle", (payload) => {
     viewport.onEvent({ event: "particle", payload });
   });
@@ -77,6 +83,8 @@ export function connectLocal(engine: Engine, viewport: ViewportHandle, opts?: Lo
     },
     destroy(): void {
       unsubscribeSound();
+      unsubscribeBgmPlay();
+      unsubscribeBgmStop();
       unsubscribeParticle();
       label = undefined;
     },

+ 7 - 2
packages/render/src/sync/ws.ts

@@ -56,6 +56,7 @@ export function connectWs(opts: WsSyncOptions): WsSyncHandle {
   let label: ((label: string) => void) | undefined;
   let time: { day?: number; season?: string } | undefined;
   let destroyed = false;
+  let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
   const coordinator = new AssetCoordinator();
   let lastSnapshot: WsSnapshotFrame | undefined;
   let loadedAssets: Record<string, AssetEntry> = {};
@@ -121,7 +122,7 @@ export function connectWs(opts: WsSyncOptions): WsSyncHandle {
       } else if (typed.type === "event") {
         const eventFrame = frame as WsEventFrame;
         applyTime(eventFrame.payload);
-        if (eventFrame.event === "sound-play") {
+        if (eventFrame.event === "sound-play" || eventFrame.event === "bgm-play" || eventFrame.event === "bgm-stop") {
           sound?.onEvent(eventFrame);
         }
         if (eventFrame.event === "particle") {
@@ -132,7 +133,7 @@ export function connectWs(opts: WsSyncOptions): WsSyncHandle {
       }
     };
     ws.onclose = () => {
-      if (!destroyed) setTimeout(connect, RECONNECT_MS);
+      if (!destroyed) reconnectTimer = setTimeout(connect, RECONNECT_MS);
     };
     ws.onerror = () => ws.close();
   }
@@ -159,6 +160,10 @@ export function connectWs(opts: WsSyncOptions): WsSyncHandle {
     },
     destroy(): void {
       destroyed = true;
+      if (reconnectTimer !== undefined) {
+        clearTimeout(reconnectTimer);
+        reconnectTimer = undefined;
+      }
       socket?.close();
     },
   };

+ 20 - 4
packages/render/src/viewport.ts

@@ -379,6 +379,9 @@ export async function createViewport(
 
   function spawnBurst(def: ParticleDef, xPx: number, yPx: number): void {
     activeParticles.push(...particleSpawn(def, xPx, yPx));
+    // A particle burst arriving while the loop is stopped must restart it
+    // (onEvent("particle") does not go through render()).
+    startAnimLoop();
     drawParticles();
   }
 
@@ -496,9 +499,7 @@ export async function createViewport(
         const sprite = animated?.sprite ?? resolveSprite(entity, spriteCatalog);
         return sprite !== undefined && isAnimatedSprite(sprite);
       }) ?? false) === true;
-    if (rafId === undefined) {
-      rafId = requestAnimationFrame(animTick);
-    }
+    startAnimLoop();
   }
 
   function animationPhase(): number {
@@ -513,6 +514,14 @@ export async function createViewport(
     return phase;
   }
 
+  /** Start (or keep) the animation loop. `animLast` resets so the first tick
+   * after a stop never sees a huge stale delta. */
+  function startAnimLoop(): void {
+    if (rafId !== undefined) return;
+    animLast = 0;
+    rafId = requestAnimationFrame(animTick);
+  }
+
   function animTick(now: number): void {
     if (animLast === 0) animLast = now;
     const dtMs = now - animLast;
@@ -527,7 +536,14 @@ export async function createViewport(
         draw();
       }
     }
-    rafId = requestAnimationFrame(animTick);
+    // Stop the loop when nothing animates — a fully static scene must not burn
+    // a 60fps RAF forever (it restarts on the next render()/particle spawn).
+    const weatherActive = weatherState?.particleId !== undefined && map !== undefined;
+    if ((animActive && snapshot) || tweening || activeParticles.length > 0 || weatherActive) {
+      rafId = requestAnimationFrame(animTick);
+    } else {
+      rafId = undefined;
+    }
   }
 
   /** Detect consecutive tile-coordinate changes and start a movement tween

+ 36 - 0
packages/render/test/sound.test.ts

@@ -291,4 +291,40 @@ describe("createSoundPlayer (event wiring + lazy AudioContext)", () => {
 
     player.destroy();
   });
+
+  it("playBgm with a missing asset never claims the BGM slot and stopBgm clears cleanly", () => {
+    const player = createSoundPlayer(fakeAudioContext);
+    player.setCatalog([{ id: "ghost", src: "missing-asset" }]);
+    player.setAssets({});
+    player.playBgm("ghost");
+    expect(player.currentBgm()).toBeNull(); // nothing started — no stale id
+    player.stopBgm(100);
+    expect(player.currentBgm()).toBeNull();
+    player.destroy();
+  });
+
+  it("a src whose asset cannot be decoded stays silent and stops cleanly", async () => {
+    const player = createSoundPlayer(fakeAudioContext); // fake decodeAudioData throws
+    player.setCatalog([{ id: "track1", src: "res-1" }]);
+    player.setAssets({ "res-1": { kind: "audio", mime: "audio/ogg", data: "bm90LW9nZzE=" } });
+    player.playBgm("track1");
+    await vi.runAllTimersAsync(); // flush the failing decode promise
+    expect(player.currentBgm()).toBeNull();
+    player.stopBgm(100);
+    expect(player.currentBgm()).toBeNull();
+    player.destroy();
+  });
+
+  it("a failed BGM does not prevent a later valid BGM from playing", () => {
+    const player = createSoundPlayer(fakeAudioContext);
+    player.setCatalog([
+      { id: "ghost", src: "missing-asset" },
+      { id: "real", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } },
+    ]);
+    player.playBgm("ghost");
+    player.playBgm("real");
+    expect(player.currentBgm()).toBe("real");
+    player.destroy();
+  });
 });
+

+ 23 - 0
packages/render/test/sync-local.test.ts

@@ -165,3 +165,26 @@ describe("connectLocal sync handle", () => {
     }
   });
 });
+
+describe("connectLocal sound wiring (bug-fix round)", () => {
+  it("forwards bgm-play and bgm-stop engine events to the sound player", async () => {
+    const { engine, close } = await setup();
+    try {
+      const calls: { event: string; payload: unknown }[] = [];
+      const sound = {
+        setCatalog(): void {},
+        setAssets(): void {},
+        onEvent: (frame: { event: string; payload: unknown }) => calls.push(frame),
+      } as never;
+      connectLocal(engine, fakeViewport().viewport, { sound });
+      engine.events.emit("bgm-play", { soundId: "bgm-spring", fadeMs: 1500 });
+      engine.events.emit("bgm-stop", { fadeMs: 800 });
+      expect(calls).toEqual([
+        { event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } },
+        { event: "bgm-stop", payload: { fadeMs: 800 } },
+      ]);
+    } finally {
+      await close();
+    }
+  });
+});

+ 104 - 0
packages/render/test/sync-ws.test.ts

@@ -0,0 +1,104 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { connectWs } from "../src/sync/ws.js";
+import type { WsSyncOptions } from "../src/sync/ws.js";
+import type { RenderSnapshot, ViewportHandle } from "../src/viewport.js";
+
+/** Minimal WebSocket stand-in with instance tracking for reconnect tests. */
+class FakeWebSocket {
+  static instances: FakeWebSocket[] = [];
+  static OPEN = 1;
+  static CONNECTING = 0;
+  readyState = FakeWebSocket.CONNECTING;
+  onopen: (() => void) | null = null;
+  onmessage: ((event: { data: string }) => void) | null = null;
+  onclose: (() => void) | null = null;
+  onerror: (() => void) | null = null;
+  sent: string[] = [];
+  constructor(public url: string) {
+    FakeWebSocket.instances.push(this);
+  }
+  send(data: string): void {
+    this.sent.push(data);
+  }
+  close(): void {
+    this.readyState = 3; // CLOSED
+  }
+  open(): void {
+    this.readyState = FakeWebSocket.OPEN;
+    this.onopen?.();
+  }
+  receive(frame: unknown): void {
+    this.onmessage?.({ data: JSON.stringify(frame) });
+  }
+  drop(): void {
+    this.onclose?.();
+  }
+}
+
+function fakeViewport(): ViewportHandle {
+  return {
+    render(_snapshot?: RenderSnapshot): void {},
+    tileAt(): { x: number; y: number } | null {
+      return null;
+    },
+    resize(): void {},
+    destroy(): void {},
+  };
+}
+
+function opts(overrides: Partial<WsSyncOptions> = {}): WsSyncOptions {
+  return { url: "ws://127.0.0.1:1/mcp", viewport: fakeViewport(), ...overrides };
+}
+
+beforeEach(() => {
+  vi.useFakeTimers();
+  FakeWebSocket.instances = [];
+  vi.stubGlobal("WebSocket", FakeWebSocket);
+});
+
+afterEach(() => {
+  vi.unstubAllGlobals();
+  vi.useRealTimers();
+});
+
+describe("connectWs sound forwarding (bug-fix round)", () => {
+  it("forwards bgm-play and bgm-stop event frames to the sound player", () => {
+    const calls: { event: string; payload: unknown }[] = [];
+    const sound = { onEvent: (frame: { event: string; payload: unknown }) => calls.push(frame) } as never;
+    connectWs(opts({ sound }));
+    const socket = FakeWebSocket.instances[0]!;
+    socket.open();
+    socket.receive({ type: "event", event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } });
+    socket.receive({ type: "event", event: "bgm-stop", payload: { fadeMs: 800 } });
+    expect(calls).toEqual([
+      { type: "event", event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } },
+      { type: "event", event: "bgm-stop", payload: { fadeMs: 800 } },
+    ]);
+  });
+});
+
+describe("connectWs reconnect lifecycle (bug-fix round)", () => {
+  it("destroy() cancels a queued reconnect — no socket leaks after teardown", () => {
+    const handle = connectWs(opts());
+    const first = FakeWebSocket.instances[0]!;
+    first.open();
+    first.drop(); // schedule reconnect
+    expect(FakeWebSocket.instances).toHaveLength(1);
+    handle.destroy();
+    vi.advanceTimersByTime(600);
+    expect(FakeWebSocket.instances).toHaveLength(1); // no second socket created
+  });
+
+  it("reconnects while alive and keeps working after a reconnect", () => {
+    const handle = connectWs(opts());
+    const first = FakeWebSocket.instances[0]!;
+    first.open();
+    first.drop();
+    vi.advanceTimersByTime(500);
+    expect(FakeWebSocket.instances).toHaveLength(2);
+    const second = FakeWebSocket.instances[1]!;
+    second.open();
+    second.receive({ type: "event", event: "bgm-stop", payload: {} });
+    handle.destroy();
+  });
+});

+ 0 - 1
packages/ui/src/dialogue-box.ts

@@ -12,7 +12,6 @@ export interface DialogueState {
 type EventHandler = {
   choose: (index: number) => void;
   'skip-typing': () => void;
-  closed: () => void;
 };
 
 export class DialogueBox {

+ 15 - 0
packages/ui/src/hud-bar.test.ts

@@ -41,4 +41,19 @@ describe('HudBar', () => {
     const badge = host.querySelector('.nui-badge');
     expect(badge).not.toBeNull();
   });
+  it('bug-fix: unknown (data-driven) seasons/weather render the raw id, never undefined', () => {
+    const bar = new HudBar();
+    const host = document.createElement('div');
+    document.body.appendChild(host);
+    bar.mount(host);
+    bar.update({ day: 3, season: 'dry' as never, gold: 10, stamina: { cur: 5, max: 10 }, weather: 'storm' as never });
+    const dateEl = host.querySelector('.nui-hud-date');
+    const text = dateEl?.textContent ?? '';
+    expect(text).toContain('Day 3 /');
+    expect(text).not.toContain('undefined');
+    const weatherEl = host.querySelector('.nui-hud-weather');
+    expect(weatherEl?.textContent).toBe('?');
+    bar.unmount();
+    host.remove();
+  });
 });

+ 11 - 5
packages/ui/src/hud-bar.ts

@@ -3,11 +3,15 @@ export type WeatherKind = "sunny" | "cloudy" | "rain" | "snow" | "unknown";
 
 export interface HudBarState {
   day: number;
-  season: Season;
+  /** The engine's season id — a free string (data-driven content); the
+   * `Season` literals are the known values, anything else renders the raw id
+   * with a generic emoji instead of "undefined". */
+  season: Season | (string & {});
   gold: number;
   stamina: { cur: number; max: number };
   selectedSeed?: string | null;
-  weather?: WeatherKind;
+  /** The engine's weather id — same free-string contract as `season`. */
+  weather?: WeatherKind | (string & {});
   festival?: boolean;
 }
 
@@ -111,8 +115,10 @@ export class HudBar {
     this.state = state;
 
     if (this.dateEl) {
-      const seasonName = SEASON_NAMES[state.season];
-      const seasonEmoji = SEASON_EMOJIS[state.season];
+      // Unknown (data-driven) seasons/weathers render their raw id — never
+      // "undefined".
+      const seasonName = SEASON_NAMES[state.season as Season] ?? state.season;
+      const seasonEmoji = SEASON_EMOJIS[state.season as Season] ?? "🌾";
       this.dateEl.textContent = `Day ${state.day} / ${seasonEmoji} ${seasonName}`;
     }
 
@@ -138,7 +144,7 @@ export class HudBar {
 
     if (this.weatherEl) {
       if (state.weather) {
-        this.weatherEl.textContent = WEATHER_EMOJIS[state.weather];
+        this.weatherEl.textContent = WEATHER_EMOJIS[state.weather as WeatherKind] ?? '?';
         this.weatherEl.style.display = '';
       } else {
         this.weatherEl.style.display = 'none';

+ 14 - 0
packages/ui/src/inventory-panel.test.ts

@@ -134,4 +134,18 @@ describe("InventoryPanel", () => {
     expect(host.querySelector(".nui-inv-icon")).not.toBeNull();
     expect(host.querySelector(".nui-inv-tooltip")).not.toBeNull();
   });
+  it("bug-fix: durability max=0 renders a 0% bar, never Infinity%", () => {
+    const panel = new InventoryPanel();
+    panel.mount(host);
+    panel.update({
+      items: [{ itemId: "broken", name: "Broken", qty: 1, durability: { max: 0, current: 0 } }],
+      activeCategory: "all",
+    });
+    const cell = host.querySelector<HTMLElement>(".nui-inv-cell");
+    cell!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+    const fill = host.querySelector<HTMLElement>(".nui-inv-tooltip-dur-fill");
+    expect(fill).not.toBeNull();
+    expect(fill!.style.width).toBe("0%");
+    panel.unmount();
+  });
 });

+ 14 - 3
packages/ui/src/inventory-panel.ts

@@ -233,7 +233,7 @@ export class InventoryPanel {
     });
   }
 
-  private showTooltip(item: InventoryItem, _e: MouseEvent): void {
+  private showTooltip(item: InventoryItem, event: MouseEvent): void {
     if (!this.tooltipEl) return;
     this.tooltipEl.innerHTML = "";
 
@@ -274,7 +274,7 @@ export class InventoryPanel {
       durBar.className = "nui-inv-tooltip-dur-bar";
       const durFill = document.createElement("div");
       durFill.className = "nui-inv-tooltip-dur-fill";
-      const pct = (item.durability.current / item.durability.max) * 100;
+      const pct = item.durability.max > 0 ? (item.durability.current / item.durability.max) * 100 : 0;
       durFill.style.width = `${pct}%`;
       durBar.appendChild(durFill);
       durWrap.appendChild(durBar);
@@ -282,6 +282,16 @@ export class InventoryPanel {
     }
 
     this.tooltipEl.style.display = "";
+    // Position near the pointer (the tooltip is absolutely positioned inside
+    // the panel — .nui-inv-panel is position:relative), clamped into the panel.
+    const parent = this.tooltipEl.offsetParent as HTMLElement | null;
+    if (parent) {
+      const rect = parent.getBoundingClientRect();
+      const left = event.clientX - rect.left + 14;
+      const top = event.clientY - rect.top + 14;
+      this.tooltipEl.style.left = `${Math.min(left, Math.max(0, rect.width - 200))}px`;
+      this.tooltipEl.style.top = `${Math.min(top, Math.max(0, rect.height - 120))}px`;
+    }
   }
 
   private hideTooltip(): void {
@@ -338,7 +348,7 @@ export class InventoryPanel {
       durBar.className = "nui-inv-detail-dur-bar";
       const durFill = document.createElement("div");
       durFill.className = "nui-inv-detail-dur-fill";
-      const pct = (item.durability.current / item.durability.max) * 100;
+      const pct = item.durability.max > 0 ? (item.durability.current / item.durability.max) * 100 : 0;
       durFill.style.width = `${pct}%`;
       durBar.appendChild(durFill);
       durWrap.appendChild(durBar);
@@ -383,6 +393,7 @@ export class InventoryPanel {
   padding: 16px;
   border-radius: 8px;
   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+  position: relative;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
   position: relative;
 }

+ 49 - 0
packages/ui/src/shop-panel.test.ts

@@ -54,4 +54,53 @@ describe('ShopPanel', () => {
     expect(plus1After!.disabled).toBe(true);
     expect(plus5After!.disabled).toBe(true);
   });
+  it('bug-fix: stock 0 disables both quantity buttons; partial stock limits +5', () => {
+    const panel = new ShopPanel();
+    panel.mount(host);
+    const initialState: ShopPanelState = {
+      shopId: 'farm-shop',
+      shopName: 'Farm Shop',
+      playerGold: 100,
+      sells: [
+        { itemId: 'carrot-seed', name: 'Seed', price: 2, owned: 0, stock: 0 },
+      ],
+      buys: [],
+    };
+    panel.update(initialState);
+    const row = host.querySelector('.nui-shop-row');
+    const buttons = Array.from(row?.querySelectorAll('button.nui-shop-btn') ?? []) as HTMLButtonElement[];
+    expect(buttons[0]?.disabled).toBe(true); // +1 with stock 0
+    expect(buttons[1]?.disabled).toBe(true); // +5 with stock 0
+    panel.update({
+      ...initialState,
+      sells: [{ itemId: 'carrot-seed', name: 'Seed', price: 2, owned: 0, stock: 2 }],
+    });
+    const buttons2 = Array.from(host.querySelector('.nui-shop-row')?.querySelectorAll('button.nui-shop-btn') ?? []) as HTMLButtonElement[];
+    expect(buttons2[0]?.disabled).toBe(false); // +1 with stock 2
+    expect(buttons2[1]?.disabled).toBe(true); // +5 needs 5
+    panel.update({
+      ...initialState,
+      sells: [{ itemId: 'carrot-seed', name: 'Seed', price: 2, owned: 0, stock: 7 }],
+    });
+    const buttons3 = Array.from(host.querySelector('.nui-shop-row')?.querySelectorAll('button.nui-shop-btn') ?? []) as HTMLButtonElement[];
+    expect(buttons3[0]?.disabled).toBe(false);
+    expect(buttons3[1]?.disabled).toBe(false);
+    panel.unmount();
+  });
+
+  it('bug-fix: undefined stock means unlimited (gold is the only gate)', () => {
+    const panel = new ShopPanel();
+    panel.mount(host);
+    panel.update({
+      shopId: 'farm-shop',
+      shopName: 'Farm Shop',
+      playerGold: 100,
+      sells: [{ itemId: 'carrot-seed', name: 'Seed', price: 2, owned: 0 }],
+      buys: [],
+    });
+    const buttons = Array.from(host.querySelector('.nui-shop-row')?.querySelectorAll('button.nui-shop-btn') ?? []) as HTMLButtonElement[];
+    expect(buttons[0]?.disabled).toBe(false);
+    expect(buttons[1]?.disabled).toBe(false);
+    panel.unmount();
+  });
 });

+ 4 - 2
packages/ui/src/shop-panel.ts

@@ -173,7 +173,9 @@ export class ShopPanel {
       const plus1 = document.createElement('button');
       plus1.className = 'nui-shop-btn';
       plus1.textContent = '+1';
-      plus1.disabled = playerGold < row.price;
+      // A numeric stock limits the buyable quantity (stock 0 = sold out);
+      // undefined/null stock means unlimited.
+      plus1.disabled = playerGold < row.price || (typeof row.stock === 'number' && row.stock <= 0);
       plus1.addEventListener('click', () => {
         this.emit('buy', row.itemId, 1);
       });
@@ -182,7 +184,7 @@ export class ShopPanel {
       const plus5 = document.createElement('button');
       plus5.className = 'nui-shop-btn';
       plus5.textContent = '+5';
-      plus5.disabled = playerGold < row.price * 5;
+      plus5.disabled = playerGold < row.price * 5 || (typeof row.stock === 'number' && row.stock < 5);
       plus5.addEventListener('click', () => {
         this.emit('buy', row.itemId, 5);
       });

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików