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