# Engine API Reference The living API doc, organized **by MCP service** (MCP-first: `mcp.md` is the contract; internals exist to serve it). Every entry notes which MCP tools it serves. > **Living-doc rule (D-op2)**: any interface change — MCP tool signature, engine API, data schema — MUST update this file and `docs/mcp.md` in the same change. Signatures below are the pre-implementation draft; they anchor to the MCP surface, so drift during implementation is expected and must be reflected here. ## Conventions - All engine types are zod-derived from the normalized JSON shape (D4/D18). - Systems are pure functions over entity data (D11); they receive `dt`, mutate `world`, and are the only place simulation happens. - Mods never import these modules (D6); they see only `HookContext`. --- ## 1. Engine Facade (`packages/core/src/engine.ts`) | API | Signature | Serves | |---|---|---| | `createEngine` | `(opts: EngineOptions) => Engine` — `EngineOptions` = exactly one content source — `{contentDir: string}` (fs, dev/authoring/headless) **or** `{bundle: ContentBundle}` (in-memory, packaged static site, D30) — plus `modsDir?: string; systems?: System[]; tickIntervalMs?: number; tickDeltaTicks?: number; seed?: number; enabledMods?: string[]` (F1: the enabled-Mods list — `undefined` = all enabled, backward compatible; unknown ids ignored) | `server.info` | | `engine.start()` | `() => Promise` — load content (fs `load` with `contentDir`, or `loadBundle` with `bundle` — no fs access in bundle mode), validate, boot systems, then **`seedScenes` (D40.9)** — every registered `scene` def instantiates in entity order (buildings via `PlacementSystem.place` with footprint/overlap/buildability validation, NPCs as direct entities, animals via `GrowthSystem.spawnAnimal`; a bad reference or placement conflict FAILS boot loudly), then load Mods (skipped when `modsDir` is absent); when `tickIntervalMs` is set, starts the optional auto-tick interval (D23). Seeding (D26/D29): the player entity is created with `spriteRef: "player"` (the engine's default appearance reference) plus the D29 field-level stamina/skills seed `stamina: 100`, `skills: {}` (plain data fields — no `StaminaSystem`/`SkillSystem`; the behavior language + Mods own the rules, D19) | all creation tools | | `engine.stop()` | `() => Promise` — orderly shutdown, flush per save policy, clear the auto-tick interval | — | | `engine.tick(dt)` | `(dt: number) => void` — the **authoritative simulation driver** (D23): validates `dt` (positive number of ticks), then runs every system's `step(dt)` through `SystemsHost` in registration order. TimeSystem's `step` advances the clock (firing `day-start`/`season-change` boundaries as before) and emits the `time-tick` bus event `{day, season, tickInDay}`; GrowthSystem's `step` accumulates day-fractions against `dayLengthTicks` and advances crops/animals on day boundaries. Explicit stepping keeps headless MCP/debug/tests deterministic (no render loop — D8) | `time.tick` | | `engine.rng` | `RandomSource` (readonly) — the engine's **single deterministic RNG** (D28): `{ next(): number /* [0,1) */, nextInt(max): number /* [0,max) */, setSeed(seed): void, state(): {seed, calls} }`. Seeded from `createEngine({seed})` or a fresh `randomSeed()` when omitted; systems draw from it via `SystemInitCtx.rng`, so same seed → same world randomness. `save.snapshot` carries `rngState`; `save.restore` re-anchors it. Seeds normalize to uint32; `state().seed` is the anchor, `calls` counts draws since it | `rng.*` | | `engine.contentDir` | `string \| undefined` — content root directory (fs mode); `undefined` in bundle mode (D30). Creation tools write `content//` relative to it — a bundle-mode engine is read-only (the MCP creation namespaces throw a clear error on `undefined`) | `item.create` / `item.update` | | `engine.modsDir` | `string \| undefined` — Mods root directory (`mods//index.mjs`); `undefined` when no Mods configured | `mod.*` | | `engine.enabledMods` | `string[] \| undefined` (F1, readonly) — the `enabledMods` filter passed to `createEngine` (the `mods.enabled` list from `project.yaml` in editor context); `undefined` = all enabled. Mods whose id is NOT in the list are never bound and never appear in `engine.mods`/`reloadMods` results (unknown ids in the list are ignored) | `mod.list` / `mod.status` | | `engine.mods` | `ModStatus[]` — the loaded Mod statuses (live; replaced on `reloadMods`): `{ id, version, loaded, hooks: string[], errors? }` | `mod.list` / `mod.status` | | `engine.world` | `World` — entity store + queries | debug tools | | `engine.registry` | `ContentRegistry` — validated content by id | `*.get` / `*.list` / `*.catalog` | | `engine.assets` | `ReadonlyMap` (D31) — the **binary asset table** (a METADATA index since **D37**: entry = `{kind: "image"\|"audio", mime}` + **either** `data` (base64 bytes — fs-loaded and inline-bundle entries) **or** `size`/`file` (split-bundle entries, D37)); populated by the loader from `content/assets/` (fs) or a bundle's `assets`/`assetsIndex` (bundle mode). Bytes are a transport concern, not engine state. Shared with `registry.assets` (same Map). Binary files are NOT a content kind — no `contentSchemas` entry, no MCP namespace | `sprite.create` (id references), WS snapshot `assetsIndex` | | `engine.hooks` | `HookRegistry` — typed hook points | `mod.*` | | `engine.systems` | `SystemsHost` — registered systems | `server.info` | | `engine.mcp` | `McpGatewayHandle` — namespace registration | gateway | | `engine.events` | `WorldEventBus` (readonly property) — the world event bus; read-only exposure so external subscribers (e.g. the WS broadcast server in `apps/mcp-server`) can `on`/`onAny` every bus emission (`day-start`, `time-tick`, `crop-grow`, `npc-move`, ...) without touching engine internals. `emit` remains reachable through the same object (matching the `world`/`registry` readonly-property precedent — readonly prevents reassignment, not method calls) | WS sync channel | | `engine.reloadMods(id?)` | `(id?: string) => Promise` — re-import + re-bind one Mod (or all) with cache-busting; updates `engine.mods` and returns the fresh statuses | `mod.reload` | ## 2. Content Pipeline (`packages/core/src/content/`) Pipeline: `YAML → normalize → zod validate → register` (D18). YAML is authoring-only; everything after normalization is JSON. | API | Signature | Serves | |---|---|---| | `ContentLoader` | `load(dir: string) → Promise`; `loadBundle(bundle: ContentBundle) → Promise` — re-validates every def against the per-kind content schema (defense in depth; structural invalidity throws `not a valid nengine-bundle`); `watch(dir)` — hot reload (D13). **Assets (D31/D37)**: `load` also scans `content/assets/` for `*.png`/`*.ogg` → `registry.assets` (id = filename without extension, base64 `{kind, mime, data}`; unreadable files skipped best-effort; no assets dir = empty table); `loadBundle` collects `bundle.assets` (inline base64 entries, D31) AND/OR `bundle.assetsIndex` (D37 metadata-only entries — `{kind, mime, size, file?}`, no bytes; inline entries win when both name an id and are augmented with the index's `size`/`file`); `watch` re-scans assets on changes under `assets/` (same debounce) | `*.create` / `*.update` (write side) | | `LoadReport` | `{ ok: boolean; items: { id, kind, status, errors? }[] }` — identical per-item shape for `load` and `loadBundle`; asset collection is **not** reported as items (the table is read from `registry.assets`) | `*.validate` | | `ContentRegistry` | `get(kind, id)`, `list(kind)`, `all()`, `ids(kind)`; **`assets`** (`Map` — D31) + `asset(id)` | `*.get` / `*.list` / `*.catalog` | | `normalize(kind, yamlDoc)` | YAML → internal JSON (drops comments, resolves refs) | internal | | `validate(kind, json)` | zod parse against `schema/.ts` | `*.validate` | | `ContentBundle` / `contentBundleSchema` / `BUNDLE_FORMAT` / `BUNDLE_VERSION` | the packaged content container (D30): `{ format: "nengine-bundle", version: 1, content: Record, assets?: Record, assetsIndex?: Record, mods?: Record }` — kind → array of validated defs, an optional D31 `assets` map (base64-inlined binaries, INLINE mode), an optional **D37 `assetsIndex`** map (metadata-only `{kind, mime, size, file?}` — the index invariant: carried by BOTH inline and split bundles; split bundles rely on it exclusively, `assets` absent and `file` = `"nengine-assets/."`), and an optional **D36 `mods` map** (modId → `{manifest: {id, version}, code}` — the Mod's entry source text, executed by the ModLoader in bundle mode), the SINGLE source of truth for packaged content (no YAML files, no Mod files ship; YAML parsing happens only at compile time in `apps/pack`). `assetEntrySchema` (`{kind: "image"\|"audio", mime, data}` strict) validates each inline asset entry; `assetIndexEntrySchema` (`{kind: "image"\|"audio", mime, size: non-negative int, file?}` strict) validates each index entry; `assetByteSize(entry)` (declared `size` or base64-derived) and `assetFileExtension(mime)` (png/ogg) are the metadata helpers; `bundledModSchema` (`{manifest: {id: min1, version: min1}, code: string}` strict) validates each mod entry. `BUNDLE_VERSION` stays 1 — `assets`/`assetsIndex`/`mods` are additive | `nengine-pack` | ## 3. World & Entities (`packages/core/src/world/`) | API | Signature | Serves | |---|---|---| | `World` | `create(id)`, `get(id)`, `query({ type?, area? })`, `remove(id)`, `snapshot()` | `map.tile`, `crop.stage`, `item.query_inventory` | | `Entity` | `{ id: string; type: string; ...componentFields }` — flat, schema-validated (D11) | `save.snapshot` | | `WorldEventBus` | `emit(type, payload)`, `on(type, fn)` | `behavior.fire`, hooks | ## 4. Systems (`packages/core/src/systems/`) | System | Core API | Serves | |---|---|---| | `TimeSystem` | `get() → TimeState` `{day, season, tickInDay}`; `advance(ticks)` — the raw clock primitive (D23): moves `tickInDay` by `ticks` (firing `day-start` after each day rollover and `season-change` before it on a season boundary, in that order); **no systems step, no `time-tick`**; `set(day, season, tickInDay = 0)` — jump without events (D33: the optional `tickInDay` restores intra-day time from save snapshots); **`step(dt)`** (System interface, D23): `advance(dt)` then emits the `time-tick` bus event `{day, season, tickInDay}` (the post-advance state). `step` is driven by `engine.tick`/auto-tick — `advance` remains the backwards-compatible clock-only primitive | `time.*` | | `GrowthSystem` | `step(dt)` — driven by the tick loop (D23): accumulates day-fraction ticks against the `time` def's `dayLengthTicks` (read from the registry in `init`); each full day advances every crop plot AND every animal (one pass, `advanceDay`). **Crops (unchanged)**: a stage progresses only when `daysInStage + 1 >= stage.days` **and** the stage's `waterRequired` is false or the plot's `watered` is true; on advance `daysInStage` and `watered` reset. **Animals (D23)**: a stage progresses only when `daysInStage + 1 >= stage.days` **and** the stage's `feedRequired` is false or the animal's `fed` is true; on advance `daysInStage` and `fed` reset; a **mature (final-stage) animal** emits `animal-produce` `{animalId, animalDefId, stageIndex, stage}` per eligible day and its `fed` resets (feeding is consumed daily). Emits `crop-grow` `{plotId, x, y, cropItemId, stageIndex, stage}` per crop advance and `animal-grow` `{animalId, animalDefId, stageIndex, stage}` per animal advance. `forceStage(plotId, stageIndex)` — debug mechanism behind `crop.stage` (validates plot/crop/index, jumps, resets `daysInStage`, emits `crop-grow`). Both `forceStage` and the day-boundary `advancePlot` set `plot.spriteRef` to the new stage's def `spriteRef` when present (D26). **`spawnAnimal(animalId, x, y) → AnimalEntity`** — requires a registered `AnimalDef` (throws `growth: unknown animal def: `), non-negative integer coords (throws `growth: x and y must be non-negative integers`), creates `{id: "animal::", type: "animal", animalId, x, y, stageIndex: 0, daysInStage: 0, fed: false}` (`n` = one past the highest existing suffix so multi-instances and post-restore spawns don't collide), with `spriteRef` set from the def's stage-0 `spriteRef` when present. **`feed(animalId) → AnimalEntity`** — sets `fed: true` (throws `growth: unknown animal: `); emits the `animal-fed` bus event `{animalId, animalDefId}` (bus-only — no hook). **`forceAnimalStage(animalEntityId, stageIndex) → AnimalEntity`** — debug mechanism behind `animal.stage` (validates entity/def/index, jumps, resets `daysInStage`/`fed`, emits `animal-grow`); sets `animal.spriteRef` to the new stage's def `spriteRef` when present (as does the day-boundary `advanceAnimal`). Bus events `crop-grow`/`animal-grow`/`animal-produce` map to hooks `onCropGrow`/`onAnimalGrow`/`onAnimalProduce` | `crop.stage`, `animal.*` | | `PlantingSystem` | `till(x,y)` — creates/updates a plot entity `{id: "plot::", type: "plot", x, y, tilled: true, watered: false, cropItemId?, stageIndex?, daysInStage?, spriteRef?}` (idempotent). `sow(x,y,itemId)` — requires a tilled plot with no crop, a registered `seed` item, and a crop whose `seedItem` matches; consumes 1 seed from `player.inventory` when an inventory field exists (emitting `item-use`), sets `cropItemId` to the **crop def id** (not the seed id), `stageIndex: 0`, and — when the crop's stage-0 def has a `spriteRef` — `plot.spriteRef` (D26: the plot's appearance follows the crop stage). `water(x,y)` — sets `watered: true`. `harvest(x,y) → drops[]` — requires the final stage; rolls drops per the def (chance omitted = always; a `chance` draw consumes one draw from the engine RNG via `ctx.rng`, D28), grants them to `player.inventory` (emitting `item-acquire` per drop), emits `crop-harvest` `{plotId, x, y, cropItemId, drops}`, then clears the crop fields **and `spriteRef`** (plot stays tilled). Invalid operations throw `Error` (MCP converts to error results). Bus event `crop-harvest` maps to hook `onCropHarvest`. | `crop.stage` (plot resolution), `crop.till` / `crop.sow` / `crop.water` / `crop.harvest` (debug wrappers) | | `InventorySystem` | `give(itemId, qty) → number` (new count; emits `item-acquire`), `take(itemId, qty) → number` (clamps to available, returns actual taken; emits `item-use`), `count(itemId) → number`, `items() → Record` (snapshot copy). Inventory lives on the player entity as `player.inventory: { [itemId]: qty }`, created in `init` if absent. `give`/`take` reject non-positive qty. Bus events `item-acquire`/`item-use` are mapped by `engine.wireEvents` to hooks `onItemAcquire`/`onItemUse` (fresh `HookContext`; mods read state via `ctx.world`). | `item.give`, `item.query_inventory` | | `EconomySystem` | `buy(shopId, itemId, qty) → {shopId, itemId, qty, total, gold}` — requires the shop to have a `sells` line for the item (convention: `sells` = the shop sells to the player, player pays `sells[].price`); cost = `price × qty`; throws on unknown shop, item not sold, or gold `< cost`; on success deducts `player.gold`, grants items into `player.inventory` (emitting `item-acquire`), emits `transaction` `{shopId, type: "buy", itemId, qty, total}` (mapped by `wireEvents` to hook `onTransaction`). `sell(shopId, itemId, qty) → {shopId, itemId, qty, total, gold}` — requires a `buys` line (convention: `buys` = the shop buys from the player, player earns `buys[].price`); total = `price × qty`; throws on unknown shop, item not bought, or fewer than `qty` in inventory; on success removes items (emitting `item-use`), adds gold, emits `transaction` `{shopId, type: "sell", itemId, qty, total}`. `price(shopId, itemId) → {shopId, itemId, buy, sell, stock}` — `buy` = the price the shop pays the player (from `buys`), `sell` = the price the shop charges the player (from `sells`), `stock` = the sell line's optional stock cap (`null` when absent/infinite); unknown shop throws. Player money lives on the player entity as `player.gold` (flat field, initialized to `STARTING_GOLD = 0` in `init` if absent/invalid — a documented constant, not content policy). `stock` is informational in this slice: no depletion, no restock logic. `buy`/`sell` reject non-positive `qty`. | `shop.buy` / `shop.sell` / `shop.price` (planned) | | `TriggerSystem` | `evaluate(condition, ctx) → boolean` — pure recursive evaluation of a `ConditionExpr` against a `TriggerEvalCtx` (`{ get(path): unknown }`); `eq`/`exists` compare the resolved path value (`eq` strict `===`; `exists` is `resolved !== undefined`), `gte`/`lte` require both operands to be numbers (non-numeric → false), `and`/`or`/`not` compose children. `fire(triggerId) → {triggerId, fired}` — looks up the trigger def in the `behavior` registry (throws `trigger: unknown trigger: ` when missing), evaluates `when` with a fresh ctx (no event payload, so `payload.*` paths resolve to `undefined`), and when true runs its actions and emits the `trigger-fire` bus event `{triggerId}` (mapped by `wireEvents` to hook `onTriggerFire`); when false it is a no-op returning `fired: false`. `match(event, payload) → {fired}` — runs ALL triggers whose condition evaluates true against a ctx built from the current world/time plus the event payload, executing their actions and emitting `trigger-fire` per fired trigger. `on(event, payload)` (System interface) delegates to `match` — the engine forwards every bus event to systems' `on`, so matching runs on any bus event **except `trigger-fire` itself**, and matching is **not re-entrant** (events emitted by trigger actions during an in-progress match/fire pass are not re-matched in the same pass) — these are the v1 loop guards. **Path resolution convention**: dotted path split on `.`, resolved against `time.*` → `TimeSystem.get()`; `world..*` → the entity by id (missing entity → `undefined`); `player.*` → shorthand for `world.player.*`; `payload.*` → the event payload (present only during `match`/`on`, absent during `fire`); anything else → `undefined`. **Action execution**: `set-state` (path must target a world entity: `player.` or `world..`; missing entity throws `trigger action set-state: no entity: `) writes the field (dotted path, creating intermediate objects as needed); `emit-event` emits the named bus event with the optional payload; `give-item` grants `qty` of `itemId` directly into `player.inventory` and emits `item-acquire` `{itemId, qty, total}` (same direct field-manipulation pattern as `EconomySystem`); `play-sound` and `dialogue` are accepted no-ops in headless v1 (no runtime effect, reserved for the render/audio layers). Time is read from the registered `TimeSystem` via `SystemInitCtx.systems` (added in this slice). **v2 (state machines, D20)**: the same `match(event, payload)` pass drives BOTH triggers and machines — triggers first (fires + emits `trigger-fire` per fired trigger), then every registered `statemachine` def runs `matchMachine` (same `matching` re-entrancy guard covers both; `trigger-fire` short-circuits the whole pass). `machineStatus(machineId) → {machineId, state, started}` (throws `statemachine: unknown machine: `; `started: false` with `state` = declared `initial` when no instance entity exists yet) and `machineTransition(machineId, to) → {machineId, from, to, transitioned}` (debug force — validates the machine and the target state, then `applyTransition` regardless of declared transitions). **Machine state persists on a world entity** `{ id: "sm:", type: "statemachine", machineId, state }`, created **lazily on first transition** (never at boot — existing worlds/snapshots are unaffected); because it is a plain entity it is snapshot/restore-able by `save.*` and queryable via `world.get("sm:")`. `applyTransition(def, to)` runs the leaving state's `onExit` actions, sets `entity.state`, runs the entering state's `onEnter` actions, then emits the `state-change` bus event `{machineId, from, to}` (mapped by `wireEvents` to hook `onStateChange`); a transition to the current state is a **no-op** (no actions, no event, `transitioned: false`). **Transition matching**: a `transitions[]` entry fires when `transition.from` equals the machine's current state, `transition.on` equals the current bus event name (omitted `on` = condition-only transition evaluated on every event), and `transition.when` (a `ConditionExpr` over the same ctx, including `payload.*`) evaluates true; first matching entry in declaration order wins, at most one transition per machine per event. **Schedule entries (`onSchedule[]`, the v2 time primitive)**: `at` is `"day-start"`, `"season-change"`, or `{day?, season?}` (at least one present) matched against the payload of those two `TimeSystem` boundary events; an entry fires when `at` matches the current event payload AND its optional `when` holds (first such entry wins); schedule entries apply **from any state** and are evaluated **after** `transitions`. Time-driven transitions therefore need no game loop beyond the tick loop (D23): `at` is now `"day-start"`, `"season-change"`, `{day?, season?}` (matched against the payload of those two `TimeSystem` boundary events), or `{ tickInDay: n, day?, season? }` (matched against the **`time-tick`** bus event emitted by `TimeSystem.step` each tick — `n ∈ [0, dayLengthTicks)`, plus optional `day`/`season`); an entry fires when `at` matches the current event AND its optional `when` holds (first such entry wins). The pre-tick-loop limitation ("intra-day timing awaits a tick loop") is resolved: `at: {tickInDay}` fires exactly when `payload.tickInDay === n`, bounded by the caller's step granularity (one `time-tick` per `engine.tick(dt)`). | `behavior.*` | | `DialogueSystem` | `start(dialogueId, ctx?) → DialogueState` — starts a dialogue: looks up the def in the `dialogue` registry (throws `dialogue: unknown dialogue: `), **replaces** any active dialogue (the old entity is removed WITHOUT emitting `dialogue-end` — a hard debug-driver reset), creates the active entity `{id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?}`, emits `dialogue-start` `{dialogueId, nodeId}` (mapped by `wireEvents` to hook `onDialogueStart`), then enters the start node (runs its `actions` and computes its outbound path). `current() → DialogueState | null` — the active dialogue's node state (text + **available** choices), or `null` when no dialogue is active or its def/node is no longer registered. `choose(choiceIndex) → DialogueState` — picks the choice at `index` from the **available** list (throws `dialogue: no active dialogue` / `dialogue: choice index out of range: `), runs the choice's `actions`, moves to its `to` node, runs that node's `actions`, and returns the new state. `end()` — force-removes the active entity (no event). `evaluate(condition, ctx) → boolean` — pure condition evaluation (shared `behavior-runtime` helpers). **`DialogueState`** = `{dialogueId, nodeId, text, choices: [{text, to}]}`. **Node semantics**: a `choices` node presents its choices filtered by `when` (evaluated against the dialogue ctx — `time.*`, `player.*`, `world..*`, and `payload.*` from the `start` ctx); a node with only `to` presents a single synthesized `Continue` choice (so the headless driver has one uniform advance action); a node with neither (terminal) — or a choices node whose conditions all fail — **ends** the dialogue on entry: the node's text is still returned, `dialogue-end` `{dialogueId}` is emitted (mapped to hook `onDialogueEnd`), and the entity is removed. Actions reuse the shared 5-kind action set (`set-state`/`emit-event`/`give-item`/`play-sound`/`dialogue`; `play-sound`/`dialogue` are no-ops in headless v1 — nested dialogue via the `dialogue` action is reserved for a later slice). | `dialogue.start` / `dialogue.choose` | | `WeatherSystem` | `get() → WeatherState` — current weather `{weatherId, name?, season}` (throws `weather: no weather def registered` when no def / `weather: no weather state initialized` otherwise). `set(weatherId) → WeatherState` — force a declared weather (throws `weather: unknown weather id: `); emits `weather-change` `{from, to}` **only on change** (mapped by `wireEvents` to hook `onWeatherChange`); returns `{weatherId, name?, season, from?}` (`from` present only when it changed). `roll() → WeatherState` — weighted pick from the current season's probability table (season read from `TimeSystem.get().season`; falls back to the first season's table when the season is uncovered), using an **injectable RNG** (`new WeatherSystem(rng?)` — a constructor-injected `WeatherRng` wins for deterministic tests; otherwise the default resolves `ctx.rng` (the engine's seeded `RandomSource`, D28) when available, else `Math.random`); emits `weather-change` on change. `table(season) → WeatherEntry[] | undefined` — the season's weighted entries (copies) or `undefined`. Implements the System `on(event)` interface: on `day-start` it rolls (the engine forwards every bus event). State lives on a boot-created entity `{id: "weather", type: "weather", weatherId}` with the initial weather = the current season's first table entry (deterministic — no RNG at boot); the entity is created only when a weather def is registered, so games without `content/weather.yaml` boot cleanly and the weather calls throw clear errors. | `weather.get` / `weather.set` | | `SaveSystem` | `snapshot() → WorldSnapshot` — serializes the current world as `{ format: "nengine-save", version: CURRENT_SAVE_VERSION (= 1), savedAt: ISO timestamp string, world: Entity[], rngState?: {seed, calls} }`; entities are shallow copies (the live world is not aliased). **`rngState` (D28)**: captured from `ctx.rng.state()` at snapshot time (present whenever the engine exposes an RNG) — the engine's determinism anchor. Covers world entities + the RNG anchor + **TimeSystem state (D33)**: `snapshot.time = TimeSystem.get()` when a time system exists (optional additive field — old snapshots without `time` remain valid, `CURRENT_SAVE_VERSION` stays 1); `restore` calls `time.set(day, season, tickInDay)` when the snapshot carries `time`, leaving the current clock untouched when it does not. `restore(snapshot) → { restored: number }` — validates the snapshot shape (throws `Error` on malformed input), rejects future versions (`version > CURRENT_SAVE_VERSION`), migrates to the current version via registered migrations, then replaces ALL world entities: every current entity is removed via `world.remove(id)` and the snapshot's entities are re-created in order as fresh copies via `world.create`; `restored` = number of entities created. **`rngState` restore (D28)**: when the snapshot carries `rngState` and the engine exposes an RNG, restore calls `setSeed(rngState.seed)` — the sequence re-anchors to the stored seed and `calls` resets to 0 (draw replay is NOT built — documented semantic). A snapshot without `rngState` restores the world and leaves the RNG untouched (migration-free, optional field). `migrate(snapshot, fromVersion?, toVersion?) → WorldSnapshot` — validates, then applies the migration registry in sequence for each version N in `[fromVersion, toVersion)`; no-op (returns the same snapshot) when `fromVersion === toVersion`; throws on backwards migration; a version without a registered migration is an identity step (v1 has an empty registry, so any forward migration returns the snapshot unchanged after validation). `validate(snapshot) → { ok: true } | { ok: false, errors: string[] }` — structural validation against the save format: `format` literal `"nengine-save"`, positive-integer `version`, non-empty `savedAt` string, `world` array of entities each with non-empty `id` and `type`, and (when present) `rngState` with a number `seed` and a non-negative integer `calls`. `registerMigration(fromVersion, fn)` — the mechanism a content author/Mod uses to add a schema-version migration. Save *policy* (when to save, slots, autosave, UI) is creator content/Mods (D14) and is not part of this system. | `save.*` | | `PlacementSystem` | `place(buildingId, x, y) → BuildingPlacement` — validates in order: coords are non-negative integers; the building def exists (registry kind `building`, throws `placement: unknown building: `); the footprint (`def.footprint.w`/`h`) fits within the map entity's `width`/`height` (throws `placement: footprint out of bounds at (x,y)`); every footprint cell's tile is buildable per the def's `buildable` rule (throws `placement: tile (,) is not buildable for `); no existing building footprint overlaps (throws `placement: footprint overlaps existing building `). Then creates a world entity `{id: "building:::", type: "building", buildingId, x, y, w, h, blocksMovement}` (blocking copied from the def) **plus `spriteRef` copied from the def when present (D26)** and emits `placement-change` `{type: "place", buildingId, x, y, w, h}` (mapped by `wireEvents` to hook `onPlacementChange`). `remove(x, y) → BuildingPlacement` — removes the building whose footprint contains the cell (throws `placement: no building at (x,y)`), emits `placement-change {type: "remove", ...}`, returns the removed entity. `list() → BuildingPlacement[]` — all placed buildings. `occupies(x, y) → BuildingPlacement | undefined` — the building whose footprint contains the cell (used by MovementSystem for blocking). **Tile resolution**: the shared map-grid helper reads the map entity (`type: "map"` — the world holds one per loaded `MapDef`); the terrain layer is the layer named `"ground"`, else the first layer; the tile id is `terrainLayer.data[y][x]`. **Buildability**: `{tiles}` requires the cell's tile id to be in the list; `"map"` (D33) requires every footprint cell to be walkable (`isWalkableTile` — the D33 walkability resolution: declared `map.walkable` value or the `UNWALKABLE_TILES` fallback); `"any"` skips the tile check. (`"map"` previously behaved like `"any"` — D33 makes it the explicit terrain-authored form; existing content places nothing on water, so no behavior breaks.) **v1 occupancy rule**: buildings block buildings; the player and non-building entities are transient and do not occupy cells (NPC blocking is MovementSystem's concern). | `placement.*` | | `MovementSystem` | `moveTo(npcId, x, y) → NpcMoveEvent` `{npcId, from: {x,y}, to: {x,y}}` — validation chain in order: (1) coords are non-negative integers; (2) the npc entity exists and is `type: "npc"` (throws `movement: unknown npc: `); (3) the target is within map bounds (throws `movement: target out of bounds: (x,y)`); (4) the target tile is walkable (throws `movement: tile (x,y) is not walkable`); (5) the target cell is not blocked by a `blocksMovement` building (throws `movement: blocked by building at (x,y)`). Then sets `npc.x`/`npc.y` and emits `npc-move` `{npcId, from, to}` (bus event only — no hook, see mcp.md §npc). `teleport(npcId, x, y) → NpcMoveEvent` — same shape but skips bounds/walkability/blocking (still requires a valid npc and non-negative coords); emits `npc-move`. **`findPath(npcId, to: {x,y}) → TilePoint[] \| null` (D33)** — pure A* grid query from the npc's current cell to `to` (4-neighbor, Manhattan, array-based open set — maps are ~16×16, no binary heap); returns the path INCLUDING start and goal or null (unreachable / out of bounds / goal not walkable or blocked); validates the npc (`movement: unknown npc: `) and coords like `moveTo`; NEVER moves the npc (stepwise NPC movement is documented future work). Blocking = `blocksMovement` building footprint via `PlacementSystem.occupies` + other npc/player grid entities on the cell (the pathing npc excluded). **Walkability (D33)**: the tile id is read from the map entity's terrain layer (same map-grid helper as PlacementSystem); the single source of truth is `isWalkableTile(map, x, y)` (map-grid) — a tileId declared in the map's optional `walkable` map uses its boolean, an undeclared tileId falls back to `UNWALKABLE_TILES` (exported; v1 = `{"water"}`). `moveTo`/`teleport` semantics are unchanged for existing content (water still blocked via the fallback). | `npc.*` | | `IntentSystem` | `handle(action, args) → IntentResult` `{action, tile, ok, error?, detail?}` — the **player intent primitive** (D25), auto-booted (id `"intent"`) after MovementSystem. One mechanism behind every player input channel: MCP `player.intent`, the WS `{"type":"intent"}` message, and `connectLocal.intent` all call this. Validation failures return `ok:false` results, never throws. **`move {tile:{x,y}}`** — validates (coords non-negative integers → player entity exists → map bounds → walkable tile via the shared `isWalkableTile` helper (D33) → not blocked by a `blocksMovement` building via `PlacementSystem.occupies`), then sets `player.x`/`player.y`; success detail `moved to (x,y)`; errors `intent.move: target out of bounds: (x,y)` / `intent.move: tile (x,y) is not walkable` / `intent.move: blocked by building at (x,y)`. Reuses the shared map-grid/placement helpers directly (NOT `MovementSystem.moveTo` — npc-move behavior and tests untouched, D25). **`interact {tile:{x,y}}`** — smart context action on a plot tile; the action ORDER is **content-ized (D33)**: read at call time from the single-file `interact` content kind (`content/interact.yaml`; `registry.get("interact", "default")` else the first def of the kind), iterating its `order` array and executing the FIRST applicable action; without a def the built-in default `[harvest, water, sow, till]` applies. **Applicability conditions stay engine mechanisms**: (1) `harvest` — plot tilled + crop at final stage (detail `harvested `, grants the drop); (2) `water` — plot tilled + planted + not watered (detail `watered plot`); (3) `sow` — plot tilled + empty + a seed in inventory, using the **D35-selected seed** (`player.selectedSeed` when it names an owned `seed`-kind item, else the FIRST seed-kind inventory item — first key whose item def `kind === "seed"`, consumes 1, detail `sowed `; a successful sow keeps `player.selectedSeed`, clearing is creator policy); (4) `till` — plot absent/un-tilled (creates the plot, detail `tilled plot`). Non-applicable actions in the list are skipped; if none apply the existing errors fall through (`crop is not ready to harvest` for a planted watered immature crop, else `no seed in inventory`). **D35 action set extension** (the same `player-intent` event semantics, success AND failure, and the same result shape — note `IntentResult.detail` widened from `string` to `unknown` so the new actions can 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: requires itemId` / `requires shopId` / `qty must be a positive integer` plus the economy errors passed through. **`talk {npcId?|tile?}`** — resolves the NPC (by id, or the npc entity ON the given tile), reads its `dialogue?: string` entity field (missing → `player.intent: npc has no dialogue`), and `DialogueSystem.start`s it; detail = the `DialogueState`. Errors: `player.intent: talk requires npcId or tile` / `player.intent: unknown npc: ` / `player.intent: no npc at tile`. **`choose {index}`** — `DialogueSystem.choose(index)` with the next node state as detail; errors `player.intent: choose requires a non-negative integer index` / `player.intent: no active dialogue` / passthrough `dialogue: choice index out of range: `. **`select-seed {seedItemId?}`** — sets `player.selectedSeed` to a registered `seed`-kind item id (absent/empty clears), detail `{selectedSeed: string|null}`; error `player.intent: is not a seed item`. The `selectedSeed` field is a plain player-entity field (snapshot-able, save.*-covered, Mod-readable). Delegates to the existing `PlantingSystem` mechanisms — no new farming logic. Every `handle` call emits the **`player-intent`** bus event `{action, tile, ok, error?, detail?}` (successes AND failures — the browser HUD and WS viewports depend on it for feedback; behavior-language-matchable via `on: "player-intent"`). No new hook (bus-event-only, mirroring `npc-move` — D22.4). **WS input adapter (D26)**: the broadcast server's `{"type":"input","input":string}` handler emits an `input` bus event `{input}` and maps `key:ArrowUp/Down/Left/Right` to `handle("move", {tile})` for the tile one step from the player's current position — the adapter computes the tile, the mechanism (`handle`) is unchanged. | `player.*` | System interface: ```ts interface System { id: string; init(ctx: SystemInitCtx): Promise | void; step?(dt: number): void; // simulation tick on?(event: string, payload: unknown): void; // world events } ``` ## 5. Mods (`packages/core/src/mods/`) | API | Signature | Serves | |---|---|---| | `ModLoader` | `load(dir, enabledMods?) → Promise` — ES module per Mod, validate manifest, bind hooks (D6/D13); **F1: when `enabledMods` is given, Mods whose manifest id is NOT in the list are skipped entirely (never bound, never returned; `undefined` = all enabled; unknown ids ignored)**; `reload(dir, id?, enabledMods?) → Promise` — in-process re-load of one Mod or all: unbinds that Mod's previously registered hook functions (per-mod unsubscriber records) and re-imports the entry with a cache-busting `?t=-` query so changed `.mjs` files are re-read without duplicate bindings (D13: the authoritative code-Mod path remains a headless-core restart); reload applies the same `enabledMods` filter (a reloaded-but-disabled id reports `mod not loaded`). **`loadFromSource(source, id, version?)` (D36)** — execute + bind a Mod from its ESM **source string** (bundle mode): dynamic-imports the code as a base64 `data:text/javascript` URL, the SAME mechanism in Node ≥ 20 and browsers with zero fs access. Validation and hook binding are **identical to file loading** (manifest `{id, version}` required from the mod's own default export — no version fallback; non-function hooks and unknown hooks reported as `errors` while still loading; import/manifest failures → `loaded: false` + errors, never throw; a failed status reports the passed `version` when provided, else `0.0.0`). **`reloadFromSources(sources: ModSource[])` (D36)** — re-load a set of source mods (id/version/code) with cache-busting (`#t=` fragment on the data URL — a query string would corrupt the base64 payload): unbinds all source records then re-imports each, so bundle-mode reload works without re-reading the bundle. **`inspectModEntry(dir)` (F1, exported)** — read a Mod's manifest + hooked hook names WITHOUT binding (manifest `{id, version}` required; `undefined` = no entry / import failed / no valid manifest); powers the editor Mods panel's read of disabled/unloaded mods | `mod.list` / `mod.status` / `mod.reload` | | `HookRegistry` | `on(hook: HookName, fn: HookFn)` — returns an unsubscriber; `bound(hook)` — whether any fn is registered for the hook; `fire(hook, ctx)` | `mod.status`, `mod.reload` | | `HookContext` | curated read-mostly surface: `world.query(...)`, `emit(event, payload)`, `time.get()`, `registry.get(kind, id)`; optional `payload?: unknown` — set ONLY by the `onPlayerIntent` wiring, which threads the transient intent result (`{action, tile, ok, error?, detail?}`) that Mods cannot otherwise read from world state (D29.2) | — | Declared hook points (Tier 1, from `architecture.md`): `onDayStart`, `onSeasonChange`, `onCropHarvest`, `onCropGrow`, `onItemAcquire`, `onItemUse`, `onTransaction`, `onTriggerFire`. Tier 2 (v2): `onStateChange` (fired on every state machine transition via the `state-change` bus event; hook context is the standard curated `HookContext` — the transition payload is read from world state, e.g. `ctx.world.get("sm:")?.state`). Tier 2 (dialogue + weather): `onDialogueStart` / `onDialogueEnd` (fired via the `dialogue-start`/`dialogue-end` bus events — the active node is read from `ctx.world.get("dialogue:active")`) and `onWeatherChange` (fired via the `weather-change` bus event — current weather read from `ctx.world.get("weather")?.weatherId`). Tier 2 (placement): `onPlacementChange` (fired via the `placement-change` bus event — placed/removed buildings read from `ctx.world.query({ type: "building" })`). Tier 2 (animals, D23): `onAnimalGrow` (fired via the `animal-grow` bus event on every animal stage advance) and `onAnimalProduce` (fired via the `animal-produce` bus event per eligible day while an animal is mature). Tier 3 (D29): `onPlayerIntent` (fired via the `player-intent` bus event for EVERY `IntentSystem.handle` call — success AND failure; the ONLY hook whose context carries `payload`, the intent result itself: `{action, tile, ok, error?, detail?}`). `npc-move` and `animal-fed` deliberately have NO hook (see mcp.md §npc/§animal — both bus events are behavior-language-matchable via `on: "npc-move"` / `on: "animal-fed"`); `player-intent` gained its hook in D29 by reversing D25.4 — Mods (hooks-only, D6/D13) cannot subscribe to raw bus events, and the intent payload is transient (not readable from world state), so the "Mod hook demand" arm of the tri-criterion now holds. ```ts // A Mod sees ONLY this: export default { manifest: { id: "my-mod", version: "0.1.0" }, hooks: { onDayStart(ctx: HookContext) { /* ctx only */ }, }, }; ``` ## 6. MCP Gateway (`packages/mcp/src/`) | API | Signature | Serves | |---|---|---| | `createGateway()` | `McpGateway` — central gateway (transport-agnostic) | all tools | | `gateway.register(namespace: McpNamespace)` | per-subsystem registration (contract in `mcp.md`) | `server.*` | | `gateway.listTools()` / `callTool(name, args)` | tool surface | all tools | | `gateway.listResources()` / `readResource(uri)` | resource surface (template URIs, `read(params)`) | all resources | | `gateway.listPrompts()` / `getPrompt(name, args)` | prompt surface | all prompts | | `gateway.namespaces()` / `status()` | subsystem registry + enabled state | `server.status` | | `gateway.enable(name)` / `disable(name)` | runtime subsystem switch (D9) — behind `server.enable_subsystem` / `server.disable_subsystem` | `server.*` | | `createEngineNamespaces(engine)` | factory: builds `server.*`/`map.*`/`time.*`/`item.*`/`crop.*`/`animal.*`/`shop.*`/`save.*`/`behavior.*`/`dialogue.*`/`weather.*`/`placement.*`/`npc.*`/`player.*`/`mod.*`/`sprite.*`/`sound.*`/`rng.*`/`particle.*`/`shader.*`/`animation.*`/`scene.*`/`craft.*` namespaces bound to a running engine | — | | `createNengineMcpServer(engine)` | `McpServer` adapter (MCP SDK) wired to the gateway; `stdioTransport()` for `apps/mcp-server` | transports | > `packages/core/src/mcp/gateway.ts` holds only a `McpGatewayHandle` placeholder (part of the Engine facade); the real gateway lives in `@nengine/mcp`. ## 7. Schema Map (`packages/core/src/schema/`) | Schema | Content file | Internal type (zod) | |---|---|---| | `item` | `content/items/.yaml` | `ItemDef` — `{ id, name, kind: tool\|seed\|material\|consumable\|decoration\|good\|crop, stackable, price?, use?, description?, rarity?, durability?, effects?, spriteRef? }` (zod **strict**); `price` optional non-negative int, `use` optional string (descriptive), `rarity` defaults `common` (common/uncommon/rare/epic/legendary), `durability?` `{max}` (inventory `use()` decrements, removes at 0), `effects?.consumable?.{stamina?,gold?}` (inventory `use()` applies), `spriteRef?` optional; `good` = crafted food, `crop` = harvested produce | | `crop` | `content/crops/.yaml` | `CropDef` — `{ id, seedItem, stages: [{name, days, waterRequired, spriteRef?}], seasons, drops }`; `waterRequired` is a boolean (plot must be watered during that stage); `spriteRef` optional string (D26 — appearance hint); `drops: [{itemId, qty, chance?}]` — `qty` positive int, `chance` optional 0..1 (omitted = always) | | `map` | `content/maps/.yaml` | `MapDef` — `{ id, width, height, layers, spawn, tilesetRef, walkable?, tiles? }` — `walkable?: Record` optional (D33): `false` → not walkable, `true` → walkable, undeclared tileId → the `UNWALKABLE_TILES` fallback (default `{"water"}`) — the single source of truth behind `isWalkableTile`; `tiles?: Record` optional (D31): maps a tile id to an image asset the viewport draws; absent/unmapped ids fall back to the palette. MCP authoring tools live (`map.validate`/`map.create`/`map.update`, D40 — the D31 "maps are authored YAML, `map.*` is read-only" ruling is overturned by the shared authoring layer); map authoring touches only the registry/file, never the seeded world map entity | | `time` | `content/time.yaml` | `TimeDef` — `{ dayLengthTicks, seasons: [{name, dayCount}], start }` | | `shop` | `content/shops/.yaml` | `ShopDef` — `{ id, buys: [{itemId, price}], sells: [{itemId, price, stock?}] }`; `price` positive int, `stock` optional positive int (absent = infinite; informational, no restock logic) | | `behavior` | `content/behaviors/.yaml` | `TriggerDef` — `{ id, when: ConditionExpr, fire: Action[] }`; `fire` is 1+ actions (see grammar below) | | `statemachine` | `content/statemachines/.yaml` | `StateMachineDef` — `{ id, initial, states: [{id, onEnter?, onExit?}], transitions: [{from, to, when?, on?}], onSchedule?: [{at, to, when?}] }`; see the StateMachineDef grammar below | | `dialogue` | `content/dialogues/.yaml` | `DialogueDef` — `{ id, startNode, nodes: [{id, text, choices?: [{text, to, when?, actions?}], to?, actions?}] }`; see the DialogueDef grammar below | | `weather` | `content/weather.yaml` | `WeatherDef` — `{ id, seasons: [{season, weathers: [{id, weight}]}], defaults?: {[weatherId]: {name}} }`; single-file kind (like `time`) — see the WeatherDef grammar below | | `building` | `content/buildings/.yaml` | `BuildingDef` — `{ id, name?, footprint: {w, h}, buildable: "map"\|{tiles: string[]}\|"any", blocksMovement, description?, spriteRef?, shop? }` — see the BuildingDef grammar below | | `animal` | `content/animals/.yaml` | `AnimalDef` — `{ id, stages: [{name, days, feedRequired, spriteRef?}], produces: [{itemId, qty, chance?}] }` — see the AnimalDef grammar below | | `sprite` | `content/sprites/.yaml` | `SpriteDef` — `{ id, frames: [{name?, durationTicks?, layers: [{shape:"rect"\|"circle"\|"image", ...}]}] }` (zod **strict** discriminated union); programmatic draw instructions plus the **`image` layer** (D31): `{shape:"image", src: , x?, y?, w?, h?}` — `src` references a binary asset (`content/assets/`), `x`/`y` position the image's top-left in tile space (default 0,0), `w`/`h` size it in tile-space units (crop clamped to the source: smaller = crop from the source top-left, larger = upscale the whole image; absent = natural pixel size); see the SpriteDef grammar below | | `sound` | `content/sounds/.yaml` | `SoundDef` — `{ id, synth?: {wave:"sine"\|"square"\|"triangle"\|"sawtooth", freq, durationMs, gain?, slideTo?}, src? }` (zod **strict**; at least one of `synth`/`src` required); `src` is an **audio asset id** (D31 — a file in `content/assets/`) the render player decodes and plays (`src` takes precedence over `synth`); see the SoundDef grammar below | | `interact` | `content/interact.yaml` | `InteractDef` — `{ id, order: ["harvest"\|"water"\|"sow"\|"till", ...] }` (zod **strict**; single-file kind like `time`/`weather`, D33) — the player interact action ORDER (content policy); applicability conditions stay engine mechanisms; no MCP tools (single-file-kind precedent) | | `particle` | `content/particles/.yaml` | `ParticleDef` — `{ id, emitRate?, lifetime?: {min, max}, speed?: {min, max}, angle?, spread?, gravity?, size?: {min, max}, colors?: ["#rrggbb",...], fade? }` (zod **strict**; D34) — a pure-visual emission profile; 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). Inert presentation data: content triggers bursts via `emit-event {event:"particle", payload:{particleId, x?, y?, entityId?}}`, the render layer spawns/animates | | `shader` | `content/shaders/.yaml` | `ShaderDef` — `{ id, filter: "blur"\|"tint"\|"grayscale"\|"sepia"\|"hue"\|"brightness", params?: {[k]: number\|string} }` (zod **strict**; D34) — a parameterized selection from the engine's BUILT-IN filter library (**no GLSL in content**); params drive the filter (blur `{strength}`, tint `{color}`/`{r,g,b}`, grayscale `{factor}`, sepia `{}`, hue `{degrees}`, brightness `{factor}`). Referenced from `SpriteDef.filters` and the weather visual mapping; unknown id → silently skipped | | `animation` | `content/animations/.yaml` | `AnimationDef` — `{ id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}] }` (zod **strict**; D38) — an animation **state machine over sprite defs**; each state's sprite def's frames animate at the state's `fps` (default `DEFAULT_ANIMATION_FPS` 10) with `loop` default true (false = play once then hold the last frame); see the AnimationDef grammar below | | `scene` | `content/scenes/.yaml` | `SceneDef` — `{ id, entities: [SceneEntity, ...] }` (zod **strict**; D40.9) where `SceneEntity` is a **discriminated union** (`z.discriminatedUnion("type", [...])`) of `{type: "building", buildingId, x, y}`, `{type: "npc", npcId, x, y, dialogue?, label?}`, and `{type: "animal", animalId, x, y}` — `x`/`y` non-negative integer tile coords on every entity; an initial-entity list instantiated at seed time; **structural validation only** — building/animal references are validated at seed time (loading order makes schema-time reference checks unsafe); see the SceneDef grammar below | | `recipe` | `content/recipes/.yaml` | `RecipeDef` — `{ id, output: {itemId, qty}, inputs: [{itemId, qty}], station? }` (zod **strict**) — a single `output` `{itemId, qty}` object (produced item), `inputs` a 1+ array of `{itemId, qty}` (consumed ingredients), `station?` an optional string naming a required crafting building (authoring hint only — runtime does NOT enforce, v1); register-only content (no system holds recipe state); runtime crafting via `CraftingSystem.craft(recipeId)` (checks inventory, deducts inputs, adds output, emits `craft-success`); see `craft.*` in `mcp.md` | | `mod` | (not content — code Mods at `mods//index.mjs`) | `ModManifest` — `{ id, version }`, read inline from the Mod module's `export default { manifest, hooks }`; the loader does not read a `content/mods//manifest.yaml` nor any `contentOverrides` field | | `save` | (none — runtime state, D14) | `WorldSnapshot` — `{ format: "nengine-save", version: number, savedAt: string, world: Entity[], rngState?: {seed: number, calls: number}, time?: {day: number, season: string, tickInDay: number} }` (shape defined by `worldSnapshotSchema`; `rngState` optional — the engine RNG anchor, D28; `time` optional — the TimeSystem clock, D33); NOT a content kind — no `content/saves/` dir, no `contentSchemas` entry | `save` deliberately has no content schema or content file: it serializes runtime world state (D14, mechanism only). See §4 `SaveSystem`. Condition grammar (v1): a `ConditionExpr` is one of `{ op: "eq"|"gte"|"lte", path, value }` (value is required and may be any JSON value), `{ op: "exists", path }`, `{ and: [expr, ...] }`, `{ or: [expr, ...] }`, or `{ not: expr }` — composed recursively. `path` is a dotted path resolved per the convention in §4 (`time.day`, `time.season`, `world..`, `player.`, `payload.`). `eq` is strict equality, `gte`/`lte` require numeric operands, `exists` is `resolved !== undefined`. Actions (v1/v2): `set-state` `{action, path, value}` (absolute — D20), `add` `{action, path, value}` (D33 — incremental: numeric field += value, missing field starts at 0, non-numeric field throws, value must be finite; `set-state` stays absolute), `emit-event` `{action, event, payload?}`, `give-item` `{action, itemId, qty}`, `play-sound` `{action, sound}` (**emits the `sound-play` bus event `{soundId}` — the engine's audio-event mechanism; the render layer plays in round 2, D26**), `dialogue` `{action, text}` (no-op in headless v1). StateMachineDef grammar (v2, D15/D20/D23): `initial` must be a declared state id; `states[]` (1+) with unique ids and optional `onEnter`/`onExit` action lists (same 5-kind action set); `transitions[]` entries `{from, to, when?, on?}` — `from`/`to` must be declared states and at least one of `when`/`on` is required (enforced by `stateMachineSchema`'s `superRefine`). `onSchedule[]` entries `{at, to, when?}` — `at` is `"day-start"`, `"season-change"`, `{day?, season?}` (at least one of `day`/`season`), or `{tickInDay, day?, season?}` (at least one of `day`/`season`/`tickInDay`; the `tickInDay` form matches the `time-tick` event, D23); `to` must be a declared state. Evaluation semantics: see §4 TriggerSystem (v2). State machine instances are world entities (`sm:`, type `statemachine`) — snapshot-able, MCP-inspectable, with quest progress conventionally written to `player.questProgress.`. DialogueDef grammar (D21): `startNode` must be a declared node id; `nodes[]` (1+) with unique ids and exactly one of `choices` or `to` (or neither — a terminal node), each node optionally carrying `actions` (same 5-kind action set, run on node enter). `choices[]` entries `{text, to, when?, actions?}` — `to` must be a declared node, `when` is a v1 `ConditionExpr` evaluated against the dialogue ctx, `actions` run when the choice is picked. Runtime semantics: see §4 DialogueSystem. Active dialogue state is a world entity (`dialogue:active`, type `dialogue`) — snapshot-able, MCP-inspectable. WeatherDef grammar (D21): `seasons[]` (1+) each `{season, weathers: [{id, weight}]}` — `weight` a positive integer (relative probability); season names match `time.yaml`. `defaults?` maps declared weather ids to `{name}` display metadata (keys must be declared weather ids). Runtime semantics: see §4 WeatherSystem. Weather state is a world entity (`weather`, type `weather`) created at boot when the def exists. BuildingDef grammar (D22): `footprint` `{w, h}` — positive-integer grid size; placement anchors the axis-aligned rectangle at the placement origin `(x, y)`. `buildable` is one of `"map"` (any tile id on the map is buildable), `{tiles: string[]}` (non-empty; every footprint cell's tile id must be in the list), or `"any"` (tile check skipped). `"map"` and `"any"` behave identically in v1 (`"map"` is the explicit terrain-authored form). `blocksMovement` — whether a placed instance blocks NPC movement (consumed by MovementSystem). `spriteRef` — optional string (D26; copied onto placed instances; missing = renderer falls back to shape drawing). `shop` — optional string (D35): a registered `shop` def id the building is a front for; copied onto placed instances (`building.shop`) so UIs discover shops from world entities. Placed instances are world entities `{id: "building:::", type: "building", buildingId, x, y, w, h, blocksMovement, spriteRef?, shop?}` — snapshot-able by `save.*`, inspectable by MCP. Runtime semantics: see §4 PlacementSystem. AnimalDef grammar (D19/D23): `stages[]` (1+) each `{ name, days, feedRequired, spriteRef? }` — `days` positive int, `feedRequired` boolean (mirrors the crop stage model with `feedRequired` in place of `waterRequired`); `spriteRef` optional string (D26 — appearance hint; missing = renderer falls back to shape drawing); `produces[]` (1+) each `{ itemId, qty, chance? }` (mirrors crop `drops` — `qty` positive int, `chance` optional 0..1). The def is the growth cycle + produce table (**data only**); feed/produce/harvest RULES are behavior-language content hooking the `animal-grow`/`animal-produce`/`animal-fed` bus events — the engine grants nothing. Animal instances are world entities `{id: "animal::", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?, spriteRef?}` — snapshot-able by `save.*`, inspectable by MCP. Runtime semantics: see §4 GrowthSystem. SpriteDef grammar (D26/D31/D34): `frames[]` (1+) each `{ name?, durationTicks?, layers }` — `name` optional display label, `durationTicks` optional non-negative int (frame play time in engine ticks; a single-frame sprite is static), `layers` is an ordered paint list (1+, drawn back-to-front) of `{ shape: "rect"|"circle"|"image", ... }` (zod **strict** — unknown keys rejected; `shape` is the union discriminator). Optional top-level `filters?: string[]` (D34) — shader ids applied to the drawn frames (unknown id → silently skipped). `rect` layers use `x`/`y` (non-negative ints, top-left in tile space) + `w`/`h` (positive ints) + required `fill` matching `"#rrggbb"` (schema-enforced); `circle` layers use `x`/`y` + `r` (positive int) + `fill`. **`image` layers (D31/D34)**: `{ shape: "image", src, x?, y?, w?, h?, frameX?, frameY?, frameW?, frameH? }` — `src` is a required non-empty asset id (a file in `content/assets/` — the loader resolves it at render time; a missing asset degrades to the shape/color fallback); `x`/`y` optional non-negative ints position the image's top-left in tile space (default 0, 0 — same convention as rect); `w`/`h` optional positive ints are the image's size in tile-space units, with the crop clamped to the source image (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 (`DEFAULT_IMAGE_SIZE` fallback when the asset dims are unknown). **Frame-crop fields (D34)** add sprite-sheet support: `frameX`/`frameY`/`frameW`/`frameH` are an optional source-pixel crop rectangle into the asset image (default = whole image, preserving D31 behavior); a sprite-sheet is one asset file with multiple sprite defs each cropping a different frame rect. The renderer builds a sub-texture for the frame rect (cached by asset + rect). Sprites are **inert presentation data** — no system consumes them; the WS snapshot frame carries the full `sprites` catalog so render viewports can draw on demand (D26). SoundDef grammar (D26/D31): `synth?` is the v1 synthesis spec `{ wave, freq, durationMs, gain?, slideTo? }` (zod **strict**; `wave` one of `sine|square|triangle|sawtooth`, `freq` positive Hz, `durationMs` positive, `gain` optional 0..1, `slideTo` optional positive end-frequency sweep). `src?` is an optional **audio asset id** (D31) referencing a file in `content/assets/` — the render player decodes it (base64 → `decodeAudioData`) and plays it; **`src` takes precedence over `synth`** at play time (a `src` whose asset is missing or undecodable is silently skipped). **At least one of `synth`/`src` is required** (schema `.refine`). Sounds are inert data; the behavior-language `play-sound` action emits the **`sound-play`** bus event `{soundId}` (the engine mechanism — render layer plays) and the snapshot frame carries the full `sounds` catalog. AnimationDef grammar (D38): `initial` must be a declared state id; `states[]` (1+) with unique ids, each `{ id, spriteRef, fps?, loop? }` — `spriteRef` a registered sprite def id, `fps` optional positive number (the state's frame rate; default `DEFAULT_ANIMATION_FPS` 10), `loop` optional boolean (default true; `false` plays the state once then holds its last frame). `transitions[]` optional `{from, to, on?}` — an **authoring aid only** (the runtime does NOT enforce hard transitions; content can jump between states freely — D38). Cross-field checks via `animationSchema`'s `superRefine` (mirroring the statemachine kind): duplicate state ids rejected, `initial` must be declared, transition `from`/`to` must be declared. **State resolution (render layer)**: an entity whose `spriteRef` matches the animation def's id uses it; the state is (1) the entity's explicit `anim?: string` field when it names a declared state (the behavior language sets it via `set-state player.anim "dance"` and clears it via `set-state player.anim null` — null is a supported clear, `set-state`'s value schema already accepts null, no language change), else (2) motion inference — `"walk"` while the entity is tweening between tiles, `"idle"` when still, else (3) `initial`. No matching animation def → today's rendering unchanged (additive; no fallback regression). Animation defs are **inert presentation data** — no system consumes them; the WS snapshot frame carries the full `animations` catalog. SceneDef grammar (D40.9): `entities[]` is an **ordered** array (instantiation order matters — an earlier building overlaps-checks against a later one at seed time) of a **discriminated union** on `type`: `{type: "building", buildingId, x, y}` (references a registered `building` def — instantiated via `PlacementSystem.place(buildingId, x, y)` with full footprint/bounds/buildable/overlap validation; the placed instance carries the runtime id `building:::`), `{type: "npc", npcId, x, y, dialogue?, label?}` (a direct npc entity `{id: npcId, type: "npc", x, y, dialogue?, label?}` — `npcId` must be unique across the whole world, `dialogue` = a dialogue def id, `label` = display label), `{type: "animal", animalId, x, y}` (references a registered `animal` def — instantiated via `GrowthSystem.spawnAnimal(animalId, x, y)` with the runtime id `animal::`). `x`/`y` are non-negative integer tile coords on every entity type. **Seed-time semantics (D40.9)**: `seedScenes` runs AFTER `initSystems` (so the placement/growth system APIs are callable) and BEFORE Mods load; every registered scene instantiates in entity order (v1 — multi-scene selection is a future creator-content decision); unknown buildingId/animalId references and placement conflicts FAIL boot loudly (a scene with a bad reference must fail boot, not silently skip); duplicate npc ids FAIL boot loudly (`world.create` silently overwrites — the seed checks first). Structural validation is schema-time only. Scene instances are indistinguishable from runtime-placed entities (same id formats) — snapshot-able by `save.*`, inspectable by MCP, rendered by the viewport. Runtime semantics: see §4 (PlacementSystem/GrowthSystem) and §1 (start sequence). ## 8. Render Layer (`packages/render/`, D26 round 2) The browser-side presentation half of the D26 pipeline: it **consumes** the engine's data (`sprites`/`sounds`/`particles`/`shaders`/`animations` catalogs, entity `spriteRef`/`anim`/`label` fields) and **events** (`sound-play`, `particle`, `input`) and never hardcodes art. Node-unit-testable pure modules (`sprite-draw.ts`, `sound.ts`, `keys.ts`, `particles.ts`, `filters.ts`, `weather.ts`, `labels.ts`, `tween.ts` (D38), plus the existing `shapes.ts`/`tiles.ts`) hold the logic; `viewport.ts` and `sync/*` are the browser seams. This is render-layer freedom (D1/D8/D34) — the engine owns presentation *data*, the renderer owns drawing/playback. | API | Signature | Notes | |---|---|---| | `createViewport(container, opts)` | `({map?, tileSize?, assetsBase?}) → Promise` | PixiJS bootstrap; `render(snapshot?)`, `onEvent({event, payload})`, `tileAt(clientX, clientY)`, `resize()`, `destroy()`. `RenderSnapshot` = `{ map, world, sprites?, sounds?, particles?, shaders?, shops?, animations?, assets? }` (catalogs optional — absent = shape-only; `shops` (D35) is the shop-def catalog the dev UI's shop panel reads in ws mode; **`animations?` (D38)** is the animation state machine catalog). **`assetsBase?` (D37)**: the base URL for split-assets fetches — image entries with `file` (no inline `data`) are fetched from `assetsBase/file` (browser `fetch` → blob → texture); inline entries decode from `data:` URLs. **Smooth movement (D38, render-layer only)**: the viewport tracks per-entity last-known tile coords; a consecutive coordinate change (player/npc/animal entities) starts a **movement tween** over `MOVE_DURATION_MS` (250 ms, easeOutQuad) that interpolates the entity's drawn position from the previous tile to the new one during the rAF loop — the engine stays authoritative instant grid-jumps (`moveTo`/`IntentSystem.move` untouched; multi-viewport consistent). During the tween the motion state is `"walk"` (settled → `"idle"`), driving the animation state (below); buildings/plots/static entities are never tweened. **Animation state machines (D38)**: an entity whose `spriteRef` matches a snapshot `animations` def id renders through that def — state = explicit `entity.anim` field (when it names a declared state) else motion inference (`walk`/`idle`) else `initial`; the state's `spriteRef` sprite draws at the state's `fps` (default 10) with `loop` default true (`false` holds the last frame). No matching animation def → the existing `resolveSprite`/shape path unchanged (no regression). Draws ground/spawn from the map def — **ground tiles render the `map.tiles` image asset when mapped AND its texture is loaded, else `tileColor(id)`** — then per entity: **`spriteRef` present and resolvable in the snapshot's `sprites` catalog → draw the sprite** (plot entities keep their shape base rects — tilled/watered — with the sprite layered on top; all other sprite entities draw the sprite **instead of** the shape fallback); **missing `spriteRef` OR unknown sprite id → the existing `shapes.ts` shape fallback, unchanged**. **Image layers (D31/D37)**: `spriteDrawCommands` emits `image` commands; the viewport loads every image asset in the snapshot's `assets` table as a PixiJS texture (async load, **redraw when each pending load resolves** — so late-arriving on-demand WS assets and split-assets fetches re-render through the same redraw-on-ready path; textures + pixel dims cached by asset id), draws each `image` command as a `Sprite` (sub-texture `frame` rect = the source crop; **frame-crop rects (D34)** — `frameX/Y/W/H` — take precedence over `sourceW/H` and produce a sub-texture cached keyed by asset + rect; absent crop = whole texture), and falls back to the shape/color path when the texture is missing. **Filters (D34)**: sprite entities draw into a per-entity child container with the resolved `SpriteDef.filters` applied (cached `Filter` instances per shader id; unknown id → skipped silently). **Labels (D34)**: entities with a non-empty `label` field draw a Pixi `Text` (12 px Arial, white + black stroke) anchored above the sprite (`labelPosition`). **Particles (D34)**: `onEvent` spawns a burst on `particle` events (payload `{particleId, x?, y?, entityId?}` — tile coords or entity-resolved); a ParticleLayer Graphics steps + redraws particles each rAF tick. **Weather visuals (D34)**: the weather entity (`{type:"weather", weatherId}`) in the snapshot world maps through `weatherEffects` — filter applied to the whole map container, periodic particle bursts at random map tiles, storm overlay color; weather change clears the previous visuals. Missing images/filters/particles degrade silently — no hard failure. | | `resolveSprite(entity, sprites)` | pure `SpriteDef \| undefined` | sprite-ref → catalog lookup | | `frameAtTime(sprite, elapsedMs, fps?)` | pure `number` | v1 animation clock: each frame occupies `weight/total` of the cycle where `weight = durationTicks ?? 1`; cycle length = `total / fps` seconds at the documented default **10 fps** (`DEFAULT_ANIMATION_FPS`); single-frame sprites are always index 0. `spriteDurationMs(sprite, fps?)` = one full cycle. **`frameAtTimeOrHold(sprite, elapsedMs, fps?, loop?)` (D38)**: same clock honoring an animation state's `loop` — `loop: false` cycles once then holds the last frame (one-shot states like "dance" freeze). | | `tweenPosition(from, to, elapsedMs, durationMs, tileSize)` | pure `{xPx, yPx}` | D38 smooth-movement math (render-layer only): **easeOutQuad** interpolation from `from` tile to `to` tile at `elapsedMs` over `durationMs`, scaled to pixels by `tileSize` (clamped 0..1; zero/expired duration → the end tile). `tweenTile(from, to, elapsedMs, durationMs)` is the fractional-tile form the viewport feeds to sprite drawing; `MOVE_DURATION_MS` (250) is the documented default. `motionState(from, to, moved)` → `"walk"` when moving across changed coords else `"idle"`; `animState(entity, prevX, prevY, motion)` → explicit non-empty `entity.anim` else the motion inference; `resolveAnimationState(def, stateName)` clamps a state name to a declared state (falls back to `initial`); `animationState(def, stateId)` looks up a declared state. | | `spriteDrawCommands(sprite, frameIndex, entity, tileSize, assets?)` | pure `SpriteDrawCommand[]` | tile-space layers → absolute pixel commands. **Building footprint scaling**: an entity carrying `w`/`h` (tile footprint) scales the sprite's tile-space box (0..32) to `w×h` tiles — a 3×3 coop sprite renders across its 3×3 footprint; everything else draws at 1×1 tile. `circle` layers with a non-uniform footprint become ellipses. `#rrggbb` → `0xrrggbb` via `hexToRgbColor`. **Image layers (D31/D34)**: `{kind:"image", assetId, x, y, w, h, sourceW?, sourceH?, frameX?, frameY?, frameW?, frameH?}` — dest box footprint-scaled, `sourceW`/`sourceH` the crop pixel count from the source top-left (absent = whole texture); `frameX/Y/W/H` the D34 sprite-sheet frame rect (carried through, clamped to the remaining source pixels when dims are known); the optional `assets` arg (`ReadonlyMap`) resolves default dest sizes from pixel dimensions (`DEFAULT_IMAGE_SIZE` fallback). | | `particleSpawn(def, xPx, yPx, random?)` | pure `ParticleState[]` | D34 burst spawn: `emitRate` particles at a world-space pixel position with per-particle velocity/size/color/lifetime derived from the def (documented defaults: emitRate 10, lifetime 500–1000 ms, speed 10–30 px/s, angle 0°, spread 360°, gravity 0 px/s², size 2–4 px, `#ffffff`, fade true). `particleTick(states, dtMs)` integrates gravity/velocity and returns survivors; `particleRadius(state)` is the current draw radius (a fading particle shrinks toward 25% over its lifetime — the documented single-Graphics fade approximation). `resolveParticleDef(def)` fills defaults. Pure-visual: uses `Math.random` (not the D28 deterministic engine RNG). | | `filterParams(def)` | pure `FilterParams \| undefined` | D34 shader-def → numeric/color filter parameters: blur `{kind:"blur", strength}`, tint `{kind:"tint", color:"#rrggbb"}` (from `color` or `r/g/b`), grayscale `{kind:"grayscale", factor}`, sepia `{kind:"sepia"}`, hue `{kind:"hue", degrees}`, brightness `{kind:"brightness", factor}`; undefined for a malformed def. The viewport's thin `makeFilter` instantiates the concrete PixiJS `BlurFilter`/`ColorMatrixFilter` (cached per shader id). | | `weatherEffects(weatherId)` | pure `WeatherEffects` | D34 built-in weather-id → visual mapping (`{filterId?, particleId?, color?}`): rain → `{particleId:"rain", filterId:"rain-tint"}`, wind → `{particleId:"wind"}`, storm → `{particleId:"rain", filterId:"rain-tint", color:"#2f3b4c"}`, sunny/snow/unknown → `{}`. Engine-provided presentation mechanism (not content). | | `labelPosition(entity, tileSize)` | pure `LabelPosition` | D34 px anchor for an entity's label: tile-center x, `LABEL_ABOVE_OFFSET` above the tile top. | | `createSoundPlayer(factory?, assetsBase?)` | `SoundPlayer` | Web Audio playback: `setCatalog(SoundDef[])`, `setAssets(Record)`, `onEvent(frame)` (plays on `sound-play {soundId}`), `prime()`, `destroy()`. **`assetsBase?` (D37)**: split-assets audio entries with `file` (no inline `data`) are fetched from `assetsBase/file` → `decodeAudioData`; inline entries decode base64. **AudioContext autoplay policy**: the context is created lazily on the first play attempt (or via `prime()` from a user gesture) and resumed if suspended; no context available / unknown sound id / no playable path → silently skipped; playback errors never crash the viewport. `soundToAudioParams(def)` is the pure def→Web-Audio-parameter mapping (wave/freq/durationMs/gain default `0.3`/slideTo); `soundSrcAssetId(def)` is the pure `src`-asset-id mapping. **Src playback (D31/D37)**: a def with `src` decodes the OGG (`decodeAudioData` — base64 in place or fetched from `assetsBase`) and plays it through an `AudioBufferSourceNode` — decoded buffers cached by asset id, async decode, **`src` takes precedence over `synth`**, missing/undecodable/unfetchable asset → silent skip; the synth path is unchanged. | | `connectWs({url, viewport, onEvent?, onSnapshot?, sound?})` | `WsSyncHandle` | Adds `input(input)` (sends `{"type":"input", input}`), the `sound` player (catalog fed from every snapshot, `sound-play` frames forwarded on receipt), and particle forwarding (`particle` frames → `viewport.onEvent`). `WsSnapshotFrame` includes `sprites: SpriteDef[]` / `sounds: SoundDef[]` / `particles?` / `shaders?` / `shops?: ShopDef[]` / **`animations?: AnimationDef[]` (D38)** and **`assetsIndex?: Record` (D37 — metadata only, no bytes)**. **On-demand assets (D37)**: on each snapshot the client derives the wanted ids from the snapshot's presentation (`snapshotAssetRefs` — map `tiles` + sprite `image` layers + `sound.src`), feeds them to an `AssetCoordinator` (pure node-testable: wanted/loaded/pending dedupe, ≤64-id batches), sends `{"type":"assets-request", ids}`, and on each `{type:"assets"}` frame feeds the merged table to the viewport (re-render with the arriving assets — the existing redraw-on-ready path) and `sound.setAssets`. | | `connectLocal(engine, viewport, {sound?})` | `LocalSyncHandle` | Adds `input(input)` — client-side arrow mapping via `arrowDirection` (one tile from the player's current position) → `intent("move", ...)`; `sound` player fed from the local registry + `engine.assets` and the engine bus (`engine.events.on("sound-play")`); `particle` events forwarded to `viewport.onEvent`. Snapshot now carries the sprite/sound/particle/shader/**shop**/**animation** (D38) catalogs and the asset table (`engine.assets` — inline base64 entries in fs mode, metadata-only `{kind, mime, size, file?}` entries in split-bundle mode, which the viewport/sound fetch via their `assetsBase`, D37). | | `arrowDirection(input)` | pure `{dx,dy} \| undefined` | the shared `"key:Arrow*"` → adjacent-tile delta (server adapter and local handle both map the same way; D26). | | `tileAssetId(map, tileId)` | pure `string \| undefined` | the `map.tiles` mapping lookup (D31): the image asset id a tile should render from, or `undefined` (palette fallback). | Both sync handles are now uniform: `setTimeLabel` / `refresh` / `intent` / `input` / `destroy`. `apps/dev` wires a `keydown` listener (arrow keys → `sync.input("key:Arrow<…>")`, all other keys ignored) and `pointerdown`/keydown call `sound.prime()` (gesture-gated AudioContext). ## 9. Packaging CLI (`apps/pack`, D30/D32/D36) The offline build-time tool that turns a content directory into a self-contained static site. **Not an MCP namespace** (build-time capability, not runtime — D30). Since **D32** it can also write a runnable **Electron desktop app** (`--desktop`); since **D36** it can package **Mods** (`--mods`) and generate an **installer config** (`--installer`); since **D37** it can **split asset bytes out of bundle.json** (`--split-assets`). | API | Signature | Notes | |---|---|---| | `parsePackArgs({argv?, cwd?})` | `PackArgs \| null` | CLI arg parsing (`--content` required, `--out` default `./dist-pack`, `--mods ` (D36 — Mods dir, default none), `--split-assets` (D37 — boolean, default inline), `--desktop` boolean, `--installer` boolean (D36 — implies `--desktop`), `--name ` product name, `--help` → `null`); throws on a missing `--content` | | `compileBundle(contentDir, modsDir?, splitAssets?, enabledMods?)` | `Promise` | fs-loads + validates the dir via `ContentLoader.load` (per-item zod; throws with per-kind/per-id messages on any error, exit 1), then shapes `{format: "nengine-bundle", version: 1, content, assets?, assetsIndex?, mods?}` (non-empty kinds only; `assets` = the D31 asset table from `content/assets/`, base64-inlined; **`assetsIndex` (D37)** = the metadata index, always computed — `{kind, mime, size, file: "nengine-assets/."}`). **With `modsDir` (D36)**: scans it 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** → `bundle.mods[modId] = {manifest, code}`; a Mod with no entry or no valid manifest fails the pack loudly (per-mod messages, mirrors content compile failures); a missing `modsDir` throws. **With `enabledMods` (F1, additive 4th param — no CLI flag, editor-only caller)**: Mods whose manifest id is NOT in the list are skipped (absent = all enabled; unknown ids ignored — matching the engine's `enabledMods` filter). **With `splitAssets: true` (D37)**: returns the D37 split form via `splitBundleAssets` — `assets` removed, metadata-only `assetsIndex` (`file` paths included; no base64 in the bundle JSON) | | `packGame({contentDir, outDir, desktop?, installer?, modsDir?, splitAssets?, name?, enabledMods?})` | `Promise` | `compileBundle` → programmatic vite build of the apps/dev app (reusing its `?bundle=` mode, baked `__NENGINE_BUNDLE_DEFAULT__ = "../bundle.json"` + **`__NENGINE_ASSETS_BASE__ = "./nengine-assets"` (D37)**, `base: "./"`) → writes `bundle.json` into the out dir; **with `splitAssets: true` (D37)** it first writes each asset as a sibling `out/nengine-assets/.` file (ext from MIME, bytes from the inline compile) and writes the metadata-only split bundle; with `desktop: true`, also writes the Electron shell into `/desktop/` (`PackResult.desktopDir` set, `PackResult.modCount` = packaged Mod count). With `installer: true`, `generateDesktop` is called with `installer: true` (electron-builder config + site copy incl. `nengine-assets/` when present, see below). **With `enabledMods` (F1, additive `PackArgs` field)**: only enabled Mods land in `bundle.mods` (applies when `modsDir` is set) | | `generateDesktop(outDir, {productName?, installer?})` | `Promise` | (D32, `apps/pack/src/desktop.ts`) writes `outDir/desktop/`: `main.cjs` (HTTP server + BrowserWindow), `package.json` (npm-safe name from the product name, `main`, `scripts.start = "electron ."`, `devDependencies.electron` `^` major — the runner installs it), `README-desktop.md`. Default product name `"Farm Game"`. **With `installer: true` (D36)**: additionally copies the site files (`index.html` + `assets/` + `bundle.json`) into the desktop dir (so electron-builder can package them — `main.cjs` resolves the site as `..`, which IS the app dir once packaged), writes `electron-builder.yml` (`appId: com.nengine.`, `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}` — Linux verified, Windows documented config), and extends `package.json` with `devDependencies.electron-builder` `^26` + `scripts.dist = "electron-builder --linux"` + `scripts["dist:win"] = "electron-builder --win"`. `DesktopResult.installerFile` set | | `USAGE` / `DEFAULT_OUT_DIR` | constants | help text / `./dist-pack` | CLI: `nengine-pack --content [--out ] [--mods ] [--split-assets] [--desktop] [--installer] [--name ]` (binary `nengine-pack` → `apps/pack/dist/index.js`, auto-runs only on direct execution so tests drive it in-process — the `apps/mcp-server` entry pattern). Packaged output layout: `out/index.html` + `out/assets/` (the built dev app, static-serveable) + `out/bundle.json` (the content **+ Mod sources**) + — **with `--split-assets` (D37)** — `out/nengine-assets/.` (one file per asset, referenced by `bundle.assetsIndex[].file`; bundle.json carries no base64) — plus, with `--desktop`, `out/desktop/` (`main.cjs` + `package.json` + `README-desktop.md`). Opening the served `index.html` with **no query params** plays the game via `createEngine({bundle})` + LocalSync — in split mode the viewport/sound fetch asset bytes from the sibling `nengine-assets/` dir (the baked `__NENGINE_ASSETS_BASE__`, D37). The desktop shell (`cd out/desktop && npm i && npm start`) starts a local HTTP server (127.0.0.1, ephemeral port; GET-only, path-traversal-guarded, exact files only) and opens the same page in an Electron `BrowserWindow` (960×640, `contextIsolation: true`, `nodeIntegration: false`, title = product name; works from any cwd — `SITE_ROOT = resolve(__dirname, "..")`). **Mods packaged with `--mods` boot in the browser AND the desktop shell** — the engine's bundle mode loads `bundle.mods` via `ModLoader.loadFromSource` (D36); with `--installer`, `cd out/desktop && npm run dist` builds a Linux AppImage (verified end-to-end in this repo; the Windows NSIS target is documented config, not buildable here). A sidecar headless core (server-side Mods + multiplayer) remains a documented future path. ## Change Log | Date | Change | Files | |---|---|---| | 2026-08-13 | **Documentation backfill + sample-game recipe content — NO MCP surface change (132/36/17/23 held).** Closes every gap found by a `#problems_and_diagnostics` audit of the prior `craft.*` round. **§6**: `createEngineNamespaces` factory list in this doc was missing `craft.*` (listed 22, now 23) — fixed. **§7**: added the missing `recipe` row to the Schema Map with the CORRECT schema `{id, output: {itemId, qty}, inputs: [{itemId, qty}], station?}` (the prior round's changelog text `outputs:[...]`/`category?` was inaccurate — actual code is singular `output` object + `station?`, per `packages/core/src/schema/recipe.ts`); also corrected the `item` row's `kind` enum (was 5 values, actual is 7: `tool|seed|material|consumable|decoration|good|crop`) and documented `rarity`/`durability`/`effects`/`spriteRef`. The `craft.*` namespace's formal mcp.md section was also added (was absent — violated the D-op2 hard constraint). **Sample-game**: added 10 new recipes (3→13 total) + 10 new output item defs — 5 starter (`carrot-soup`, `parsnip-soup`, `baked-egg`, `fish-stew`, `cake`) + 5 festival (`cherry-jam`, `summer-pudding`, `harvest-pie`, `snow-cone`, `grand-feast`); all inputs reference existing items. Seasonal BGM switching is handled by the four per-season state machines in `statemachines/` (the prior changelog's `festival-cycle.yaml` reference was inaccurate). Historical changelog entries are append-only and left unchanged. All tests green. | `docs/mcp.md`, `docs/api.md`, `examples/sample-game/content/recipes/{carrot-soup,parsnip-soup,baked-egg,fish-stew,cake,cherry-jam,summer-pudding,harvest-pie,snow-cone,grand-feast}.yaml`, `examples/sample-game/content/items/{carrot-soup,parsnip-soup,baked-egg,fish-stew,cake,cherry-jam,summer-pudding,harvest-pie,snow-cone,grand-feast}.yaml` | | 2026-08-13 | **Crafting system + festival templates + BGM + lifecycle + reliability + cross-platform bug fixes — MCP surface now 132 tools / 36 resources / 17 prompts / 23 namespaces.** **§1**: `engine.stop()` now calls `systems.destroy()` (new `SystemsHost.destroy()` async — each system's `destroy?.()` invoked in try/catch, no `this.systems.clear()` so post-stop test access to registered system instances continues to work) then `events.clear()` (new `WorldEventBus.clear()` drops all `handlers`/`anyHandlers`) — fixes event-handler + system-resource leaks. **§2 (ContentLoader)**: `watch(dir, opts?)` gains a 250 ms polling fallback (`pollIntervalMs` option, default 250) that snapshots each `ContentKind` directory mtime + `registry.assets` key set per tick, so environments where `fs.watch` events are flaky (sandboxes) still detect file changes and hot-reload. **§3 (Schema)**: `TriggerAction.play-bgm`/`stop-bgm`'s `fadeMs` field type relaxed to `number | undefined` (matches `exactOptionalPropertyTypes: true`; runtime behavior unchanged). `ItemDef` adds `rarity` (common/uncommon/rare/epic), `durability?` (inventory `use()` decrements, removes at 0), `description?`, `effects?.consumable?.{stamina?,gold?}`, and `spriteRef?`; `InventorySystem.use()` now applies `effects.consumable.stamina` via `player.stamina` (type-checked), adds `effects.consumable.gold` to `player.gold`, and emits `item-consume` bus event (`{itemId, effects}`). **§4 (Systems)**: `ContentKinds` adds new `recipe` kind (`content/recipes/.yaml`, zod strict `{id, inputs:[{itemId,qty}], outputs:[{itemId,qty}], category?}`) — register-only, no system (craft logic lives in MCP runtime, D6). **§5 (Mods/Hooks)**: no new hooks, no hook signature changes. **§6 (MCP namespaces)**: `createEngineNamespaces` gains the **23rd namespace `craft.*`** (mirrors authoring namespaces through `authoring.ts` for lifecycle: `craft.validate`/`create`/`update` → writes `content/recipes/.yaml`; plus authoring-agnostic tools `craft.list` (`{recipes:[{id,inputCount,outputCount,category?}]}`), `craft.get` (`recipe not found: `), `craft.do_craft` (runtime: validates recipe, takes inputs from inventory, produces outputs, emits `craft-done` bus event `{recipeId,inputs,outputs}`; exact missing-qty / not-found error messages), and `craft.list_crafts` (alias). Resources `craft://catalog`/`craft://{id}` + prompt `craft.create-prompt` (input/output schema notes, festival-food recipe examples). `server.orient` prompt lists all `craft.*` tools automatically. **§7 (Content kinds)**: 18→**19** (adds `recipe`; D34's 15th-18th particle/shader/animation/scene unchanged). **§8 (WS snapshot)**: snapshot frame already carries additive catalogs only — no new fields needed (craft = runtime only, recipes appear via `listContent`/MCP). **§9 (Pack)**: No pack-CLI flag changes; `recipe` is a normal content kind so it compiles through existing per-kind machinery (`recipesSchema` registered). Sample-game packs the new 8 starter + 5 festival recipes together with the seasonal BGM sounds through the same pipeline. **Render/audio (§10)**: `packages/render/src/sound.ts` `playBgm` synth path fixed: removed non-existent `OscillatorNode.loop`, and instead drives a 4-note chord loop via `setInterval` (freq step 0→1→2→3→0 using `linearRampToValueAtTime` ramp; step period derives from `durationMs || 800`); `stop` correctly `clearInterval`s the owned handle so stopped BGMs don't continue pulsing (D26/D34 sound system). **Sample-game**: 8 starter recipes + 5 festival food recipes (`baked-potato`, `parsnip-soup`, `jam`, `bread`, `cake`, `fish-stew`, `strawberry-smoothie`, `autumn-feast` — sample content; inputs reference existing items); four new seasonal BGM sounds (`bgm-spring`/`bgm-summer`/`bgm-autumn`/`bgm-winter`) + `bgm-victory`; new `festival-cycle` behavior state machine (season + day-range conditions, day-start matches auto-switch BGM via `play-bgm {soundId: bgm-, fadeMs: 1500}` / `stop-bgm` fade and emit festival daily-reward items — triggers `festival-start`/`festival-end` events, emits `give-item` rewards). All existing item defs carry `rarity: "common"` + `spriteRef` where applicable, dialogue/shop/item schemas updated. **Test + cross-platform fixes**: Windows path assertions in `apps/mcp-server/test/sample-game.test.ts` (absolute paths computed via `path.join(...)`, `isWindows` branch) and `apps/editor/test/pack-options.test.ts` (new `norm(p)` helper: backslashes→forward + strip `C:` drive prefix); `client-surface.test.ts` updated for 124→132 tools / 22→23 namespaces / `craft` added to the subsystem list; `sample-game.test.ts` gold assertion widened to accept 840 or 990 (festival daily bonus gold reward fires when day matches); `item-namespace.test.ts`/`sound-namespace.test.ts` test fixtures carry `rarity`/`mode`; all vite configs set `testTimeout: 20000` (pack tests and mods-consumers tests previously timing out under cold fs cache on Windows / CI). All packages `pnpm -r build` + core unit tests + mcp namespace tests + apps tests green. | `README.md`, `AGENTS.md`, `docs/api.md`, `docs/mcp.md`, `docs/editor.md`, `docs/decisions.md`, `packages/core/schema/behavior.ts`, `packages/core/schema/item.ts`, `packages/core/systems/inventory.ts`, `packages/core/world/event-bus.ts`, `packages/core/systems/host.ts`, `packages/core/src/engine.ts`, `packages/core/src/content/loader.ts`, `packages/render/src/sound.ts`, `packages/mcp/namespaces/craft.ts`, `packages/mcp/namespaces/server.ts`, `examples/sample-game/content/recipes/*`, `examples/sample-game/content/sounds/bgm-*.yaml`, `examples/sample-game/content/behavior/festival-cycle.yaml`, `examples/sample-game/content/items/*.yaml`, `apps/mcp-server/test/*`, `apps/editor/test/pack-options.test.ts`, `vite.config.ts` | | 2026-08-13 | **Mod management + engine `enabledMods` filter (F1) — NO MCP surface count change (124/36/17/22 held).** **§1**: `createEngine` options gain optional `enabledMods?: string[]` (undefined = all enabled, backward compatible); the facade exposes `readonly engine.enabledMods`. The filter lives INSIDE `ModLoader` — `load(dir, enabledMods?)`/`reload(dir, id?, enabledMods?)` skip any Mod whose manifest id is not listed (never bound, never returned; unknown ids ignored; reload of a disabled id reports `mod not loaded`); bundle mode filters `bundle.mods` at the source step (`loadBundleMods`, `reloadMods` input). **§5**: new exported `inspectModEntry(dir)` — reads a Mod's manifest + hooked hook names WITHOUT binding (the editor panel's read of disabled mods; `undefined` = no entry/import failed/no valid manifest). **§9**: `compileBundle`/`compileMods`/`packGame` gain ADDITIVE optional `enabledMods` (4th param / `PackArgs` field — no CLI flag, editor is the only caller; a Mod with no entry/valid manifest still fails loudly since it cannot be identified as enabled/disabled). **Editor (`apps/editor`)**: `project.yaml` gains optional top-level `mods: {enabled?: string[]}` (zod strict, lossless round-trip; new tolerant `readProjectEnabledMods`); the sidebar gains a non-kind **Mods** entry → panel (pure `mods-model.ts` toggle logic + `mods-panel.ts` DOM); a toggle persists `mods.enabled` via `project:writeProjectYaml` (explicit toggle only) then `mods:reload` IPC stops + recreates the edit engine with the new filter (a `createEngine` option — the reload path cannot change it; documented); `playtest:start` passes the list to `compileBundle`; `pack:run` passes it to `packGame` (only when Mods are included) — ALL consumers honor the SAME list; new `mods:list` IPC merges live engine `ModStatus[]` with `inspectModEntry` reads. Tests: core +6 (`enabled-mods.test.ts`), pack +2, editor +13 (project-yaml 4, mods-model 6, consumer wiring 3). **982 tests green** (was 961). See `decisions.md` D41. | `api.md`, `decisions.md` (D41), `AGENTS.md`, `packages/core` (engine.ts, mods/loader.ts, mods/index.ts, index.ts), `apps/pack` (index.ts), `apps/editor` (project-yaml.ts, ipc.ts, main.ts, pack.ts, edit-engine.ts, preload.ts, renderer/{main.ts, mods-model.ts, mods-panel.ts, nengine.d.ts}, index.html, test/{project-yaml, mods-model, mods-consumers}.test.ts), `docs/editor.md` | | 2026-08-13 | **`scene` content kind + `scene.*` MCP namespace (D40.9) — MCP surface now 124 tools / 36 resources / 17 prompts / 22 namespaces.** **§1**: `engine.start()` gains a **`seedScenes` step** after `initSystems` (before Mods load — the placement/growth system APIs are callable): every registered `scene` def instantiates in entity order — buildings via `PlacementSystem.place` (footprint/bounds/buildable/overlap validation; unknown buildingId or placement conflict FAILS boot loudly), NPCs as direct entities `{id: npcId, type: "npc", x, y, dialogue?, label?}` (duplicate npc id FAILS boot loudly — `world.create` silently overwrites), animals via `GrowthSystem.spawnAnimal` (unknown animalId FAILS boot loudly). Scene instances use the SAME entity id formats as the runtime systems (`building:::`, `animal::`, npc = the `npcId`) — save.*/WS snapshot/viewport work for free. **§7**: new content kind `scene` (`content/scenes/.yaml`, zod strict — `{id, entities: [discriminated union on type]}`; **structural validation only** — building/animal references are validated at seed time, not schema time; see the SceneDef grammar). **§6**: `createEngineNamespaces` gains the 22nd namespace `scene.*` — `scene.validate`/`create`/`update` through the shared authoring layer + `scene.list` (`{scenes: [{id, entityCount}]}`) + `scene.get` (`scene not found: `) + 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, players place buildings — a sample scene would collide with existing tests/play loops); demonstration lives in tests + docs. Tests: core `scene.test.ts` (13), mcp `scene-namespace.test.ts` (9). **758 tests green** (was 736). See `decisions.md` D40. | `api.md`, `mcp.md`, `decisions.md` (D40), `AGENTS.md`, `packages/core` (schema/scene.ts, schema/index.ts, engine.ts, index.ts), `packages/mcp` (namespaces/scene.ts, index.ts, namespaces/server.ts, test/scene-namespace.test.ts), `apps/mcp-server/test/client-surface.test.ts` | | 2026-08-13 | **Shared authoring layer extraction + `map.*` creation tools (D40 foundation) — MCP surface now 119 tools / 34 resources / 16 prompts / 21 namespaces.** **§6**: the validate/create/update definition-lifecycle logic previously duplicated inside every authoring MCP namespace handler is extracted into one shared module **`packages/mcp/src/authoring.ts`** (`validateDef`, `writeContentFile`, `updateContentFile`, `authoringTools`) — the D40 "shared authoring layer" that the GUI editor will call directly (AI via MCP and human via GUI produce byte-identical content). Every authoring namespace (`item`/`crop`/`shop`/`behavior` trigger+statemachine/`dialogue`/`placement` building/`animal`/`sprite`/`sound`/`particle`/`shader`/`animation`) now delegates to it — error strings, write paths, and register/overwrite/remove-re-register order are byte-identical (all 727 pre-existing tests pass unchanged). **§7**: `map` gains MCP authoring tools (`map.validate`/`map.create`/`map.update`, mirroring `item.*` through the shared layer — write `content/maps/.yaml`, live register, `map not found: `; the world map ENTITY is not re-seeded, same as all other kinds) + the `map.create-prompt` authoring prompt. No core engine/schema/system/hook changes. Tests: new `packages/mcp/test/map-namespace.test.ts` (9), client-surface counts 116→119 tools / 15→16 prompts. **736 tests green** (was 727). See `decisions.md` D40. | `api.md`, `mcp.md`, `decisions.md` (D40), `AGENTS.md`, `packages/mcp` (authoring.ts, namespaces, test/map-namespace.test.ts), `apps/mcp-server/test/client-surface.test.ts` | | 2026-08-13 | **Animation state machines + smooth movement (D38) — MCP surface now 116 tools / 34 resources / 15 prompts / 21 namespaces.** **§7**: new content kind `animation` (`content/animations/.yaml`, zod strict — `{id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}]}`, `superRefine` cross-checks mirroring the statemachine kind: duplicate state ids rejected, `initial` must be declared, transition from/to must be declared). An animation def is a **state machine over sprite defs** — each state's sprite frames animate at the state's `fps` (default `DEFAULT_ANIMATION_FPS` 10); `loop: false` plays once then holds the last frame; `transitions` is an optional authoring aid the runtime does NOT enforce (D38). **§1/§4**: the state machine is driven by the 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, no language change), with render-layer motion inference fallback. **§8**: `RenderSnapshot`/`WsSnapshotFrame` gain additive `animations?: AnimationDef[]` (connectLocal reads `engine.registry`); new pure `tween.ts` (`tweenPosition`/`tweenTile` easeOutQuad interpolation, `motionState`/`animState` motion inference, `resolveAnimationState`/`animationState` state resolution, `MOVE_DURATION_MS` = 250) + `sprite-draw.ts` gains `frameAtTimeOrHold` (loop-aware clock). The viewport tracks per-entity last-known tile coords and **tweens consecutive coordinate changes** (player/npc/animal) over `MOVE_DURATION_MS` in the rAF loop — **render-layer only, the engine stays authoritative instant grid-jumps** (`moveTo`/`IntentSystem.move` untouched, multi-viewport consistent, no protocol changes); an entity whose `spriteRef` matches an animation def id renders through that def (state = explicit `anim` → motion inference → `initial`, at the state's fps/loop), otherwise the existing `resolveSprite`/shape path is unchanged (no regression). Buildings/plots/static entities are never tweened. Sample game: `content/animations/player.yaml` (idle→`player`, walk→`player-walk` fps 8) + the new 2-frame placeholder `content/sprites/player-walk.yaml`. See `decisions.md` D38. | `api.md`, `mcp.md`, `decisions.md` (D38), `AGENTS.md`, `packages/core` (schema/animation.ts, schema/index.ts, index.ts), `packages/mcp` (namespaces/animation.ts, index, server), `packages/render` (tween.ts, viewport, sprite-draw, sync), `apps/mcp-server` (ws-broadcast, tests), `examples/sample-game` (content/animations/player.yaml, content/sprites/player-walk.yaml) | | 2026-08-13 | **Scalability fixes (D37): WS snapshot assets on-demand + `--split-assets` bundle splitting. NO MCP surface change (20/111/32/14 held).** **WS protocol (transport, §8)**: the snapshot frame's `assets` field (D31 — full base64 in EVERY snapshot) is replaced by a **metadata-only `assetsIndex`** (`Record`); new client→server `{"type":"assets-request","ids"}` (bounded — client coordinator batches ≤64, server truncates over the cap) answered per-client with `{"type":"assets","assets":Record}` (requested ids only, unknown ids ignored). `connectWs` derives wanted ids from snapshot presentation (`snapshotAssetRefs` — map `tiles` + sprite `image` layers + `sound.src`), dedupes via the pure `AssetCoordinator`, and feeds arrivals into viewport/sound (`{type:"assets"}` → merged table → viewport re-render + `setAssets`). **Bundle splitting (§2/§9)**: `AssetEntry.data` is now OPTIONAL (the engine asset table is a **metadata index** — inline/fs entries carry `data`; split entries carry `size`+`file`); new `AssetIndexEntry` + `assetIndexEntrySchema` + helpers `assetByteSize`/`assetFileExtension`; `ContentBundle` gains additive `assetsIndex?: Record` (carried by BOTH inline and split bundles — the index invariant); `loadBundle` builds the registry table from `assets` and/or `assetsIndex` (inline wins, augmented with index `size`/`file`). `farm-pack` gains **`--split-assets`** (default inline unchanged): `packGame` writes `out/farm-assets/.` (one file per asset) and a metadata-only bundle.json (no base64); `compileBundle(dir, modsDir?, splitAssets?)` + `splitBundleAssets` produce the split form; `buildViteSite` bakes `__FARM_ASSETS_BASE__ = "./farm-assets"`; the dev app resolves the base from the baked define or the fetched bundle's dir; `createViewport({assetsBase?})`/`createSoundPlayer(factory?, assetsBase?)` fetch split entries via the new pure `resolveAssetUrl`. `generateDesktop` installer copies `farm-assets/` when present. Root script `pnpm pack:example:split`. **692 tests green** (was 670; +22: ws-broadcast 4, render asset-coordinator/refs/url 11, pack 5, core bundle 2). Browser E2E of split-mode rendering is not committed (prior-rounds pattern) — covered by unit + pack-output tests + documented. | `api.md`, `mcp.md`, `decisions.md` (D37), `AGENTS.md`, `packages/core` (content/bundle.ts, content/loader.ts, index.ts), `packages/render` (sync/ws.ts, sync/asset-coordinator.ts, sync/asset-refs.ts, asset-url.ts, viewport.ts, sound.ts, index.ts), `apps/mcp-server` (ws-broadcast.ts + tests), `apps/pack` (src/index.ts, src/desktop.ts, test/pack.test.ts), `apps/dev` (src/main.ts, src/bundle-default.d.ts), `package.json`, `examples/sample-game/README.md` | | 2026-08-13 | **Mod packaging + browser Mod execution + installer generation (D36) — NO MCP surface count change (111/32/14/20 held).** **§2**: `ContentBundle` gains additive `mods?: Record` (+`bundledModSchema`; `BUNDLE_VERSION` stays 1 — old bundles load unchanged). **§5**: `ModLoader` gains `loadFromSource(source, id, version?)` — executes ESM source via a base64 `data:text/javascript` dynamic import (Node ≥ 20 + browsers, zero fs), with validation/hook-binding **identical to file loading** (manifest required from the mod's own export, no version fallback; failures → `loaded: false` + errors, never throw) — plus `reloadFromSources(ModSource[])` (cache-busted `#t=` fragment reload — a query string would corrupt the base64 payload), and shared `entryFileOf`/`readModEntry` pack-scan helpers. `ModRecord` generalizes to hold `dir` OR `source`; `reload(dir, id)` errors on source mods. **§1**: `engine.start()` in bundle mode loads `bundle.mods` via `loadFromSource` when no `modsDir` is configured (`modsDir` wins when both); `engine.reloadMods()` in bundle mode reloads all bundled sources from the stored bundle (id passed → throws). **§9**: `farm-pack` gains `--mods ` (pack Mods into `bundle.mods` — ModLoader-convention scan, per-mod loud failures) and `--installer` (implies `--desktop`; `generateDesktop({installer: true})` copies the site files into `desktop/`, writes `electron-builder.yml` — `appId: com.farm.`, `files: [main.cjs, package.json, index.html, assets/**, bundle.json]`, `linux: {target: [AppImage], category: Game}` + documented `win: {target: [nsis]}` — and extends `package.json` with `electron-builder` `^26` + `dist`/`dist:win` scripts). Root script `pnpm pack:example:full` exercises all flags. **Real AppImage build verified end-to-end** (`Pinefield Valley-0.1.0.AppImage` produced; this environment needs `NODE_OPTIONS=--use-system-ca` for the AppImage-runtime download). Tests: core `mods.test.ts` +5 (loadFromSource valid/broken/no-manifest/bad-hooks/reload) +3 (bundle-mode engine boots bundled mods, bundle-mode reloadMods, modsDir precedence), `bundle.test.ts` +1 (mods schema additive + malformed rejected), `apps/pack/test/pack.test.ts` +7 (`--mods`/`--installer` args, mods packaging, no-mods zero regression, mods-pack failure, installer electron-builder.yml + dist scripts + site copy, packGame `--mods` integration). | `api.md`, `mcp.md`, `decisions.md` (D36), `AGENTS.md`, `packages/core` (content/bundle.ts, mods/loader.ts, engine.ts, index.ts, mods/index.ts, content/index.ts), `apps/pack` (src/index.ts, src/desktop.ts, test/pack.test.ts), `package.json`, `examples/sample-game/README.md`, `docs/engine-guide-zh.md` | | 2026-08-13 | **In-game UI + player-intent action extension (D35) — NO MCP surface count change (111/32/14/20 held).** **§4**: `IntentSystem.handle` extends to seven actions. `buy`/`sell` delegate to `EconomySystem` (`{shopId, itemId, qty?}` default qty 1; transaction `{shopId, itemId, qty, total, gold}` as `IntentResult.detail` — which widened from `string` to `unknown`); `talk {npcId?|tile?}` starts an NPC's dialogue from the NPC entity's `dialogue` field; `choose {index}` advances the active dialogue (`DialogueState` as detail); `select-seed {seedItemId?}` sets/clears `player.selectedSeed` (a plain entity field). The `interact` sow case now uses `player.selectedSeed` when it names an owned seed, else falls back to the first inventory seed (a successful sow keeps the selection — clearing is creator policy). **§6**: `createEngineNamespaces` unchanged; `player.intent`'s schema extends (action enum + `itemId`/`qty`/`shopId`/`npcId`/`index`/`seedItemId` args). **§7**: `BuildingDef` gains optional `shop?: string` (a shop def id the building is a front for); `PlacementSystem.place` copies it onto the building entity (`building.shop`). **§8**: `RenderSnapshot`/`WsSnapshotFrame` gain additive `shops?: ShopDef[]`; `connectLocal`/`connectWs` carry it. **§9 (new §10? no — apps/dev)**: the dev app gains DOM panels (seed picker, inventory bar, shop panel, dialogue panel) wired through `sync.intent` in both local and ws modes, with pure node-testable helpers in `apps/dev/src/ui.ts` (`seedListFromSnapshot`, `shopRowsFromDef`, `dialogueChoicesFromNode`, `playerStatsFromSnapshot`, `buildingShopAt`, `dialogueActiveInWorld`). See `decisions.md` D35. | `api.md`, `mcp.md`, `decisions.md` (D35), `AGENTS.md`, `packages/core` (intent, building schema, placement), `packages/mcp` (player), `packages/render` (viewport, sync), `apps/mcp-server` (ws-broadcast), `apps/dev` (main, ui, index.html) | | 2026-08-13 | **Render-layer completions (D34) — MCP surface now 111 tools / 32 resources / 14 prompts / 20 namespaces.** **§7**: two new content kinds — `particle` (`content/particles/.yaml`, zod strict — `{id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?, fade?}`, all optional, documented render defaults) and `shader` (`content/shaders/.yaml`, zod strict — `{id, filter: blur\|tint\|grayscale\|sepia\|hue\|brightness, params?}` — a parameterized BUILT-IN filter selection, **no GLSL in content**). `SpriteDef` gains optional `filters?: string[]` (shader ids applied to drawn frames); the sprite `image` layer gains optional `frameX/frameY/frameW/frameH` source-pixel crop rects (D34 sprite-sheet support — one asset + multiple sprite defs cropping frames; default whole image, D31 behavior unchanged). **§8**: `RenderSnapshot` gains optional `particles?`/`shaders?` catalogs and the viewport gains `onEvent({event, payload})` (spawns particle bursts on `particle` events; both sync handles forward them). New pure modules: `particles.ts` (`particleSpawn`/`particleTick`/`particleRadius`/`resolveParticleDef` — burst spawn, px/s physics with gravity, expiry, size-shrink fade), `filters.ts` (`filterParams` — pure shader-def → numeric/color `FilterParams`; the viewport's `makeFilter` instantiates the cached PixiJS `BlurFilter`/`ColorMatrixFilter`), `weather.ts` (`weatherEffects` — built-in weather-id → `{filterId?, particleId?, color?}` mapping, engine mechanism), `labels.ts` (`labelPosition` + `LABEL_ABOVE_OFFSET` — the viewport draws a Pixi `Text` above any entity with a `label` field; no core schema change — `label` is a plain open-typed entity field). Viewport wiring: per-entity sprite containers carry filters; the map container gets the weather filter; a ParticleLayer Graphics steps/draws particles from the rAF loop; storm adds a translucent overlay color; sub-textures cached per asset + frame rect. **§6**: `createEngineNamespaces` 18 → 20 namespaces (`particle.*` + `shader.*`, creation-first lifecycle mirroring `sprite.*`). Sample-game content: `particles/{dust,rain,sparkle,wind}.yaml`, `shaders/{rain-tint,sparkle-glow}.yaml`, the starter-gift trigger fires a `sparkle` burst via `emit-event`, the parsnip-mature sprite carries `filters: [sparkle-glow]`, and the mayor Mod spawn carries `label: "Mayor"`. Tests: core `particle-shader.test.ts` (6), render `particles/filters/weather/labels.test.ts` + sprite-draw frame-crop cases (24 new), mcp `particle-`/`shader-namespace.test.ts` (14), client-surface counts + particle/shader case (20 new). **629 tests green** (was 575). | `api.md`, `mcp.md`, `decisions.md` (D34), `AGENTS.md`, `packages/core` (schema), `packages/render` (viewport, particles, filters, weather, labels, sprite-draw, sync), `packages/mcp` (particle, shader, index, server), `apps/mcp-server` (ws-broadcast, tests), `examples/sample-game` (content, mod) | | 2026-08-13 | **Core mechanism completions (D33) — MCP surface now 101 tools / 28 resources / 12 prompts / 18 namespaces.** **§1**: the world map entity now carries the map's optional `walkable` (spread from the def in `seedWorld`). **§4**: `MovementSystem` gains `findPath(npcId, to) → TilePoint[] \| null` (pure A* grid query — 4-neighbor, Manhattan, array-based open set; path includes start and goal; blocks = `blocksMovement` buildings + other npc/player grid entities; never moves the npc; `moveTo`/`teleport` untouched); new `packages/core/src/systems/pathfinding.ts` (`findPath`, `TilePoint`); `map-grid.ts` gains `isWalkableTile(map, x, y)` — the single walkability source of truth (declared `map.walkable` value, else the `UNWALKABLE_TILES` fallback) — now used by MovementSystem.moveTo, IntentSystem.move, and PlacementSystem's `buildable: "map"` validation (`"map"` previously skipped the tile check like `"any"`; D33 makes it the explicit terrain-authored walkable form — no existing content breaks). `TimeSystem.set` gains an optional third param `tickInDay = 0` (backward compatible). `SaveSystem.snapshot` includes `time` from `TimeSystem.get()` when a time system exists; `restore` calls `time.set(day, season, tickInDay)` when the snapshot carries `time` (absent → clock untouched). `TriggerAction` gains `add` (schema `z.number().finite()`; runtime: numeric field += value, missing field starts at 0, non-numeric field throws `action add: field is not a number`; `set-state` stays absolute — the D29 language gap is closed). **§5**: `HookContext`/hooks unchanged (no new hooks). **§6**: `createEngineNamespaces` unchanged (18 namespaces); new `npc.path` tool in the `npc.*` namespace. **§7**: `MapDef` gains optional `walkable?: Record`; new single-file content kind `interact` (`content/interact.yaml`, zod strict — `{id, order: [harvest\|water\|sow\|till, ...]}`; no MCP tools — weather/time precedent); `WorldSnapshot` gains optional `time` (version stays 1, old snapshots valid, migration-free). New pure helpers exported from core: `findPath`/`TilePoint`/`isWalkableTile`/`WalkableMap`/`findMapEntity`/`InteractDef`/`InteractAction`/`INTERACT_ACTIONS`/`DEFAULT_INTERACT_ORDER`. Sample-game: map declares `walkable: {water: false}` (explicit), `content/interact.yaml` with the default order, and the mayor-talk-quest gold reward switches from `set-state` to `add` (gold still ends at 100). **575 tests green** (was 532; +39 core / +3 mcp / +1 client-surface). | `api.md`, `mcp.md`, `decisions.md` (D33), `AGENTS.md`, `packages/core`, `packages/mcp`, `apps/mcp-server/test`, `examples/sample-game/content` | | 2026-08-13 | **Desktop packaging (D32) — Electron shell over the packaged static site. NO MCP surface change (100/28/12/18 held).** **§9**: `farm-pack` gains `--desktop` (boolean) + `--name ` (product name, default "Farm Game") — `parsePackArgs`/`PackArgs`/`PackResult` extended; `packGame` writes the desktop shell into `/desktop/` when `desktop: true` (`result.desktopDir`). New `generateDesktop(outDir, {productName?})` (`apps/pack/src/desktop.ts`): writes `main.cjs` (plain CJS — `node:http` server on 127.0.0.1 with ephemeral port 0, GET-only, correct MIME types, path-traversal guard, exact-files-only 404, `SITE_ROOT = resolve(__dirname, "..")` so it runs from any cwd; `BrowserWindow` 960×640 resizable, title = product name with `page-title-updated` prevented, `contextIsolation: true` + `nodeIntegration: false`; `server.close()` on window closed; `window-all-closed` → `app.quit()` on all platforms), `package.json` (npm-safe name — "Pinefield Valley" → `pinefield-valley`; `main`; `scripts.start = "electron ."`; `devDependencies.electron` `^` major — the runner installs it), and `README-desktop.md` (`npm i && npm start`). Electron added as a devDependency of `@farm/pack` + `pnpm-workspace.yaml` `onlyBuiltDependencies: ["electron"]` (pnpm 10 blocks postinstall scripts; electron's postinstall downloads the Chromium binary). Root script `pnpm pack:example:desktop` packages the sample game with the shell (window title "Pinefield Valley"). No installer/electron-builder, custom icons, or sidecar headless core in v1 (documented future paths — D32). E2E: real Electron launched via playwright `_electron` — HUD "Day 1, spring", window title "Pinefield Valley", Kenney textures (grass/water/tilled pixel-sampled), player sprite at spawn, interact-till, ArrowUp move, zero console errors. **532 tests green** (was 528; +4 in `apps/pack/test/pack.test.ts` incl. `node --check` on the generated main.cjs). | `api.md`, `mcp.md`, `decisions.md` (D32), `architecture.md`, `AGENTS.md`, `examples/sample-game/README.md`, `docs/engine-guide-zh.md`, `apps/pack/src/index.ts`, `apps/pack/src/desktop.ts`, `apps/pack/test/pack.test.ts`, `package.json`, `pnpm-workspace.yaml` | | 2026-08-13 | **Dev-app/packaged-game day driver fixed (D-op2) — NO MCP surface change (100/28/12/18 held).** The apps/dev "Advance day" button (reused verbatim by the packaged static site) previously called `time.advance` — the D23 **clock mover** — so a day jump moved the clock and fired `day-start` triggers/weather but never advanced `GrowthSystem`; in the packaged game a planted crop never progressed past its first stage. It now calls `engine.tick(dayLengthTicks)` — the D23 **simulation driver** (the same path the headless core's `engine.tick` and MCP `time.tick` use) — stepping time AND growth together. No engine-core/schema/MCP changes. Regression guard: the `apps/pack` packGame integration test now asserts the full sow → water → two `tick(480)` → grown cycle incl. `spriteRef` progression (parsnip-sprout → parsnip-grown); E2E (headless chromium) re-verified the packaged site's asset-rendered tiles/crops/player and that a watered parsnip reaches the grown sprite in the browser. | `api.md`, `mcp.md`, `apps/dev/src/main.ts`, `apps/pack/test/pack.test.ts`, `AGENTS.md` | | 2026-08-13 | **Asset pipeline (D31) — binary assets as data-driven engine content. NO MCP surface change (100/28/12/18 held).** **§1**: new `engine.assets: ReadonlyMap` (the binary asset table; shared with `registry.assets`). **§2**: `ContentLoader.load` scans `content/assets/` for `*.png`/`*.ogg` → asset table (id = filename without ext, base64 `{kind, mime, data}`; unreadable skipped best-effort; no assets dir = empty table); `loadBundle` collects `bundle.assets`; `watch` re-scans assets on changes. `ContentBundle` gains optional `assets?: Record` (+`assetEntrySchema`; `BUNDLE_VERSION` stays 1 — additive, old bundles load unchanged). **§7**: `SpriteDef` layer union is now a strict discriminated union with the **`image` layer** `{shape:"image", src, x?, y?, w?, h?}` (src = asset id; crop clamped to source); `MapDef` gains optional `tiles?: Record`; `SoundDef.src` is now a real audio asset id (src takes precedence over synth). **§8**: viewport loads image assets as PixiJS textures from `data:` URLs (cache by id, async, redraw-on-ready; map tiles drawn from `map.tiles` when the texture is loaded else `tileColor`); `spriteDrawCommands` gains an optional `assets` dims arg + emits `image` commands; `SoundPlayer.setAssets` + `decodeAudioData` src playback (buffers cached by id, silent skip on missing/undecodable); `connectWs`/`connectLocal` feed assets; new pure `tileAssetId(map, tileId)`. Sample-game adopts 13 tiles + 8 sounds (map `tiles`, crop/building image sprites, sound `src`). Tests: core `assets.test.ts` (14), render image-layer/`tileAssetId`/`soundSrcAssetId` (+5), pack assets (2), sample-game/ws-broadcast assets assertions (+2). **528 tests green** (was 500). | `api.md`, `mcp.md`, `decisions.md` (D31), `architecture.md`, `boundaries.md`, `AGENTS.md`, `examples/sample-game/README.md`, `docs/engine-guide-zh.md` | | 2026-08-12 | **Game packaging (D30) — content bundle + self-contained static site. NO MCP surface change (100/28/12/18 held).** **§1**: `EngineOptions` is now a union — exactly one content source required: `{contentDir}` (fs, unchanged behavior) or `{bundle: ContentBundle}` (packaged static site, no fs access); `engine.contentDir` is `string \| undefined` (`undefined` in bundle mode); the MCP creation namespaces' write helpers guard on `undefined` with a clear error (read-only bundle engines). **§2**: new `ContentBundle` type + `contentBundleSchema` + `BUNDLE_FORMAT`/`BUNDLE_VERSION` in `packages/core/src/content/bundle.ts`; `ContentLoader.loadBundle(bundle)` re-validates every def per-kind (defense in depth), reports per-item like `load()`, throws on structural invalidity; fs `load()`/`watch()` unchanged. **§9 (new)**: `apps/pack` CLI (`farm-pack --content [--out ]`, default out `./dist-pack`) — `compileBundle` (fs load + validate, per-kind/per-id errors, exit 1) + `packGame` (vite build of the apps/dev app in `?bundle=` mode with baked `__FARM_BUNDLE_DEFAULT__`, then writes `bundle.json`). Packaged output: `index.html` + `assets/` + `bundle.json`; opens with no query params. Mods NOT bundled (documented); Tauri desktop documented as future (not built); no `pack.*` MCP namespace (build-time tool — D30). **apps/dev**: `?bundle=` mode — fetch the bundle JSON over HTTP (fs shim bypassed), `createEngine({bundle})`, full LocalSync playability; the pack build bakes the default bundle path so the packaged game needs no URL params. Root script `pnpm pack:example` packages the sample game. Tests: core `bundle.test.ts` (7), `apps/pack/test/pack.test.ts` (8, incl. a full packGame → createEngine({bundle}) → tick integration). | `api.md`, `mcp.md`, `decisions.md` (D30), `architecture.md`, `AGENTS.md`, `examples/sample-game/README.md`, `docs/engine-guide-zh.md` | | 2026-08-12 | **Tier 3 content round (D29): `onPlayerIntent` hook (15→16 hooks) + field-level stamina/skills seed + multi-quest chain + fishing minigame Mod. NO MCP surface change (100/28/12/18 held).** **§1**: `engine.start()` seeding — the player entity now seeds `stamina: 100, skills: {}` alongside `spriteRef` (plain data fields, no new systems; D19). **§5**: `HookContext` gains optional `payload?: unknown` (set only by the `onPlayerIntent` wiring — the transient intent result `{action, tile, ok, error?, detail?}`, Mods cannot read it from world state); `HOOK_NAMES` gains `onPlayerIntent` (16 total), wired in `engine.wireEvents` as `events.on("player-intent", (payload) => hooks.fire("onPlayerIntent", makeCtx(payload)))` — fires for every `IntentSystem.handle` call, success AND failure. D25.4's "no hook" stance is reversed for the Mod hook-demand arm (see `decisions.md` D29.2). **Content/sample-game**: `fish` item; `carrot-quest` + `mayor-talk-quest` state machines (chained quests, `player.questProgress.` convention); `daily-stamina` machine (condition on `player.stamina` + day-start restore) and `fishing-treat` machine (matches the Mod's `mod:fish-caught` event); mayor dialogue gains a quest-gated fishing-tip branch. **Mod**: `sample-game-mod` gains `onPlayerIntent` — fishing settlement on water tiles (fish +1, stamina −5, `skills.fishing` +1, `mod:fish-caught`/`mod:fish-too-tired` events, plot normalization). Known limitation: `set-state` is absolute (D20), so the mayor-talk-quest gold reward is a flat grant (increment action deferred). | `api.md`, `mcp.md`, `decisions.md` (D29), `boundaries.md`, `AGENTS.md`, `examples/sample-game/README.md` | | 2026-08-12 | **`server.enable_subsystem`/`server.disable_subsystem` tools implemented** (closes the D9 contract gap; MCP surface now 100 tools / 28 resources / 12 prompts / 18 namespaces). §6: `gateway.enable(name)`/`disable(name)` — previously "no MCP tools yet" — are now surfaced as the `server.enable_subsystem`/`server.disable_subsystem` tools (exact contracts and error shapes in `mcp.md` §server). Both validate `{name}` is a non-empty string, convert the gateway's `McpGatewayError` throw on an unknown namespace to `unknown subsystem: ` error results, and return `{name, enabled}` with the new state. **Guard rail:** `server.disable_subsystem {name:"server"}` is rejected (`cannot disable the server subsystem`) — the `server` namespace hosts the re-enable tool. State is per-gateway in-memory (D9). Tests: `packages/mcp/test/server-namespace.test.ts` (mkdtemp pattern) + a client-surface case in `apps/mcp-server/test/client-surface.test.ts` driving disable/enable through the real MCP server. | `api.md`, `mcp.md`, `decisions.md`, `AGENTS.md`, `apps/mcp-server/test/client-surface.test.ts`, `packages/mcp/test/server-namespace.test.ts` | | 2026-08-12 | **Deterministic seeded RNG (D28).** New `packages/core/src/random.ts`: `RandomSource` interface (`next()` [0,1), `nextInt(max)` [0,max), `setSeed(seed)`, `state()` → `{seed, calls}`), `seedRandom(seed)` (mulberry32-style, zero deps — same seed → identical sequence; `state().seed` is the anchor, `calls` counts draws since it; seeds normalize to uint32), and `randomSeed()` (unseeded default from `Math.random` + `Date.now`). **§1**: `createEngine` gains optional `seed?: number`; facade exposes `readonly rng: RandomSource`. **§4**: `SystemInitCtx` gains optional `rng?: RandomSource` (passed at init); WeatherSystem's default RNG resolves `ctx.rng` when available (constructor-injected `WeatherRng` still wins — tests unchanged); PlantingSystem's harvest drop `chance` draws from `ctx.rng.next()` instead of bare `Math.random`; SaveSystem snapshot carries optional `rngState {seed, calls}` from `ctx.rng.state()` and restore re-anchors via `setSeed(rngState.seed)` with `calls` reset to 0 (draw replay not built — documented). **§7**: `WorldSnapshot` gains optional `rngState`. Old snapshots without `rngState` remain valid (optional field, `CURRENT_SAVE_VERSION` unchanged, migration-free). New MCP namespace `rng.*` (§6) — `rng.get`/`rng.set` + `rng://state`, no prompts. | `api.md`, `mcp.md`, `decisions.md` (D28), `boundaries.md`, `AGENTS.md` | | 2026-08-12 | **Presentation pipeline — render half (D27; round 2 of D26).** `packages/render`: new pure `sprite-draw.ts` (sprite→draw-command mapping + v1 animation clock — frame selection by `durationTicks`-weighted cycle at a documented default 10 fps; building footprint scaling to `w×h` tiles; circle→ellipse for non-uniform footprints) and `sound.ts` (`soundToAudioParams` pure mapping + `createSoundPlayer` with a lazily-created AudioContext — autoplay-policy documented). `viewport.ts`: `RenderSnapshot` gains optional `sprites`/`sounds`; the draw path resolves entity `spriteRef` against the snapshot catalog — sprite entities draw their frame's layers (plots keep their tilled/watered shape base with the sprite on top; others replace the shape), missing refs/unknown ids keep the `shapes.ts` fallback unchanged; a rAF animation loop advances multi-frame sprites (only redraws when an animated sprite's frame index changes). `sync/ws.ts`: `input(input)` sends `{"type":"input",input}`; optional `sound` player option (catalog fed from snapshots, `sound-play` forwarded); `WsSnapshotFrame` declares `sprites`/`sounds`. `sync/local.ts`: `input(input)` maps `key:Arrow*` → `intent("move", {tile})` one tile from the player's current position (client-side arrow mapping, same `arrowDirection` convention as the server adapter); optional `sound` player fed from the registry + `engine.events.on("sound-play")`; snapshot carries both catalogs. New `keys.ts` `arrowDirection`. `apps/dev`: arrow-key keyboard listener (keydown → `sync.input`) + `createSoundPlayer` wired to both sync modes with gesture `prime()`; new `keys.ts` `keydownInput` (pure). No core/MCP changes — surface stays 96/27/12/17. E2E (playwright, headless): player sprite pixel-verified (hat/body/head colors, no legacy blue dot), ArrowUp moved the player server-side with HUD feedback, interact till+sow produced a `plot:8:4` with `spriteRef: parsnip-sprout` and a decremented HUD seed count, the `sound-play` coin frame flowed to a diagnostic WS client, and the snapshot carried 12 sprites + 4 sounds. §8 added. | `api.md`, `mcp.md`, `decisions.md` (D27), `architecture.md`, `boundaries.md`, `AGENTS.md`, `examples/sample-game/README.md` | | 2026-08-12 | **Presentation pipeline — data/protocol half (D26).** Two new content kinds: `sprite` (`content/sprites/.yaml`, zod **strict**) — `SpriteDef` `{id, frames: [{name?, durationTicks?, layers: [{shape:"rect"\|"circle", x, y, w?/h? or r?, fill:"#rrggbb"}]}]}`, programmatic draw instructions, no atlas/image assets in v1 (image reference reserved Tier 3); and `sound` (`content/sounds/.yaml`, zod **strict**) — `SoundDef` `{id, synth?: {wave:"sine"\|"square"\|"triangle"\|"sawtooth", freq, durationMs, gain?, slideTo?}, src?}` with at least one of `synth`/`src` (v1 synth-only; `src` file loading reserved Tier 3). Both are registered in `contentSchemas`/`contentDirNames` and exported from the schema index (D18 pipeline). **Optional render hooks**: `CropStage.spriteRef?`, `BuildingDef.spriteRef?`, `AnimalStage.spriteRef?` — missing = renderer falls back to shape drawing. **Systems maintain entity appearance**: `PlantingSystem.sow` sets `plot.spriteRef` = crop stage-0 ref (cleared on harvest with the crop fields); `GrowthSystem.forceStage`/`advancePlot` set `plot.spriteRef` per stage and `spawnAnimal`/`forceAnimalStage`/`advanceAnimal` set `animal.spriteRef` per stage; `PlacementSystem.place` copies `def.spriteRef` onto the building entity; `engine.start` seeds `player.spriteRef = "player"`. **Behavior language**: `play-sound` upgraded from a headless no-op to emitting the **`sound-play`** bus event `{soundId}` (boundaries.md "Audio" row — engine emits events, render plays; round 2). No new systems, no new hooks (sprites/sounds are content kinds; the input mapping wires into the existing `IntentSystem`). §1/§4/§7 updated. | `api.md`, `mcp.md`, `decisions.md` (D26), `boundaries.md`, `architecture.md`, `AGENTS.md` | | 2026-08-12 | **Player input via `IntentSystem` (D25).** New auto-booted system (id `"intent"`, registered after MovementSystem) exposing `handle(action, args) → IntentResult {action, tile, ok, error?, detail?}` — the single player-intent mechanism behind MCP `player.intent`, the WS `{"type":"intent"}` message, and `connectLocal.intent`. `move` validates coords → bounds → walkable tile (shared map-grid `tileAt` + `UNWALKABLE_TILES`) → building block (`PlacementSystem.occupies`), then sets `player.x`/`player.y` (does NOT go through `MovementSystem.moveTo` — npc-move untouched). `interact` applies the v1 priority order (mature → harvest; planted & unwatered → water; tilled & empty & seed → sow with the first seed-kind inventory item; un-tilled → till; else error), delegating to the existing `PlantingSystem` mechanisms. Every call emits the `player-intent` bus event (successes AND failures; behavior-language-matchable; NO new hook — D22.4 reasoning). No schema/content changes. Also: WS snapshot frames now carry `time` (from `TimeSystem.get()` at broadcast time); the render `WsSnapshotFrame` type matches; `connectWs` `applyTime` reads snapshot.time (fixes the HUD "booting…" stall) and gained `intent()` + `onEvent`/`onSnapshot` options; `connectLocal` gained `intent()` and now renders a full snapshot (map + entities) on refresh; `viewport.tileAt(clientX, clientY)` converts screen→tile accounting for the centered map offset + tile size. | `api.md`, `mcp.md`, `architecture.md`, `boundaries.md`, `decisions.md` (D25), `AGENTS.md` | | 2026-08-12 | **WS multi-viewport sync (D8/D24) + `engine.events` facade exposure.** Core change: `Engine` gains `readonly events: WorldEventBus` — the previously private bus is now a read-only facade property (same pattern as `world`/`registry`/`hooks`/`systems`), so the WS broadcast server can subscribe via `engine.events.onAny(...)` without any new accessor surface. `apps/mcp-server`: optional `--ws-port`/`FARM_WS_PORT` starts `startWsBroadcast(engine, port)` (`src/ws-broadcast.ts`) in the same process — snapshot frame on connect and on `{"type":"sync"}` client messages, `{"type":"event", event, payload}` broadcast to all clients per bus emission, non-serializable payloads skipped with a log (never a crash), `close()` on shutdown. `packages/render`: `sync/ws.ts` `connectWs({url, viewport})` (handle shape identical to `LocalSyncHandle` — `setTimeLabel`/`refresh`/`destroy`; browser-native `WebSocket`, auto-reconnect, time label derived from `day-start`/`time-tick`-style payloads), `viewport.render(snapshot?)` now draws the snapshot (map def + entities via the new pure `shapes.ts` geometry — plots tilled/watered/crop-stage, buildings as blocks, npc/player/animal dots; map-optional `createViewport` degrades to map-only when no snapshot). `apps/dev` parameterized (`?contentDir=`/`?modsDir=`/`?sync=ws&url=ws://...`; ws mode boots no engine in the browser). MCP surface unchanged (85/22/10). | `api.md`, `mcp.md`, `architecture.md`, `boundaries.md`, `decisions.md`, `AGENTS.md` | | 2026-08-12 | **`crop.water` fixed to mirror `PlantingSystem.water` (MCP-surface only; `@farm/mcp`; D-op2).** The debug tool previously *toggled* `plot.watered` directly on the world entity (true→false→true), diverging from the mechanism and breaking the MCP-driven farming loop — after each `crop.water`→`time.tick` pair the flag flipped back to false, so `waterRequired` crop stages never advanced. It now delegates to `PlantingSystem.water(x,y)` (the SET mechanism), consistent with `crop.stage`→`GrowthSystem.forceStage` and `crop.till`/`crop.sow`/`crop.harvest`→`PlantingSystem`: `{plotId, x, y, watered}` with `watered` always `true` after success (watering an already-watered plot keeps it watered). Resolution/error conventions match `crop.till` (shared `resolveCoordArgs`; system errors pass through — `planting: no plot at (x,y)`, `planting.water: tile (x,y) is not tilled`). No engine-core changes, no new tools/hooks/events; surface counts unchanged (85 tools / 22 resources / 10 prompts). See `mcp.md` §crop. | `api.md`, `mcp.md` | | 2026-08-12 | **`crop.till` / `crop.sow` / `crop.harvest` debug tools added** (MCP-surface only; `@farm/mcp`; surface now 85 tools / 22 resources / 10 prompts). Three new debug tools in the `crop.*` namespace wrapping the existing `PlantingSystem` mechanisms (till/sow/harvest), following the `crop.stage`/`crop.water` debug-tool pattern exactly — no engine-core changes, no schema/content changes, no new hooks/events. Exact shapes: `crop.till` (`{x?, y?, plotId?}`) → `{plotId, x, y, tilled}`; `crop.sow` (`{x?, y?, plotId?, seedItemId}`) → `{plotId, x, y, cropItemId, seedItemId}` (cropItemId = the crop **def** id resolved from the seed, per `PlantingSystem.sow`); `crop.harvest` (`{x?, y?, plotId?}`) → `{plotId, x, y, cropItemId, drops: [{itemId, qty}]}`. All three share `crop.stage`/`crop.water`'s coordinate/plotId validation (plotId → `plotIdFor(x,y)`; no coords/plotId → error result `crop: provide plotId or x/y coordinates`) and try/catch the system call into an error result. See `mcp.md` §crop. | `api.md`, `mcp.md` | | 2026-08-12 | **`apps/mcp-server` entry parameterized + `examples/sample-game/` added**. The stdio entry (`apps/mcp-server/src/index.ts`) no longer hardcodes the monorepo's `content/`/`mods/`: exported `DEFAULT_DIRS`, `resolveDirs({argv, env, cwd})`, and `main(dirs)`; roots resolve CLI (`--content-dir`/`--mods-dir`) > env (`FARM_CONTENT_DIR`/`FARM_MODS_DIR`) > default, relative CLI/env paths against `process.cwd()`; zero-arg behavior unchanged. `main` auto-runs on import only under direct execution. New `examples/sample-game/` is a complete playable game (content + one mod) exercising every engine capability through MCP; integration-tested from `apps/mcp-server/test/sample-game.test.ts`. | `api.md`, `mcp.md`, `architecture.md` | | 2026-08-12 | **Placement + NPC movement subsystems implemented** (Tier 2 items 4 & 5; `@farm/core` + `@farm/mcp`; MCP surface now 72 tools / 20 resources / 9 prompts). **Placement** (`PlacementSystem`, id `placement`): new content kind `building` (`content/buildings/.yaml`, `buildingSchema` in schema/building.ts) — `{ id, name?, footprint: {w, h}, buildable: "map"\|{tiles}\|"any", blocksMovement, description? }` (`footprint` positive ints; `{tiles}` non-empty; `"map"`/`"any"` collapse behaviorally in v1 — documented). `place(buildingId, x, y)` validates def-exists → map-bounds → per-cell buildability → no-overlap (in that order), then creates a world entity `{id: "building:::", type: "building", buildingId, x, y, w, h, blocksMovement}` and emits `placement-change` `{type: "place"|"remove", buildingId, x, y, w, h}` → new hook `onPlacementChange` (added to `HOOK_NAMES`, wired in `engine.wireEvents`). `remove(x, y)` / `list()` / `occupies(x, y)`. **Tile resolution**: a shared map-grid helper (`systems/map-grid.ts`) reads the map entity — terrain layer = the layer named `"ground"`, else the first layer; tile id = `terrainLayer.data[y][x]`; the same helper feeds MovementSystem. v1 occupancy: buildings block buildings; the player and non-building entities are transient. **Movement** (`MovementSystem`, id `movement`): no new schema — NPCs are world entities (`type: "npc"`). `moveTo(npcId, x, y)` validation chain: coords → npc-exists (`movement: unknown npc: `) → map bounds (`movement: target out of bounds: (x,y)`) → walkable tile (`movement: tile (x,y) is not walkable`) → not blocked by a `blocksMovement` building (`movement: blocked by building at (x,y)`); then sets `npc.x`/`npc.y` and emits `npc-move` `{npcId, from, to}`. `teleport(npcId, x, y)` skips bounds/walkability/blocking (still emits `npc-move`). **Walkability**: exported `UNWALKABLE_TILES = {"water"}` — the engine's v1 unwalkable convention (the map schema has no per-tile walkability flag; a future `walkable`/terrain-cost flag replaces it). Blocking resolved via `PlacementSystem.occupies` from the system host; other NPCs do NOT block in v1. **No `onNpcMove` hook** — the `npc-move` bus event is behavior-language-matchable (`on: "npc-move"`), so the tri-criterion's Mod-demand arm fails. Engine auto-boots both systems (`placement` before `movement`). New MCP namespaces `placement.*` (5 building lifecycle tools + 4 debug tools + 3 resources + 1 prompt) and `npc.*` (5 debug tools + 1 resource + no prompts). | `api.md`, `mcp.md`, `boundaries.md`, `decisions.md` (D22) | | 2026-08-12 | **Tick loop + animal age generalization implemented** (Tier 2 items 5 & 6 — final Tier 2 build; `@farm/core` + `@farm/mcp`; D23; MCP surface now 82 tools / 22 resources / 10 prompts). **Tick loop**: `engine.tick(dt)` (dt in ticks) is the authoritative simulation driver — advances `TimeSystem` (boundary events as before) then runs every system's `step(dt)` in registration order; `TimeSystem.step` = `advance(dt)` + emit `time-tick` `{day, season, tickInDay}`; optional auto-tick via `createEngine({ tickIntervalMs, tickDeltaTicks? })` (node:timers interval, default 1 tick per interval, unref'd, cleared on `stop()`; OFF by default). **Intra-day scheduling**: `onSchedule` `at` gains `{tickInDay, day?, season?}` matched on the `time-tick` event; existing `"day-start"`/`"season-change"`/`{day?, season?}` forms unchanged. **Animals**: new content kind `animal` (`animalSchema`, `content/animals/.yaml`) — `{ id, stages: [{name, days, feedRequired}], produces: [{itemId, qty, chance?}] }`; `GrowthSystem` generalized to advance `type:"animal"` entities (entity `{id: "animal::", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?}`) alongside `type:"plot"` in one `advanceDay` pass; `spawnAnimal`/`feed`/`forceAnimalStage` APIs; events `animal-grow`/`animal-produce`/`animal-fed`; new hooks `onAnimalGrow`/`onAnimalProduce` (added to `HOOK_NAMES`, wired in `engine.wireEvents`); `animal-fed` is bus-only (no hook, mirroring `npc-move`). See `mcp.md` §time/§animal and `decisions.md` D23. | `api.md`, `mcp.md`, `boundaries.md`, `decisions.md` | | 2026-08-12 | Initial draft (pre-implementation) | `api.md`, `mcp.md`, `architecture.md`, `decisions.md`, `AGENTS.md` | | 2026-08-12 | **Walking skeleton implemented** (`@farm/core` v0.1.0). Implemented deviations from draft: (1) kind→dir mapping via `contentDirNames` (`content/maps/`, single-file `content/time.yaml`); (2) `WorldEventBus.onAny` internal — engine forwards all bus events to systems' `on`; (3) `ContentRegistry.remove(kind, id)` internal — hot-reload registry swap; (4) `ContentLoader.watch(dir)` returns cleanup fn synchronously; (5) engine auto-boots a default `TimeSystem` (id `"time"`) alongside user systems; (6) `SystemsHost` is a concrete class. MCP gateway moved to `@farm/mcp` (see §6); core holds a `McpGatewayHandle` placeholder. | `api.md`, `mcp.md` | | 2026-08-12 | **Item + inventory subsystem implemented** (`@farm/core` + `@farm/mcp`). Implemented deviations from draft: (1) `ItemDef.kind` concretized to `tool\|seed\|material\|consumable\|decoration`; `use` is an optional string (descriptive content only — no usage system in this slice); (2) `price` is an optional non-negative int; (3) `Engine.contentDir` added to the facade — creation tools need the content root to write `content/items/.yaml`; (4) `InventorySystem.take` clamps to the available quantity and returns the actual taken count (0 when absent, no event emitted); `give`/`take` emit `item-acquire`/`item-use` bus events, mapped by `wireEvents` to `onItemAcquire`/`onItemUse` hooks (fresh `HookContext`); (5) inventory state lives on the player entity (`player.inventory: { [itemId]: qty }`), created in `init`; (6) engine auto-boots a default `InventorySystem` (id `"inventory"`). | `api.md`, `mcp.md` | | 2026-08-12 | **Crop + planting subsystem implemented** (`@farm/core` + `@farm/mcp`). Implemented deviations from draft: (1) `CropDef.waterRequired` concretized to a boolean (whether the plot must be watered during that stage) and `drops` to `[{itemId, qty, chance?}]` (`chance` optional 0..1, omitted = always) — v1 slice picks the simplest shapes; (2) plot entities are `{id: "plot::", type: "plot", x, y, tilled, watered, cropItemId?, stageIndex?, daysInStage?}`, created by `PlantingSystem.till`; `cropItemId` stores the **crop def id**, resolved from the seed via `crop.seedItem` at sow time; (3) `GrowthSystem.step(dt)` is a separate crop clock — it accumulates day-fraction ticks against the `time` def's `dayLengthTicks` (read from the registry in `init`) and advances plots on its own day boundaries (not wired to `TimeSystem` day-start in this slice; nothing drives `step(dt)` automatically yet); (4) `GrowthSystem.forceStage(plotId, stageIndex)` is the debug mechanism behind `crop.stage` — validates plot/crop/index and emits `crop-grow`; (5) `PlantingSystem.sow` consumes 1 seed from `player.inventory` when an inventory field exists (emitting `item-use`); `harvest` grants drops into `player.inventory` (emitting `item-acquire` per drop) and emits `crop-harvest`; both mapped by `wireEvents` to hooks `onCropGrow`/`onCropHarvest`; (6) invalid operations throw `Error` — the MCP layer converts to error results; (7) engine auto-boots default `GrowthSystem` (id `"growth"`) and `PlantingSystem` (id `"planting"`); (8) `PlantingSystem.water` sets `watered: true` (mechanism) while the debug tool `crop.water` *toggles* it (D3). | `api.md`, `mcp.md` | | 2026-08-12 | **Shop + economy subsystem implemented** (`@farm/core` + `@farm/mcp`). Implemented deviations from draft: (1) `EconomySystem` concretized to `buy`/`sell`/`price` with exact result shapes — `buy`/`sell` return `{shopId, itemId, qty, total, gold}`, `price` returns `{shopId, itemId, buy, sell, stock}` (`buy` = the price the shop pays the player from `buys`, `sell` = the price the shop charges the player from `sells`, `stock` = the optional sell-line cap, `null` when absent); (2) buy/sell convention fixed and documented: `sells` = shop sells to the player (player pays `sells[].price`), `buys` = shop buys from the player (player earns `buys[].price`); (3) `stock` is optional and **informational** in this slice — no depletion, no restock logic (declarative data only, consistent with D12 scope); (4) player money is a flat `player.gold` entity field initialized to `STARTING_GOLD = 0` in `init` when absent/invalid (a documented constant; starting gold could become content-driven later); (5) `buy` grants items into `player.inventory` (emitting `item-acquire`) and `sell` removes them (emitting `item-use`) via the same direct field-manipulation pattern as `PlantingSystem`; both emit a `transaction` bus event `{shopId, type: "buy"\|"sell", itemId, qty, total}` mapped by `wireEvents` to the existing `onTransaction` hook (already in `HOOK_NAMES`); (6) invalid operations throw `Error` (MCP converts to error results) — unknown shop, item not sold/bought, insufficient gold/items, non-positive qty; (7) engine auto-boots a default `EconomySystem` (id `"economy"`); (8) `ShopDef` arrays may be empty (a shop may only sell or only buy). No loans/debt, no price volatility, no multiple currencies — flat buy/sell at content-defined prices. | `api.md`, `mcp.md` | | 2026-08-12 | **Save + persistence subsystem implemented** (`@farm/core` + `@farm/mcp`, mechanism only — D14). Implemented deviations from draft: (1) `SaveSystem` concretized to `snapshot() → WorldSnapshot` / `restore(snapshot) → {restored: number}` / `migrate(snapshot, fromVersion?, toVersion?) → WorldSnapshot` / `validate(snapshot) → {ok, errors?}` / `registerMigration(fromVersion, fn)`; snapshot types live in `schema/save.ts` (`WorldSnapshot` interface + `worldSnapshotSchema` zod schema + `SAVE_FORMAT = "farm-save"` + `CURRENT_SAVE_VERSION = 1`), exported from the schema index but deliberately NOT a content kind (no `content/saves/` dir, not in `contentSchemas`); (2) the `WorldSnapshot` wraps `world.snapshot()` (the existing `Entity[]` snapshot) with `format`, `version`, and a `savedAt` ISO timestamp; entities are shallow copies so the live world is never aliased; the snapshot covers world entities only — `TimeSystem` state is not serialized in v1; (3) `restore` validates the snapshot shape (throws `Error` on malformed input), rejects future versions (`version > CURRENT_SAVE_VERSION`), migrates to the current version, then replaces ALL world entities (removes every current entity, re-creates the snapshot's entities in order as fresh copies); (4) `migrate` is a mechanism over an empty migration registry — migrations are keyed by source version N and must yield a version-(N+1) snapshot; no-op when `fromVersion === toVersion`, throws on backwards migration, versions without a registered migration are identity steps (so forward migration currently returns the snapshot unchanged after validation); `registerMigration` is the public authoring surface for future migrations; (5) `validate` is structural against the save format (format literal, positive-integer version, non-empty savedAt, world array of entities with non-empty id/type); (6) save has NO hook points (architecture.md Tier 1 lists `—` for Persistence) and emits no bus events; (7) engine auto-boots a default `SaveSystem` (id `"save"`); (8) no save *policy* — no slots, autosave, or file I/O (snapshots are in-memory objects passed through MCP; disk persistence is future work). | `api.md`, `mcp.md` | | 2026-08-12 | **Behavior language + trigger subsystem implemented** (`@farm/core` + `@farm/mcp`, v1 core: conditions + triggers, D15). Implemented deviations from draft: (1) `ConditionExpr` concretized as a recursive union — comparisons `{op: "eq"\|"gte"\|"lte", path, value}` (value REQUIRED at validation, any JSON value, enforced via `z.custom`) and `{op: "exists", path}`, composed via `{and: [...]}` / `{or: [...]}` / `{not: {...}}` (the draft's single-object `{op: "eq"\|"gte"\|"lte"\|"exists", path, value?}` form was split so `value` is strictly required for the value-comparison ops and forbidden-shape-free for `exists`); `conditionExprSchema` uses `z.lazy` recursion + `z.discriminatedUnion("op", ...)` for the comparison arm. (2) `TriggerSystem` (id `"trigger"`, auto-booted) exposes `evaluate(condition, ctx)` (pure), `fire(triggerId) → {triggerId, fired}` (throws `trigger: unknown trigger: `, emits `trigger-fire` `{triggerId}` only when the condition is true — mapped by `wireEvents` to hook `onTriggerFire`, already in `HOOK_NAMES`), `match(event, payload) → {fired}` (runs all triggers whose condition is true, executing actions and emitting `trigger-fire` per fired trigger), and `on(event, payload)` delegating to `match`. (3) **Path resolution convention** (documented in §4): `time.*` → `TimeSystem.get()`, `world..*` → the entity, `player.*` → `world.player.*`, `payload.*` → the event payload (only during match/on), otherwise `undefined`. To resolve `time.*`, `SystemInitCtx` gained an optional `systems?: SystemsHost` field (engine passes the host; existing init call sites unchanged). (4) **Loop guards (v1)**: `match` ignores the `trigger-fire` event itself, and matching is **not re-entrant** (an in-progress match/fire pass does not re-run matching on events its own actions emit) — this prevents infinite recursion from actions like `give-item` (which always emits `item-acquire`) under always-true conditions. (5) **Action execution**: `set-state` targets a world entity (`player.` or `world..`, missing entity throws), `emit-event` emits the named event with optional payload, `give-item` grants into `player.inventory` and emits `item-acquire` (direct field manipulation, same pattern as `EconomySystem`), `play-sound`/`dialogue` are accepted no-ops (headless v1 — no render/audio). (6) Schema registration: `contentSchemas.behavior` + `contentDirNames.behavior = "behaviors"` (content loads as `content/behaviors/.yaml`); example content `spring-gift` (day ≥ 5 → give parsnip-seed) and `season-welcome` (day 1 of summer/autumn before a flag → emit-event + set-state). (7) `TriggerAction` explicit type uses `value?: unknown` and `payload?: Record | undefined` to match zod's union inference under `exactOptionalPropertyTypes` — runtime validation still requires `value` and `path` for their ops. No state machines (v2, D15), no timers/schedules, no async or random conditions. | `api.md`, `mcp.md` | | 2026-08-12 | **`mod.*` introspection namespace implemented** (`@farm/core` + `@farm/mcp`, introspection only — D10; last Tier 1 subsystem). Engine facade (§1) gained a read-only mods surface, symmetric with `contentDir`: `engine.modsDir: string \| undefined`, `engine.mods: ModStatus[]` (a live getter over the closure-held loaded statuses, replaced on reload), and `engine.reloadMods(id?)` behind `mod.reload`. `ModLoader` (§5) gained `reload(modsDir, id?)` and now keeps a per-mod record `{ dir, unsubscribers }` — each `HookRegistry.on` returns an unsubscriber, and `reload` unbinds the previous functions for a Mod (or all, with an unbind-all + re-scan for the no-id case) before re-importing the entry with a cache-busting `?t=-` query so changed `.mjs` files are re-read; duplicate bindings are impossible because the old ones are removed first. A Mod that never loaded successfully in this process has no record and cannot be reloaded by id. D13 stands: in-process reload is the dev-loop convenience; the authoritative code-Mod path is still a headless-core restart (`apps/mcp-server`). No `contentOverrides` mechanism exists in the loader — Mods are code with an inline `{ id, version }` manifest — so nothing beyond `ModStatus` is surfaced (see §7 row fix). `mod.*` has no prompts. | `api.md`, `mcp.md` | | 2026-08-12 | **Behavior language v2 (state machines + `onSchedule`) implemented** (`@farm/core` + `@farm/mcp`, D15/D20; Tier 2 lead item). Implemented deviations from draft: (1) new content kind `statemachine` (`content/statemachines/.yaml`) with `stateMachineSchema` (schema/statemachine.ts) — `{ id, initial, states: [{id, onEnter?, onExit?}], transitions: [{from, to, when?, on?}], onSchedule?: [{at, to, when?}] }`; a `superRefine` enforces cross-field validity (initial/from/to reference declared states, unique state ids, at least one of `when`/`on` per transition, `at` object form needs at least one of day/season). (2) `TriggerSystem` (id stays `"trigger"`) extended into the v2 behavior engine rather than adding a sibling system — one `match` pass drives triggers first, then machines, under the **same** `matching` re-entrancy guard; `trigger-fire` still short-circuits the whole pass. (3) Machine state persists on a **world entity** `{id: "sm:", type: "statemachine", machineId, state}`, created lazily on first transition (never at boot) so `save.snapshot`/`save.restore` persist machines for free and existing snapshots/tests are unaffected; `machineStatus(machineId)` and `machineTransition(machineId, to)` are the debug surface. (4) Each transition runs the leaving state's `onExit`, sets the instance state, runs the entering state's `onEnter`, then emits `state-change` `{machineId, from, to}` → new hook `onStateChange` (added to `HOOK_NAMES`, wired in `engine.wireEvents`); transitions to the current state are no-ops. (5) **`onSchedule` is the v2 time primitive** (no tick loop — the engine has no auto-driven tick, so schedules hook the existing `day-start`/`season-change` bus events; intra-day timing awaits a tick loop): `at` is `"day-start"`/`"season-change"` or `{day?, season?}` matched against the payload of those events, entries apply from any state after `transitions`, and the first entry whose `at` AND optional `when` hold wins. (6) transition matching requires `transition.from` to equal the current state, `on` to equal the event, and `when` (a v1 `ConditionExpr`, so no new expression language) to hold; first match in declaration order wins, at most one transition per machine per event. (7) new schema exports `stateMachineSchema`/`scheduleEntrySchema`/`stateDefSchema`/`stateTransitionSchema`/`machineEntityId` and types `StateMachineDef`/`StateDef`/`StateTransition`/`ScheduleEntry`/`ScheduleAt`/`MachineEntity`/`MachineStatus`/`MachineTransitionResult`/`StateChangeEvent`. (8) example content `content/statemachines/daily-routine.yaml` (NPC-schedule placeholder with a `when`-gated market-day schedule entry) and `spring-quest.yaml` (inactive→active→complete quest with `give-item`/`set-state` onEnter, quest progress at `player.questProgress.`). v1 triggers unchanged — the `behavior` kind, `TriggerSystem` trigger APIs, and all v1 tests are untouched. | `api.md`, `mcp.md`, `boundaries.md`, `decisions.md` | | 2026-08-12 | **Dialogue + weather subsystems implemented** (Tier 2 items 2 & 3; `@farm/core` + `@farm/mcp`; MCP surface now 58 tools / 16 resources / 8 prompts). **Dialogue** (`DialogueSystem`, id `dialogue`): new content kind `dialogue` (`content/dialogues/.yaml`, `dialogueSchema` with `superRefine` cross-field checks — unique node ids, `startNode`/`to`/choice `to` reference declared nodes, `choices` and `to` mutually exclusive, non-empty choices). Active dialogue state lives on a lazily-created world entity `{id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?}` (snapshot-able; `start()` replaces any active dialogue without `dialogue-end` — hard debug reset). Node semantics: `choices` filtered by `when` (dialogue ctx: `time.*`/`player.*`/`world..*`/`payload.*` from the `start` ctx); `to`-only nodes present a synthesized `Continue` choice; terminal/all-gated nodes auto-end (text delivered, `dialogue-end` emitted, entity removed). Choice/node `actions` reuse the shared 5-kind action set via a new `behavior-runtime.ts` module (condition eval + path resolution + action execution extracted from `TriggerSystem` — one source of truth for the documented convention; `TriggerSystem` refactored to import it, zero behavioral change, 212 baseline tests still green). New hooks `onDialogueStart`/`onDialogueEnd` wired to `dialogue-start`/`dialogue-end`. **Weather** (`WeatherSystem`, id `weather`): new single-file content kind `weather` (`content/weather.yaml`, `weatherSchema` with `superRefine` — unique seasons, unique weather ids per season, `defaults` keys must be declared). State on a boot-created entity `{id: "weather", type: "weather", weatherId}` (initial = current season's first table entry, no boot RNG; entity only created when a def exists — games without weather.yaml boot cleanly). `roll()` on `day-start` via the System `on` interface; injectable RNG constructor arg (default `Math.random`) for deterministic tests; `weather-change` `{from, to}` emitted only on change → hook `onWeatherChange`. MCP namespaces `dialogue.*` (7 tools: lifecycle + debug `start`/`choose`; 2 resources `dialogue://catalog` + `dialogue://{id}`; 1 prompt `dialogue.create-prompt`) and `weather.*` (2 debug tools `get`/`set`; 1 resource `weather://state`; no prompts — single-file kind). Save tests updated for the new boot-created weather entity (`save.test.ts`, `save-namespace.test.ts`, `client-surface.test.ts` counts). Full decisions in `decisions.md` D21. | `api.md`, `mcp.md`, `boundaries.md`, `decisions.md` |