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