The interface contract. MCP-first (D-op1): every interface in this engine is designed on this surface first; the engine API (api.md) is derived from it. This file is the spec — implementations must match it.
item.*, crop.*, save.*). "Every feature has MCP" is enforced by the registration layer: a subsystem cannot boot without registering a namespace.content/*.yaml) are the primary surface. Runtime tools are for debugging only — they introspect state and trigger events, they never serve players.mod.* is introspection-only. MCP never invokes Mod-registered behavior; Mods never register MCP tools.Namespace registration contract — each subsystem implements:
interface McpNamespace {
name: string; // e.g. "item"
tools: McpTool[]; // { name, description, inputSchema, outputSchema?, handler }
resources?: McpResource[]; // { uri, description, read(params) }
prompts?: McpPrompt[]; // { name, description, arguments?, build() }
enabled: boolean; // per-subsystem switch (D9)
}
Implementation note (walking skeleton, 2026-08-12): resources use template URIs in the
urifield (map://{mapId}) resolved byread(params)— not theuriTemplatefield. Parameterized resources surface as templates inlistResourceswith their braces intact.
apps/mcp-server): agent connects, creates content.apps/mcp-server entry optionsThe stdio entry serves any content/mods pair (not just the monorepo's own). Roots resolve CLI > env > default:
| Source | Flag / var | Default |
|---|---|---|
| CLI | --content-dir <path> / --mods-dir <path> / --ws-port <port> |
— |
| Env | NENGINE_CONTENT_DIR / NENGINE_MODS_DIR / NENGINE_WS_PORT |
— |
| Default | — | content/, mods/ at the monorepo root; WS channel off |
Relative CLI/env paths resolve against process.cwd(). Zero-arg invocation is unchanged. Example:
node apps/mcp-server/dist/index.js \
--content-dir examples/sample-game/content \
--mods-dir examples/sample-game/mods
The wiring is apps/mcp-server/src/index.ts: exported resolveDirs(argv, env, cwd) → createEngine → createNengineMcpServer; main auto-runs on import only under direct execution (import.meta.url === process.argv[1]), so tests import it without booting stdio.
Not an MCP namespace — a separate broadcast channel for render viewports (D8/D24: headless core authoritative, browsers are WebSocket viewports). It adds no tools/resources/prompts to the MCP surface. Started in the same process as the stdio entry via --ws-port <port> (or the NENGINE_WS_PORT env var), resolved in the same CLI > env > default chain as content/mods roots (there is no default — absent means the channel is off, zero-flag behavior unchanged):
node apps/mcp-server/dist/index.js \
--content-dir examples/sample-game/content \
--mods-dir examples/sample-game/mods \
--ws-port 8787
Frames (JSON text, one per WebSocket message):
| Direction | Frame | Meaning |
|---|---|---|
| server → client | {"type":"snapshot","map":MapDef,"world":Entity[],"time":{day,season,tickInDay},"sprites":SpriteDef[],"sounds":SoundDef[],"shops":ShopDef[],"assetsIndex":Record<assetId,{kind,mime,size}>} |
Sent on connect and in reply to a sync request: the full current state — the map def (clients need it to build a viewport, since world entities alone lack it), every world entity, the current TimeSystem state (so viewport HUDs can show day/season without waiting for an event frame), the registered sprite/sound def catalogs (small bounded sets from registry.list at snapshot time — the render layer needs the draw/audio instructions to present the world; D26), the shop def catalog (D35 — the dev UI's shop panel reads buy/sell rows from it; additive), and the metadata-only assetsIndex (D37) — asset id → {kind, mime, size} (byte size). Bytes are NOT shipped per snapshot: clients request them on demand via assets-request (below) |
| server → client | {"type":"event","event":string,"payload":unknown} |
Broadcast to ALL connected clients for every WorldEventBus emission (day-start, time-tick, crop-grow, npc-move, player-intent, input, sound-play, ...) |
| server → client | {"type":"assets","assets":Record<assetId,AssetEntry{kind,mime,data(base64)}>} |
D37: the on-demand reply to an assets-request — the requested ids' full base64 entries (unknown ids ignored; the request is capped at 64 ids server-side, so larger requests are truncated and the remainder must be re-requested). Sent to the requesting client only (not broadcast) |
| client → server | {"type":"sync"} |
Optional: request a fresh snapshot (viewports re-sync on each event frame) |
| client → server | {"type":"assets-request","ids":string[]} |
D37: request the base64 bytes for the given asset ids (bounded — the coordinator batches at most 64 per request). The server replies with a {type:"assets"} frame; unknown ids are ignored gracefully. This replaced the D31 assets field on the snapshot — versioned protocol change (D37): old clients reading snapshot.assets break; the new client (connectWs) reads assetsIndex, requests what it needs, and feeds {type:"assets"} responses to the viewport/sound |
| client → server | {"type":"intent","action":"move"\|"interact"\|"buy"\|"sell"\|"talk"\|"choose"\|"select-seed","args":{...}} |
Player input: parse → IntentSystem.handle(action, args) → the resulting player-intent event is broadcast as an event frame (IntentSystem emits it on the bus). Invalid input never crashes the server — a synthesized player-intent error frame is broadcast instead |
| client → server | {"type":"input","input":string} |
Raw input string (D26): "key:ArrowUp" / "key:Space" / "pointer:click:<x>:<y>". The server emits an input bus event {input} broadcast as an event frame (behavior-language-matchable via on: "input"). Built-in mapping: key:ArrowUp/key:ArrowDown/key:ArrowLeft/key:ArrowRight additionally call IntentSystem.handle("move", {tile}) for the tile one step from the player's current position in that direction (player-centered). Unknown keys/prefixes are broadcast-only (no mapping). A non-string/empty input never crashes — a synthesized input error frame {input, ok:false, error:"input must be a non-empty string"} is broadcast instead |
Event payloads are JSON-serialized defensively: a non-serializable payload (circular refs, functions) is skipped with a log line — the server never crashes on a bad payload. No ping/pong heartbeat in this slice. Browser clients use the native WebSocket; apps/dev connects via connectWs (packages/render/src/sync/ws.ts, same handle shape as LocalSyncHandle).
server.*)| Kind | Name | Purpose |
|---|---|---|
| tool | server.info |
Engine version, subsystems and their enabled state, loaded content counts |
| tool | server.enable_subsystem / server.disable_subsystem |
Toggle a namespace at runtime (D9) — {name} → {name, enabled} or an error result |
| resource | server://status |
Same as server.info, as a resource |
| prompt | server.orient |
Onboard an agent: engine capabilities, content layout, authoring workflow |
Runtime semantics (D9, implemented): server.enable_subsystem {name} / server.disable_subsystem {name} flip a namespace's enabled switch on the gateway — the same per-gateway in-memory state server.info/server.status report (D9: subsystems can be enabled/disabled independently; state is not persisted and does not touch engine systems). A disabled namespace's tools/resources/prompts vanish from listTools/listResources/listPrompts and its tools reject calls with namespace "<name>" is disabled. Both tools return {name, enabled} (enabled = the new state). Errors: non-string/empty name → server.enable_subsystem: name must be a non-empty string / server.disable_subsystem: name must be a non-empty string; unknown namespace → server.enable_subsystem: unknown subsystem: <name> / server.disable_subsystem: unknown subsystem: <name> (the gateway's throw on a missing namespace is converted). Guard rail: disabling the server namespace itself is rejected (server.disable_subsystem: cannot disable the server subsystem) — that namespace hosts the re-enable tool.
item.* — Items & ToolsCreation tools (primary):
| Kind | Name | Description |
|---|---|---|
| tool | item.validate |
Validate an item definition (JSON) against the item schema; returns normalized JSON + errors |
| tool | item.create |
Create an item definition — writes content/items/<id>.yaml (via YAML serialization) and hot-reloads |
| tool | item.update |
Patch an existing item definition (merge + validate + write) |
| tool | item.list |
List registered item ids with one-line summaries |
| tool | item.get |
Fetch one item definition as normalized JSON |
Debug tools (runtime only):
| Kind | Name | Description |
|---|---|---|
| tool | item.query_inventory |
Read current inventory state (entity-level) |
| tool | item.give |
Give an item to the player inventory (debug) |
Resources:
| URI | Description |
|---|---|
item://catalog |
Full item catalog (normalized JSON) |
item://{id} |
Single item definition |
Prompts:
| Name | Purpose |
|---|---|
item.create-prompt |
Guided template: agent builds a well-formed item definition |
crop.* — Planting & Growth| Kind | Name | Description |
|---|---|---|
| tool | crop.validate / crop.create / crop.update / crop.list / crop.get |
Definition lifecycle, as item.* (content/crops/) |
| tool | crop.till |
Debug: till a plot ({x?, y?, plotId?} → {plotId, x, y, tilled}); idempotent — creates the plot entity if absent |
| tool | crop.sow |
Debug: sow a seed on a tilled plot ({x?, y?, plotId?, seedItemId} → {plotId, x, y, cropItemId, seedItemId}); consumes 1 seed from the player inventory (emits item-use) |
| tool | crop.harvest |
Debug: harvest a mature plot ({x?, y?, plotId?} → {plotId, x, y, cropItemId, drops: [{itemId, qty}]}); rolls drops into the player inventory (emits item-acquire per drop + crop-harvest), clears the crop |
| tool | crop.stage |
Debug: force an entity into a growth stage (fires growth hooks) |
| tool | crop.water |
Debug: water a tilled plot — sets watered: true ({x?, y?, plotId?} → {plotId, x, y, watered}); wraps PlantingSystem.water |
| resource | crop://catalog, crop://{id} |
Crop definitions |
| prompt | crop.create-prompt |
Guided crop creation (stages, water needs, seasons) |
The full farming loop (till → sow → water → harvest) is drivable through MCP via crop.till/crop.sow/crop.water/crop.harvest — each wraps the matching PlantingSystem mechanism (debug, D3). crop.water is a set (not a toggle): it calls PlantingSystem.water(x,y), so watered is always true after a successful call — watering an already-watered plot keeps it watered, and calling it repeatedly is safe (a waterRequired stage never flips back to unwatered mid-loop). Growth advance (stage progression over days) itself is simulation (TimeSystem + GrowthSystem), not an MCP tool — MCP only forces stages for debugging (D3). crop.sow resolves cropItemId from the seed exactly as PlantingSystem.sow does (the crop def whose seedItem matches, not the seed id itself); crop.harvest requires the final stage (planting.harvest semantics). All three accept {x, y} or {plotId} (identical coordinate/plotId validation to crop.stage/crop.water); crop.sow additionally requires a non-empty seedItemId.
map.* — Maps & Terrain| Kind | Name | Description |
|---|---|---|
| tool | map.validate / map.create / map.update / map.list / map.get |
Definition lifecycle (content/maps/) |
| tool | map.tile |
Debug: read one tile (terrain, contents, moisture) |
| tool | map.set_tile |
Debug: write one tile (fires terrain-change hooks) |
| resource | map://{mapId} |
Map definition (tile layers as normalized JSON) |
| prompt | map.create-prompt |
Guided map creation |
time.* — Day & Season| Kind | Name | Description |
|---|---|---|
| tool | time.get |
Current day, season, time-of-day tick |
| tool | time.advance |
Debug: advance the clock by ticks/days/seasons; fires onDayStart/onSeasonChange hooks as it crosses boundaries — clock movement only, no systems step, no time-tick |
| tool | time.tick |
Debug: run a full simulation step — advances the clock AND drives every system with a step (GrowthSystem grows crops/animals) and emits time-tick for intra-day scheduling (D23). {ticks?} defaults to 1 |
| tool | time.set |
Debug: jump to a specific day/season |
| resource | time://state |
Current time state as resource |
| prompt | time.configure-prompt |
Guide: authoring content/time.yaml (day length, season order) |
Time configuration (day length, seasons) is content data (content/time.yaml); MCP reads it via time://config if needed.
The tick loop (D23): the headless core has no render loop, so simulation is driven explicitly by engine.tick(dt) (dt in ticks; 1 tick = 1 engine time unit, dayLengthTicks from the time def). time.tick is the MCP surface for that driver. Each tick advances TimeSystem (firing day-start/season-change boundary events as it crosses them), then runs every system's step(dt) in registration order (TimeSystem first — it emits the time-tick bus event {day, season, tickInDay} after advancing — then GrowthSystem, which accumulates day-fractions against dayLengthTicks and advances crops/animals on day boundaries), then the loop ends. time.advance deliberately does NOT do the systems step or emit time-tick — it is the raw clock primitive the tests and boundary-event tooling use. engine.tick vs time.advance are therefore semantically different: the first runs simulation, the second only moves time. An optional auto-tick (createEngine({ tickIntervalMs, tickDeltaTicks? })) drives tick from a node:timers interval (default tickDeltaTicks = 1 tick per interval; unref'd so it never holds the process open, cleared on engine.stop()); it is OFF by default so tests and headless MCP usage stay deterministic.
shop.* — Economy| Kind | Name | Description |
|---|---|---|
| tool | shop.validate / shop.create / shop.update / shop.list / shop.get |
Shop definitions (content/shops/): stock, prices, buy/sell rules |
| tool | shop.buy |
Debug: execute a purchase (fires onTransaction) |
| tool | shop.sell |
Debug: execute a sale |
| resource | shop://{shopId} |
Shop definition |
| prompt | shop.create-prompt |
Guided shop creation |
save.* — Persistence (mechanism only, D14)| Kind | Name | Description |
|---|---|---|
| tool | save.snapshot |
Serialize the full world state to JSON (the engine's save format) |
| tool | save.restore |
Restore from a snapshot (validate, migrate via schema-version hooks, inject) |
| tool | save.migrate |
Dry-run / apply a schema-version migration to a snapshot |
| tool | save.validate |
Validate a snapshot against the current world schema |
| resource | save://state |
Latest snapshot as resource |
| prompt | save.authoring-prompt |
Explain save format, versioning, migration authoring |
Save policy — when to save, slots, autosave, UI — is creator content/Mods (D14), so it has no MCP tools.
behavior.* — Declarative Behavior Language (v1 core: conditions + triggers, D15)| Kind | Name | Description |
|---|---|---|
| tool | behavior.trigger.validate / behavior.trigger.create / behavior.trigger.update / behavior.trigger.list / behavior.trigger.get |
Trigger definitions: when <condition> → fire <actions> (content/behaviors/) |
| tool | behavior.fire |
Debug: manually fire a trigger by id |
| tool | behavior.condition.validate |
Validate a condition expression alone |
| resource | behavior://triggers |
All trigger definitions (normalized JSON) |
| prompt | behavior.trigger-prompt |
Guided trigger authoring (condition grammar, action set) |
Condition grammar (v1): comparisons (entity.state == "sprout", time.day >= 5) combined with and/or/not. Action set (v1): set-state, emit-event, give-item, play-sound, dialogue.
v2 = v1 triggers + state machine syntax + time-based triggering primitives (onSchedule). State machines are the mechanism underneath NPC schedules, quests, and events (boundaries.md "temporal state → behavior language v2"). They are a separate content kind statemachine (content/statemachines/<id>.yaml) so the existing behavior.trigger.* tools stay intact; their MCP tools live under the same behavior.* namespace.
| Kind | Name | Description |
|---|---|---|
| tool | behavior.statemachine.validate / behavior.statemachine.create / behavior.statemachine.update / behavior.statemachine.list / behavior.statemachine.get |
State machine definition lifecycle (content/statemachines/), mirroring behavior.trigger.* |
| tool | behavior.statemachine.status |
Debug: current state of a running machine instance (entity-backed; reports started: false before the machine has ever transitioned) |
| tool | behavior.statemachine.transition |
Debug: force a machine instance into a state (fires onEnter/onExit, emits state-change regardless of declared transitions) |
| resource | behavior://statemachines |
All state machine definitions (normalized JSON) |
| prompt | behavior.statemachine-prompt |
Guided state machine authoring (states, transitions, schedule entries) |
StateMachineDef shape (content kind statemachine):
{
"id": "quest-id",
"initial": "inactive",
"states": [
{ "id": "inactive" },
{ "id": "active", "onEnter": [{ "action": "give-item", "itemId": "parsnip-seed", "qty": 1 }] },
{ "id": "complete", "onExit": [{ "action": "emit-event", "event": "quest:done" }] }
],
"transitions": [
{ "from": "inactive", "to": "active", "on": "day-start", "when": { "op": "gte", "path": "time.day", "value": 3 } },
{ "from": "active", "to": "complete", "when": { "op": "gte", "path": "player.inventory.parsnip", "value": 5 } }
],
"onSchedule": [
{ "at": "day-start", "to": "working" },
{ "at": { "day": 1, "season": "summer" }, "to": "resting", "when": { "op": "eq", "path": "payload.season", "value": "summer" } }
]
}
states[] — at least one state; each state has an optional onEnter/onExit action list (same 5-kind action set as triggers). onEnter runs when entering the state; onExit when leaving. State ids are unique.transitions[] — from/to must reference declared states. A transition fires when on matches the current bus event name AND when (a v1 ConditionExpr over the trigger ctx) evaluates true; at least one of on/when is required. A transition with no on is a pure condition transition evaluated on every bus event (trigger-like). First matching transition in declaration order wins; at most one transition per machine per event.onSchedule[] — the time primitive (v1 of v2; no tick loop yet — documented limitation). Entries fire on TimeSystem boundary events: at: "day-start" / at: "season-change" match those events; at: { day?, season? } (at least one present) matches the day-start/season-change payload. to must reference a declared state; optional when is an extra condition evaluated against the trigger ctx (including payload.day/payload.season). Schedule entries apply from any state and are evaluated after transitions (so explicit event/condition transitions take priority). A transition to the machine's current state is a no-op (no onEnter/onExit, no state-change).Runtime semantics (v2): the behavior engine (TriggerSystem, id "trigger") drives both triggers and machines in the same match pass under the same re-entrancy guard — trigger actions and machine transitions run triggers-first, machines-second, and events emitted during the pass are never re-matched. Machine state persists on a world entity { id: "sm:<machineId>", type: "statemachine", machineId, state } — snapshot-able by save.*, inspectable by MCP. Instances are created lazily on first transition (never at boot, so existing worlds/snapshots are unaffected). Each machine transition emits a state-change bus event {machineId, from, to} mapped by the engine to the onStateChange hook (machinery for quest-tracking Mods; onTriggerFire stays for triggers). behavior.statemachine.status returns {machineId, state, started} where state is the declared initial and started: false before the instance exists. Quest progress convention (boundaries.md): player.questProgress.<questId> set via set-state actions.
Design decisions (v2, D20) — (1) separate content kind statemachine + behavior.statemachine.* tools rather than a behavior-kind discriminated union: the existing trigger tool names stay truthful, content dirs stay 1:1 per kind, and the behavior://triggers resource keeps a clean meaning; (2) onSchedule over a tick loop: the engine has no auto-driven tick (GrowthSystem.step has no driver) — schedules hook the existing day-start/season-change bus events, so no new game loop; fine-grained intra-day timing awaits a tick loop; (3) TriggerSystem extended (id kept "trigger") rather than a sibling system so triggers and machines share one match loop and one re-entrancy guard; (4) machine state on a world entity (not an in-memory map) so save.snapshot/save.restore persist it for free.
dialogue.* — Dialogue & Story (content-document system, D19)Dialogue is an engine system (boundaries.md item 2): authored content documents with nodes, choices, and conditions — a content document, NOT a state machine (D19). AI-authored dialogue is a core creation scenario (creation-first, D3).
| Kind | Name | Description |
|---|---|---|
| tool | dialogue.validate / dialogue.create / dialogue.update / dialogue.list / dialogue.get |
Dialogue definition lifecycle (content/dialogues/<id>.yaml), mirroring item.* exactly |
| tool | dialogue.start |
Debug: start a dialogue; returns the current node (text + available choices) |
| tool | dialogue.choose |
Debug: pick a choice by index (into the available choices); returns the next node |
| resource | dialogue://catalog |
All dialogue definitions (normalized JSON) |
| resource | dialogue://{id} |
Single dialogue definition |
| prompt | dialogue.create-prompt |
Guided dialogue authoring (nodes, choices, conditions, actions) |
DialogueDef shape (content kind dialogue):
{
"id": "welcome",
"startNode": "hello",
"nodes": [
{
"id": "hello",
"text": "Welcome to your farm!",
"choices": [
{
"text": "Thanks! What can I plant?",
"to": "seed-tip",
"when": { "op": "gte", "path": "time.day", "value": 1 },
"actions": [{ "action": "give-item", "itemId": "parsnip-seed", "qty": 1 }]
}
]
},
{ "id": "seed-tip", "text": "Parsnip seeds grow well in spring.", "to": "farewell" },
{ "id": "farewell", "text": "See you around." }
]
}
nodes[] — at least one node; each node has a unique id, a text string, and exactly one of choices or to (or neither — a terminal node). A choices node branches; a to node is linear; a terminal node ends the dialogue when entered.choices[] — each choice has text, to (a declared node), optional when (a v1 ConditionExpr evaluated against the dialogue ctx), and optional actions (the same 5-kind action set as triggers — set-state/emit-event/give-item/play-sound/dialogue; runs when the choice is picked, before moving to to).actions on a node run when the node is entered.startNode must be a declared node id.Runtime semantics: one active dialogue at a time; state persists on a world entity { id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload? } — snapshot-able by save.*. dialogue.start creates it (lazily) and returns the start node; dialogue.choose picks from the available choices (those whose when evaluates true against the dialogue ctx — time.*, player.*, world.<entityId>.*, payload.*, the same path-resolution convention as triggers), runs the picked choice's actions, moves to the target node, and runs that node's actions. Entering a node with no available outbound path (a terminal node, or a choices node whose conditions all fail) ends the dialogue: the node's text is still delivered in the result, dialogue-end is emitted, and the active entity is removed. dialogue-start {dialogueId, nodeId} and dialogue-end {dialogueId} bus events map to hooks onDialogueStart/onDialogueEnd. The dialogue action kind inside dialogue nodes is a reserved no-op in this slice (nested dialogue arrives later).
Design decisions (D21): (1) state on a world entity (not a player field / in-memory map) so save.snapshot/save.restore persist it for free and MCP can inspect it like any entity — mirrors the statemachine decision (D20.4); (2) linear to nodes surface a synthesized Continue choice so the headless driver has one uniform advance action (choose(index)) — a to node's text is still presented before advancing; (3) available choices are filtered by when at read time and choose indexes into that filtered list (a gated-out choice is hidden, not erroring); (4) terminal / all-gated nodes auto-end the dialogue — otherwise a headless dialogue with no outbound path would soft-lock (there is no dialogue.end tool); (5) dialogue.start/dialogue.choose are debug tools (D3) — dialogue is authored content; the runtime drive surface is for headless testing.
weather.* — Weather (lightweight probability-roll system, D19)Weather is a lightweight engine system (boundaries.md item 3): a probability table in content/weather.yaml (a single-file content kind, like time.yaml) keyed by season; the engine rolls a weighted pick on day-start. "Roll from a probability table" is a mechanism; the behavior language has no randomness (D19). No creation tools — the weather table is one file, so creation tools (which write per-id files) would be meaningless; the table is authored directly in YAML.
| Kind | Name | Description |
|---|---|---|
| tool | weather.get |
Debug: current weather (id, optional display name, current season) |
| tool | weather.set |
Debug: force a weather id (mechanism; emits weather-change on change) |
| resource | weather://state |
Current weather state |
WeatherDef shape (content kind weather):
{
"id": "default",
"defaults": { "sunny": { "name": "Sunny" }, "rain": { "name": "Rain" } },
"seasons": [
{ "season": "spring", "weathers": [{ "id": "sunny", "weight": 50 }, { "id": "rain", "weight": 30 }] },
{ "season": "summer", "weathers": [{ "id": "sunny", "weight": 70 }, { "id": "rain", "weight": 15 }] }
]
}
seasons[] — at least one season entry; each has a season name (matching time.yaml season names) and weathers[] (1+) of { id, weight } where weight is a positive integer (relative probability).defaults — optional per-weather display metadata ({ name }); keys must be declared weather ids (schema-enforced).Runtime semantics: weather state lives on a world entity { id: "weather", type: "weather", weatherId } created at boot with the current season's first table entry (deterministic — no RNG at boot). The first day-start rolls a weighted pick from the current season's table (the RNG is injectable for deterministic tests; default Math.random). When the current season has no table entry, the first season's table is used (fallback). weather-change {from, to} is emitted only when the weather actually changes, mapped to hook onWeatherChange. weather.get → {weatherId, name?, season}; weather.set → {weatherId, name?, season, from?} (from present when it changed). Unknown weather id → error result.
Design decisions (D21): (1) state on a world entity (snapshot-able, save.*-covered) created at boot so weather.get is always defined; (2) injectable RNG (constructor arg, default Math.random) for deterministic roll tests; (3) initial weather = first entry of the current season's table (no boot-time randomness); (4) no creation tools and no prompt — the single-file content kind makes per-id creation meaningless; (5) season is read from TimeSystem.get().season at roll time (the time def owns season names).
mod.* — Introspection Only (D10)| Kind | Name | Description |
|---|---|---|
| tool | mod.list |
Loaded Mods: id, manifest, status, registered hooks |
| tool | mod.status |
One Mod's detail: hook points bound, content overrides applied |
| tool | mod.reload |
Restart the headless core with re-loaded Mods (D13: code Mods restart, they don't hot-reload) |
| resource | mod://{id} |
Mod manifest + hook binding detail |
No tools exist to invoke Mod behavior through MCP.
Implemented (2026-08-12):
mod.*namespace is live (3 tools + 1 resource, no prompts). See the Change Log for exact result shapes and themod.reloadsemantics decision.
placement.* — Grid Placement (geometry/spatial primitive, D19/D22)Grid placement is an engine primitive (boundaries.md item 4): grid footprint + overlap validation + buildability-read-from-map-data is geometric mechanism the behavior language can't express ("geometry/spatial → engine primitive"). Building definitions — footprint, buildability rule, movement blocking — are data (content/buildings/<id>.yaml, content kind building). Buildings in the world are entities (type: "building"), so save.* snapshot/restore covers them for free.
| Kind | Name | Description |
|---|---|---|
| tool | placement.building.validate |
Validate a building definition (JSON) against the building schema; returns the normalized def |
| tool | placement.building.create |
Create a building definition: validates, writes content/buildings/<id>.yaml, registers live |
| tool | placement.building.update |
Patch an existing building definition (merge + validate + rewrite + re-register) |
| tool | placement.building.list |
List registered building ids with one-line summaries |
| tool | placement.building.get |
Fetch one building definition as normalized JSON |
| tool | placement.place |
Debug: place a building entity at a grid origin (validates def, map bounds, buildability, overlap) → placed entity or error result |
| tool | placement.remove |
Debug: remove the building occupying a grid cell → removed entity or error result |
| tool | placement.list |
Debug: list all placed building entities |
| tool | placement.occupies |
Debug: the building occupying a grid cell, or null |
| resource | placement://buildings |
Building definition catalog (normalized JSON) |
| resource | placement://{id} |
Single building definition (missing id → MCP error building not found: <id>) |
| resource | placement://placed |
Live placed building entities |
| prompt | placement.create-prompt |
Guided building definition authoring (footprint, buildable tiles, blocksMovement) |
BuildingDef shape (content kind building):
{
"id": "shed",
"name": "Shed",
"footprint": { "w": 2, "h": 2 },
"buildable": "map",
"blocksMovement": true,
"description": "Stores tools and harvests."
}
footprint — the building's grid size in cells; w/h are positive integers. Placement uses the footprint as an axis-aligned rectangle anchored at the placement origin (x, y).buildable — which tiles allow placement (tile ids come from the map's layers; the terrain layer is the map layer named "ground", else the first layer — see api.md §4):
"map" — any tile id on the map is buildable (no tile-type restriction).{ "tiles": ["grass", ...] } — every footprint cell must carry one of the listed tile ids (non-empty list)."any" — no tile-type restriction at all (the tile check is skipped entirely; the escape hatch for non-terrain/debug structures). "map" and "any" 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.blocksMovement — whether a placed instance blocks NPC grid movement (consumed by MovementSystem, Task B, via placement.occupies). A false building is walk-over-able by NPCs but still blocks placement overlap (buildings block buildings).name / description — optional display metadata.shop — optional string (D35): a registered shop def id the building is a front for. placement.place copies it onto the placed instance (building.shop), so UIs discover shops from world entities (the dev app's shop panel opens when a shop-carrying building is clicked).Runtime semantics: placement.place {buildingId, x, y} → the placed entity {id: "building:<buildingId>:<x>:<y>", type: "building", buildingId, x, y, w, h, blocksMovement} or an error result (unknown building; footprint out of map bounds; a footprint cell on a non-buildable tile; overlap with an existing building). placement.remove {x, y} → the removed entity or an error result (no building occupies the cell). placement.list → {buildings: [placed entities...]}. placement.occupies {x, y} → the building whose footprint contains the cell, or null. Every place/remove emits a placement-change bus event {type: "place"|"remove", buildingId, x, y, w, h} → hook onPlacementChange (Mods that track "what got built where" — build-quests, achievements — get their machinery). v1 occupancy rule: buildings block buildings; the player and non-building entities are transient and do not occupy cells in this slice (NPC blocking is MovementSystem's concern, Task B).
Design decisions (D22): (1) buildable as a three-form union ("map" / {tiles} / "any") over 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" collapse behaviorally in v1 (documented); (2) buildings as world entities (type: "building") so save.* persists them with zero system-side storage; (3) buildings-block-buildings only in v1 — the player is transient, NPC occupancy is MovementSystem's job (Task B); (4) placement-change event + onPlacementChange hook per the tri-criterion: placement outcomes are spatial (not behavior-language-expressible), build/removal Mods are plausible, and the debug tools already exist; (5) creation tools named placement.building.* — buildings are the subsystem's content kind, so lifecycle tools live under the namespace like item.*/crop.* do; (6) no placement content kind named building tool aliases — placement.building.list (defs) vs placement.list (placed instances) stay distinct.
npc.* — NPC Grid Movement (engine primitive, D19/D22)Grid movement + blocking is an engine primitive (boundaries.md item 5): moveTo is tile-to-tile with a simple blocking check — no pathfinding (A*/BFS) and no collision system (out of scope for the engine). NPC schedules are behavior language v2 content (state machines) — this system does NOT schedule anything. NPCs are world entities {id, type: "npc", x, y, ...}; there is no new schema — the movement API is MovementSystem (system id "movement", MCP namespace npc.*).
| Kind | Name | Description |
|---|---|---|
| tool | npc.list |
Debug: all npc entities ({npcs:[{id,x,y}]}) |
| tool | npc.get |
Debug: one npc entity (unknown → error result npc not found: <id>) |
| tool | npc.spawn |
Debug: create an npc entity at a grid cell (rejects blank id, bad coords, duplicate id) |
| tool | npc.move |
Debug: moveTo — validates npc, map bounds, walkable tile, blocking; emits npc-move |
| tool | npc.teleport |
Debug: no-check position set (emits npc-move; skips bounds/walkability/blocking) |
| tool | npc.path |
Debug (D33): compute an A* grid path for an npc (4-neighbor, Manhattan; blocks = blocksMovement buildings + other grid entities). Pure query — does not move the npc |
| resource | npc://{npcId} |
Live npc entity (missing id → MCP error npc not found: <id>) |
No prompt and no creation tools: NPCs are runtime entities, not authored content — there is no content/npcs/ kind and nothing to author (a creator gives an NPC its schedule via statemachine content). npc.* is debug-only (D3), so the subsystem checklist's lifecycle/prompt clauses do not apply (mirroring save.*, which is also a content-free mechanism).
Runtime semantics: npc.spawn {id, x, y} → the created entity {id, type: "npc", x, y}. npc.move {npcId, x, y} → {npcId, from: {x, y}, to: {x, y}} after moving, or an error result: movement: unknown npc: <id> / movement: target out of bounds: (x,y) / movement: tile (x,y) is not walkable / movement: blocked by building <id> at (x,y). npc.teleport {npcId, x, y} → the same {npcId, from, to} shape but skips bounds/walkability/blocking (still requires a valid npc and non-negative coords). Both emit the npc-move bus event {npcId, from: {x,y}, to: {x,y}}.
npc.path (D33): npc.path {npcId, to: {x, y}} → {npcId, from: {x, y}, to: {x, y}, path: [{x, y}, ...]} — the A* grid path from the npc's current cell to to including start and goal (4-neighbor, Manhattan heuristic, array-based open set — maps are ~16×16, so no binary heap), or {npcId, path: null, reason: "unreachable"|"out of bounds"|"blocked"}. Walkability = the D33 map-walkability resolution (isWalkableTile: map.walkable[tileId] when declared, else the UNWALKABLE_TILES fallback); blocked = a blocksMovement building footprint (via PlacementSystem.occupies) or another grid entity (npc/player) on the cell. It is a pure query — the npc is never moved (stepwise movement along a path is documented future work). Errors: npc.path: npcId must be a non-empty string / movement: unknown npc: <id> / npc.path: to must have integer x and y. Backs MovementSystem.findPath(npcId, to) → TilePoint[] | null (the engine API).
Validation chain (moveTo): (1) target coords are non-negative integers; (2) the npc entity exists and is type: "npc"; (3) the target is within map bounds (map entity width/height); (4) the target tile is walkable — the D33 walkability resolution via the shared isWalkableTile(map, x, y) helper (tile id read from the map entity's terrain layer — the layer named "ground", else the first layer; a tileId declared in map.walkable uses its boolean, an undeclared tileId falls back to the UNWALKABLE_TILES convention — default {"water"} — see api.md §4); (5) the target cell is not blocked — a building whose blocksMovement is true occupies the cell (via PlacementSystem.occupies, resolved from the system host; other NPCs do NOT block in v1). Then npc.x/npc.y are set and npc-move is emitted. moveTo semantics are byte-identical to pre-D33 for existing content (water still blocked; a map with no walkable declaration is unchanged).
Design decisions (D22): (1) MovementSystem is a system (id "movement") rather than a free module so it can resolve PlacementSystem from the system host (auto-booted before it) and keep one consistent blocking source; (2) walkability is a tile-id convention: the map schema has no per-tile walkability flag, so the engine treats the tile id "water" as unwalkable (the walking-skeleton map's water tiles are the demonstration) — superseded in D33: the map schema now carries an optional walkable?: Record<tileId, boolean> flag, and the single source of truth is isWalkableTile (declared value when present, else the UNWALKABLE_TILES constant); (3) buildings block, NPCs do not in v1 — building occupancy is stable geometry (perfect for a blocking check), NPC-vs-NPC overlap is a simulation concern that would need a resolution policy (who yields?) — deferred; npc.path (D33) treats other grid entities as blocked cells (a pure query — pathfinding sees other npcs/player as obstacles, unlike moveTo's single-tile check, which stays v1); (4) no onNpcMove hook — the npc-move bus event is already matchable by the behavior language (on: "npc-move" in a state machine transition), so the tri-criterion's "Mod hook demand" arm fails (Mods get the machinery via content, not a hook); the event's primary consumer is the render/sync layer; (5) npc.teleport exists because headless testing/QA often needs to reposition an NPC past blockers without writing a valid path — a no-check primitive; it still emits npc-move so render sync stays consistent. D33: npc.path is an additive debug query wrapping MovementSystem.findPath (A*, 4-neighbor, Manhattan, array-based open set) — moveTo/teleport semantics are untouched (stepwise NPC movement along a path is documented future work; pathfinding is a query mechanism today).
player.* — Player Intent (engine primitive, D25)Player input is an engine primitive (boundaries.md item 8): one IntentSystem (id "intent") turns player actions (move, interact) into world changes. Every input channel — the dev app's local sync, the WebSocket viewport channel ({"type":"intent"}), and this MCP debug tool — calls the SAME handle, so a player action behaves identically whether driven by a human clicking the canvas or an agent calling player.intent. Every handle call emits a player-intent bus event {action, tile, ok, error?, detail?} (successes AND failures — browser HUDs and WS viewports rely on it for feedback), which the behavior language can also match (on: "player-intent"). Since D29 the event ALSO maps to the onPlayerIntent hook (HookContext.payload = the intent result) — Mods are hooks-only (D6/D13), so the hook is the Mod-visible surface for the transient intent payload (see decisions.md D29.2). Hooks are not MCP surface; no tool/resource/prompt change.
| Kind | Name | Description |
|---|---|---|
| tool | player.intent |
Drive the player entity: {action: "move"\|"interact"\|"buy"\|"sell"\|"talk"\|"choose"\|"select-seed", ...args}. Success → the IntentResult; failure → error result player.intent: <issue> |
| resource | player://state |
The live player entity JSON (id, type, x, y, inventory, gold, flags, selectedSeed?, ...) |
Action semantics (IntentSystem.handle(action, args)):
move {tile:{x,y}} — validate the target (non-negative integers → within map bounds → walkable tile via the shared map-grid helper + UNWALKABLE_TILES = {"water"} → not blocked by a blocksMovement building via PlacementSystem.occupies), then set player.x/player.y. Errors: intent.move: target out of bounds: (x,y) / intent.move: tile (x,y) is not walkable / intent.move: blocked by building <id> at (x,y). Success detail: moved to (x,y). The player move reuses the shared tile/placement helpers directly — it does NOT go through MovementSystem.moveTo, so the npc-move behavior and its tests are untouched (D25).interact {tile:{x,y}} — a smart context action on a plot tile. The v1 priority order is content-ized in D33: the engine reads a single-file interact def (content/interact.yaml, kind interact; no authoring tools — weather/time precedent) at call time — registry.get("interact", "default") else the first def of the kind — and iterates its order array, executing the FIRST applicable action. Applicability conditions stay engine mechanisms; only the ORDER is content policy. Without content/interact.yaml the built-in default [harvest, water, sow, till] applies (zero behavior change for existing content):
harvested <cropId>, rolls the drop into inventory).watered plot).player.selectedSeed when that field names an owned seed-kind item (detail sowed <ItemDef.name>), else the FIRST seed-kind item in player.inventory (first key whose item def kind === "seed"; consumes 1). A successful sow keeps player.selectedSeed — clearing it is creator policy (the field is set via the select-seed action; an invalid/stale selection degrades to the first-seed fallback).tilled plot).
Non-applicable actions in the list are skipped; if none apply, the existing error falls through (crop <cropId> is not ready to harvest for a planted watered immature crop, else no seed in inventory).
Interact delegates to the existing PlantingSystem mechanisms (till/sow/water/harvest) — no new farming logic. Validation failures are ok:false IntentResults, never throws.
buy {shopId, itemId, qty?} (D35) — delegate to EconomySystem.buy (default qty 1). Success detail = the buy result {shopId, itemId, qty, total, gold}. Errors (all ok:false): player.intent: buy requires itemId (missing itemId), player.intent: buy requires shopId, player.intent: buy qty must be a positive integer, and the economy errors pass through unchanged (economy: unknown shop: <id>, economy.buy: <shop> does not sell <item>, economy.buy: insufficient gold (need N, have M)).sell {shopId, itemId, qty?} (D35) — delegate to EconomySystem.sell. Success detail = the sell result {shopId, itemId, qty, total, gold}. Errors mirror buy (player.intent: sell requires itemId / ... requires shopId / ... qty must be a positive integer; economy errors pass through: economy.sell: <shop> does not buy <item>, economy.sell: insufficient <item> in inventory (have N, need M)).talk {npcId?|tile?} (D35) — start an NPC's dialogue. Resolution: npcId (must be a type: "npc" entity) else tile (the npc entity ON that grid cell) else error player.intent: talk requires npcId or tile. The dialogue id comes from the NPC entity's dialogue?: string field (creator content/Mod spawns set it — the sample mayor carries dialogue: "mayor"); an NPC without the field errors player.intent: npc <id> has no dialogue. Errors: player.intent: unknown npc: <id> / player.intent: no npc at tile. Success calls DialogueSystem.start(dialogueId) and the detail = the resulting DialogueState {dialogueId, nodeId, text, choices}.choose {index} (D35) — advance an active dialogue: DialogueSystem.choose(index); detail = the new node state. Errors: player.intent: choose requires a non-negative integer index (non-integer/negative), player.intent: no active dialogue (nothing active), dialogue: choice index out of range: <index> (index into the available choices; a terminal/all-gated node auto-ends the dialogue on entry — D21).select-seed {seedItemId?} (D35) — set/clear the sow seed selection: a non-empty seedItemId that names a registered seed-kind item sets player.selectedSeed; absent/empty clears the field (delete). Success detail {selectedSeed: string|null}. Error: player.intent: <id> is not a seed item (unknown item or non-seed kind). The field is a plain player-entity field — snapshot-able, save.*-covered, Mod-readable.No prompts and no creation tools: the player is a runtime entity, not authored content. player.* is a debug/input surface (D3 precedent — runtime MCP is for debugging and now for driving the same intent mechanism a human drives from the browser).
Design decisions (D25): (1) one engine mechanism, three thin channels — MCP player.intent, the WS {"type":"intent"} message, and connectLocal.intent all call IntentSystem.handle; (2) the player-intent event fires for EVERY intent including failures so HUDs/viewports always get feedback; (3) the interact priority order was the v1 hardcoded engine default, explicitly documented as content-izable later — D33 content-izes it (a single-file interact content kind carries the order; applicability stays engine mechanism); (4) player move reuses the shared map-grid/placement helpers instead of MovementSystem.moveTo, keeping npc-move behavior and tests untouched.
animal.* — Animals (GrowthSystem reuse, D19/D23)Animals reuse GrowthSystem (boundaries.md item 6): an animal is a world entity {id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?} that advances through the same stage-progression mechanism as crops, with feedRequired replacing waterRequired on its stages. There is no new animal system — GrowthSystem step (driven by the tick loop, Task A) advances both plot and animal entities in one pass. The animal definition (growth cycle + produce table) is data (content/animals/<id>.yaml, content kind animal); feed/produce rules (what food, when, what the produce yields) are behavior-language content (D19) — the engine only emits the events the language hooks.
| Kind | Name | Description |
|---|---|---|
| tool | animal.validate / animal.create / animal.update / animal.list / animal.get |
Definition lifecycle, as item.*/crop.* (content/animals/<id>.yaml) |
| tool | animal.spawn |
Debug: create an animal entity at a grid cell ({animalId, x, y} → the entity) |
| tool | animal.feed |
Debug: mark an animal entity fed ({id} → {id, animalId, fed: true}) — sets the fed flag a feedRequired stage gates on |
| tool | animal.stage |
Debug: force an animal entity into a growth stage (fires animal-grow) |
| tool | animal.entities |
Debug: all placed animal entities ({animals:[...]}) |
| resource | animal://catalog |
All animal definitions (normalized JSON) |
| resource | animal://{id} |
Single animal definition (missing id → MCP error animal not found: <id>) |
| prompt | animal.create-prompt |
Guided animal authoring (stages, feed needs, produce table) |
AnimalDef shape (content kind animal):
{
"id": "chicken",
"stages": [
{ "name": "chick", "days": 2, "feedRequired": false },
{ "name": "grown", "days": 2, "feedRequired": true },
{ "name": "laying", "days": 1, "feedRequired": true }
],
"produces": [{ "itemId": "egg", "qty": 1, "chance": 1 }]
}
stages[] — at least one stage; each { name, days, feedRequired } mirrors the crop stage model with feedRequired in place of waterRequired. days positive int; feedRequired boolean.produces[] — at least one { itemId, qty, chance? } (same shape as crop drops). This is the produce table (data only) — the actual granting is behavior-language content (D19). chance optional 0..1 (omitted = always).Runtime semantics: animal.spawn {animalId, x, y} → {id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex: 0, daysInStage: 0, fed: false} (n = one past the highest existing suffix so multi-instances and post-restore spawns don't collide) or an error result (unknown animal def → growth: unknown animal def: <id>; bad coords → growth: x and y must be non-negative integers). animal.feed {id} → {id, animalId, fed: true} (unknown/non-animal entity → growth: unknown animal: <id>); feeding emits the animal-fed bus event {animalId, animalDefId} (behavior-language-matchable via on: "animal-fed"; no hook — see below). animal.stage {id, stageIndex} → {id, animalId, animalDefId, stageIndex, stage} (unknown entity / unknown def / out-of-range index → error result); delegates to GrowthSystem.forceAnimalStage, which also emits animal-grow. animal.entities → {animals:[placed entities...]}.
Growth cycle (mechanism): each in-game day (GrowthSystem's day boundary, driven by the tick loop) every animal is evaluated: if the current stage's feedRequired is true and the animal's fed is false, the day is skipped (no growth, no produce — exactly like an unwatered waterRequired crop stage). Otherwise a mature animal (final stage) emits animal-produce {animalId, animalDefId, stageIndex, stage} and its fed resets to false (feeding is consumed daily); a non-final animal increments daysInStage and on reaching stage.days advances stageIndex, resets daysInStage and fed, and emits animal-grow {animalId, animalDefId, stageIndex, stage} (the same shape as crop-grow, per stage advance including into the final stage). Both events map to hooks onAnimalGrow / onAnimalProduce. There is deliberately no separate animal-mature event and no onAnimalFed hook: animal-grow already covers reaching maturity, animal-produce covers ongoing production, and the animal-fed bus event is already behavior-language-matchable — the tri-criterion's "Mod hook demand" arm fails for feed (D22.4's reasoning applies identically).
Design decisions (D23): (1) animals are an entity kind + GrowthSystem generalization, not a new system — animals are isomorphic to crops (stages, days-per-stage, a per-day gate), so the only new mechanism is the fed flag mirroring watered (D19 verdict); (2) the animal schema holds the growth cycle + produce table ONLY — feed rules and produce/harvest rules are behavior language (D9/decision #9): the engine emits animal-grow/animal-produce/animal-fed bus events and grants nothing; (3) animal.spawn/feed/stage/entities are debug tools (D3) — animal instances are runtime entities, but the definition lifecycle is creation-first (mirroring crop.*); (4) animal.list lists definitions (consistent with item.list/crop.list); placed instances are a separate debug tool animal.entities (mirroring placement.list vs placement.building.list).
sprite.* — Procedural Sprites (presentation data, D26)Sprites are the presentation half of the rendering presentation pipeline (D26/D31): a sprite content kind (content/sprites/<id>.yaml) carrying programmatic draw instructions — an ordered list of primitive shape layers per animation frame, plus the image layer (D31: {shape:"image", src: <assetId>, x?, y?, w?, h?} referencing a binary file in content/assets/). They are data (authored in YAML, validated by zod) and MCP-addressable like every other content kind. The image layer references assets by id — assets themselves are NOT a content kind and have NO MCP namespace (binary files are filesystem content; MCP-crafted YAML references them by id; see D31). A def with spriteRef missing on a crop stage/building/animal stage simply means the renderer falls back to the existing shape drawing — spriteRef is always optional.
| Kind | Name | Description |
|---|---|---|
| tool | sprite.validate |
Validate a sprite definition (JSON) against the sprite schema; returns the normalized def |
| tool | sprite.create |
Create a sprite definition: validates, writes content/sprites/<id>.yaml, registers live |
| tool | sprite.update |
Patch an existing sprite definition (merge + validate + rewrite + re-register) |
| tool | sprite.list |
List registered sprite ids with one-line summaries |
| tool | sprite.get |
Fetch one sprite definition as normalized JSON |
| resource | sprite://catalog |
Full sprite catalog (normalized JSON) |
| resource | sprite://{id} |
Single sprite definition (missing id → MCP error sprite not found: <id>) |
| prompt | sprite.create-prompt |
Guided template for authoring a procedural sprite definition |
SpriteDef shape (content kind sprite):
{
"id": "parsnip-mature",
"frames": [
{
"name": "idle",
"durationTicks": 1,
"layers": [
{ "shape": "rect", "x": 8, "y": 8, "w": 16, "h": 16, "fill": "#2e7d32" },
{ "shape": "circle", "x": 16, "y": 8, "r": 6, "fill": "#66bb6a" }
]
}
]
}
id — unique kebab-case string.frames[] — at least one frame; each frame is a time slice of the sprite. name (optional display label) and durationTicks (optional non-negative int, how many engine ticks the frame plays before the next; a single-frame sprite is static) describe the frame; layers is an ordered paint list (1+, drawn back-to-front).layers[] — each layer is one of { shape: "rect"|"circle", x, y, ... } (per-shape sizing + required fill matching "#rrggbb") or { shape: "image", src, x?, y?, w?, h? } (D31; zod strict, shape is the union discriminator — unknown keys are rejected). rect uses w/h (positive ints); circle uses r (positive int). The image layer's src is a required non-empty asset id (a file in content/assets/), x/y position the image's top-left in tile space (default 0,0), and w/h size it in tile-space units (the crop never exceeds the source image; absent = natural pixel size). Coordinates are relative to the sprite's tile-space anchor (top-left origin; the render layer maps them to tile pixels).Runtime semantics: sprite defs are inert data consumed by the render layer (round 2). Entities carrying a spriteRef field (player, plots, buildings, animals — set by the systems in api.md §4) reference a def by id; the renderer draws the def's current frame when the ref resolves, falling back to the existing shape drawing when it does not. There are no new systems and no hooks — sprites are a content kind + appearance field, and the snapshot frame carries the full sprites catalog so viewports never need a second request (D26).
Design decisions (D26/D31): (1) programmatic shapes over atlas assets — primitive draw instructions keep content data-driven, testable, and dependency-free; the image layer (D31) adds per-asset image references (the engine never loads an atlas format — single-tile assets by id; a sprite-sheet atlas is a documented future path); (2) spriteRef on crop stages / animal stages / building defs (and the player entity seed) is optional — a missing ref degrades to shape drawing, so existing content and tests are untouched; (3) the snapshot carries the sprite catalog so render viewports get draw instructions in-band with the world state they present.
sound.* — Synthesized Sounds (presentation data, D26)The audio half of the presentation pipeline (D26/D31): a sound content kind (content/sounds/<id>.yaml) carrying synthesizer instructions (synth) and/or an audio asset reference (src — a file in content/assets/, D31). play-sound actions (behavior language v1, §behavior) reference sounds by id and emit a sound-play bus event {soundId} — behavior-language-matchable and broadcast as an event frame so the render layer can play the sound. Assets themselves are NOT a content kind and have NO MCP namespace — src references an asset by id; binary files are filesystem content (D31).
| Kind | Name | Description |
|---|---|---|
| tool | sound.validate |
Validate a sound definition (JSON) against the sound schema; returns the normalized def |
| tool | sound.create |
Create a sound definition: validates, writes content/sounds/<id>.yaml, registers live |
| tool | sound.update |
Patch an existing sound definition (merge + validate + rewrite + re-register) |
| tool | sound.list |
List registered sound ids with one-line summaries |
| tool | sound.get |
Fetch one sound definition as normalized JSON |
| resource | sound://catalog |
Full sound catalog (normalized JSON) |
| resource | sound://{id} |
Single sound definition (missing id → MCP error sound not found: <id>) |
| prompt | sound.create-prompt |
Guided template for authoring a synthesized sound definition |
SoundDef shape (content kind sound):
{
"id": "coin",
"synth": { "wave": "sine", "freq": 880, "durationMs": 120, "gain": 0.5, "slideTo": 1320 }
}
id — unique kebab-case string.synth — the v1 synthesis spec (zod strict): wave is one of sine|square|triangle|sawtooth; freq is a positive number (Hz); durationMs a positive number; gain optional 0..1 (output level); slideTo optional positive number (end frequency — a sweep).src — optional audio asset id (D31): a file in content/assets/ the render player decodes and plays (base64 → decodeAudioData); src takes precedence over synth at play time, and a src whose asset is missing or undecodable is silently skipped.synth/src is required (schema .refine).Runtime semantics: sound defs are inert data consumed by the render layer (round 2). The behavior-language play-sound {sound} action emits the sound-play bus event {soundId} (the sound id is emitted regardless of whether the def is registered — the engine's mechanism is the event, matching the audio row of boundaries.md: "engine emits events, render layer plays"). There are no new systems and no hooks; the snapshot frame carries the full sounds catalog so viewports can synthesize on demand.
Design decisions (D26/D31): (1) synth + asset-source sounds over raw audio files — a small declarative synth spec keeps audio data-driven and dependency-free; src audio assets (D31) add file playback through the same asset pipeline (a .ogg in content/assets/ referenced by id); (2) play-sound upgrades from a headless no-op to the documented audio event emitter (boundaries.md "Audio" row) — the event is the mechanism, playback is render-layer freedom.
rng.* — Deterministic Seeded RNG (engine mechanism, D28)The engine's single randomness mechanism (D28): a deterministic seeded RNG mirroring LÖVE's love.math.RandomGenerator. createEngine({ seed?, ... }) seeds the engine's one RandomSource (engine.rng, exposed on the facade); every randomness consumer draws from it via SystemInitCtx.rng (WeatherSystem day-start rolls and PlantingSystem harvest drop chances — previously bare Math.random). Same seed → identical world randomness (replay/tests/Mods); save.* snapshots carry rngState so restore re-anchors the sequence. MCP is a debug surface (D3) for this mechanism — it reads/writes engine.rng directly, no system behind it.
| Kind | Name | Description |
|---|---|---|
| tool | rng.get |
Debug: current RNG state {seed, calls} — the seed the world's determinism is anchored to and how many draws have been made since that seed |
| tool | rng.set |
Debug: reset the engine RNG to a seed (finite number required) → {seed, calls: 0}; subsequent world randomness becomes deterministic from that seed |
| resource | rng://state |
Current RNG state (same shape as rng.get) |
Runtime semantics: rng.get → {seed, calls} where seed is the anchor (the value most recently passed to setSeed/the engine seed) and calls counts draws since that seed. rng.set {seed} validates the seed is a finite number (non-number/NaN/Infinity → error result rng.set: seed must be a finite number), resets the sequence and the draw counter to 0, and returns the new {seed, calls: 0}. Determinism contract: every world-random draw (weather rolls on day-start, crop/animal drop chances on harvest) comes from this one source, so two engines with the same seed produce identical simulations; rng.set mid-run makes all subsequent randomness deterministic from the new seed.
Design decisions (D28): (1) one engine-owned RNG injected via SystemInitCtx rather than per-system instances — draw order is globally orderable and replayable, and rng.* has exactly one surface to report; (2) rng.get/rng.set + rng://state only, no prompts — the RNG is not authored content and offers no authoring guidance (D3's debug-surface precedent, mirroring weather.*/time.*); (3) save.* carries rngState {seed, calls} (optional, migration-free) and restore re-anchors to the stored seed with calls reset to 0 (draw replay is documented as not built — D28).
particle.* — Particle Definitions (presentation data, D34)A particle def is a pure-visual emission profile (content kind particle, content/particles/<id>.yaml, zod strict): { id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?: ["#rrggbb",...], fade? } — all fields 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). Particles are inert presentation data — no engine system consumes them; content triggers a burst via the behavior-language emit-event action ({action:"emit-event", event:"particle", payload:{particleId, x?, y?, entityId?}}, payload x/y are tile coords, absent → the named entity or the player), the render layer listens for the particle bus event and spawns/animates the burst (D34).
| Kind | Name | Description |
|---|---|---|
| tool | particle.validate |
Validate a particle definition (JSON) against the particle schema → normalized def or error result |
| tool | particle.create |
Validate + write content/particles/<id>.yaml + register live (overwrites on duplicate id) |
| tool | particle.update |
Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error particle.update: particle not found: <id>) |
| tool | particle.list |
→ {particles: [{id, emitRate}]} summaries (registration order) |
| tool | particle.get |
→ one ParticleDef JSON or error result (particle.get: particle not found: <id>; blank id → particle.get: particleId must be a non-empty string) |
| resource | particle://catalog |
→ {particles: [defs...]} |
| resource | particle://{id} |
→ one def (missing id → MCP error particle not found: <id>) |
| prompt | particle.create-prompt |
Guided particle-def authoring (field defaults, emit-event trigger example) |
shader.* — Built-In Filter Definitions (presentation data, D34)A shader def selects one of the engine's BUILT-IN parameterized filters (content kind shader, content/shaders/<id>.yaml, zod strict): { id, filter: "blur"\|"tint"\|"grayscale"\|"sepia"\|"hue"\|"brightness", params?: {[k]: number\|string} } — no GLSL/shader code in content (the implementations ship with the engine; content only parameterizes them). Parameter semantics: blur {strength} (default 4), tint {color:"#rrggbb"} or {r,g,b} (0–255, default white), grayscale {factor} (0–1 desaturation, default 1), sepia {}, hue {degrees} (default 0), brightness {factor} (default 1). Shader ids are referenced from SpriteDef.filters (per-sprite) and from the weather visual mapping (whole-map, D34). Unknown shader id at use time → silently skipped by the render layer.
| Kind | Name | Description |
|---|---|---|
| tool | shader.validate |
Validate a shader definition (JSON) against the shader schema → normalized def or error result |
| tool | shader.create |
Validate + write content/shaders/<id>.yaml + register live (overwrites on duplicate id) |
| tool | shader.update |
Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error shader.update: shader not found: <id>) |
| tool | shader.list |
→ {shaders: [{id, filter}]} summaries (registration order) |
| tool | shader.get |
→ one ShaderDef JSON or error result (shader.get: shader not found: <id>; blank id → shader.get: shaderId must be a non-empty string) |
| resource | shader://catalog |
→ {shaders: [defs...]} |
| resource | shader://{id} |
→ one def (missing id → MCP error shader not found: <id>) |
| prompt | shader.create-prompt |
Guided shader-def authoring (built-in filter library, params per filter) |
animation.* — Animation State Machines (presentation data, D38)An animation def is an animation state machine (content kind animation, content/animations/<id>.yaml, zod strict): { id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}] }. Each state maps a named state (e.g. idle, walk, dance) to a sprite def id — that sprite's internal frames give the state's animation at the state's fps (default 10, the DEFAULT_ANIMATION_FPS); loop: false plays the state once then holds its last frame. transitions is an optional authoring aid (an allowed-transition graph) — the runtime does NOT enforce hard transitions (content can jump between states freely; enforcement would break content freedom, D38). The state machine is driven by an entity's anim?: string field (a plain entity field the behavior language already sets/clears via set-state player.anim "dance" / set-state player.anim null — null is a supported clear): the render layer resolves the state as (1) the explicit anim field when it names a declared state, else (2) motion inference — "walk" while the entity is tweening between tiles / "idle" when still, else (3) initial. An entity whose spriteRef matches an animation def id uses that def; no match → today's rendering unchanged (additive, no regression).
| Kind | Name | Description |
|---|---|---|
| tool | animation.validate |
Validate an animation definition (JSON) against the animation schema → normalized def or error result (bad initial / duplicate state ids / transition to an undeclared state / strict-key violations) |
| tool | animation.create |
Validate + write content/animations/<id>.yaml + register live (overwrites on duplicate id) |
| tool | animation.update |
Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error animation.update: animation not found: <id>) |
| tool | animation.list |
→ {animations: [{id, stateCount}]} summaries (registration order) |
| tool | animation.get |
→ one AnimationDef JSON or error result (animation.get: animation not found: <id>; blank id → animation.get: animationId must be a non-empty string) |
| resource | animation://catalog |
→ {animations: [defs...]} |
| resource | animation://{id} |
→ one def (missing id → MCP error animation not found: <id>) |
| prompt | animation.create-prompt |
Guided animation-def authoring (states, spriteRef, fps/loop, state resolution, set-state null clear) |
When adding a subsystem, complete ALL of:
validate / create / update / list / get (writing content/<kind>/<id>.yaml, hot-reloading)docs/mcp.md section AND docs/api.md mapping updated in the same change (D-op2)| Date | Change | Files |
|---|---|---|
| 2026-08-13 | Engine rename to nEngine (D39) — NO MCP surface change (116 tools / 34 resources / 15 prompts / 21 namespaces held). Cosmetic identity/branding rename only: packages @farm/* → @nengine/*, MCP stdio server identity farm-mcp-server → nengine-mcp-server, server.info/server://status engine id "farm-engine" → "nengine" (shape unchanged), save/bundle format literals farm-save/farm-bundle → nengine-save/nengine-bundle (v1-era breaking change — old files not accepted; CURRENT_SAVE_VERSION/BUNDLE_VERSION stay 1), packaging CLI farm-pack → nengine-pack, Vite defines __FARM_BUNDLE_DEFAULT__/__FARM_ASSETS_BASE__ → __NENGINE_*, env vars FARM_CONTENT_DIR/FARM_MODS_DIR/FARM_WS_PORT → NENGINE_* (CLI flags unchanged), split-assets dir farm-assets → nengine-assets, appId prefix com.farm. → com.nengine.. server.orient + the creation-namespace prompts address "the nEngine". No tool/resource/prompt signatures change, no protocol changes, no system behavior changes; creator content is untouched (map id "farm", "farm-tiles", game name "Pinefield Valley"). Historical changelog entries keep their original text (append-only). See decisions.md D39. |
mcp.md, api.md, architecture.md, boundaries.md, decisions.md (D39), AGENTS.md, docs/engine-guide-zh.md, examples/sample-game/README.md, README.md, package.json, all workspace package.json files, apps/mcp-server (src + tests), apps/pack (src + tests), apps/dev (src + vite config), packages/core (schema/save.ts, content/bundle.ts, content/loader.ts + tests), packages/mcp (namespaces + tests), packages/render (src + tests) |
| 2026-08-13 | Animation state machines + smooth movement (D38) — MCP surface now 116 tools / 34 resources / 15 prompts / 21 namespaces. One additive creation-first namespace mirroring sprite.*/sound.*/particle.*/shader.* (D26/D34): animation.* (content kind animation, content/animations/<id>.yaml — {id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}]} zod strict; a state machine over sprite defs — each state references a sprite def whose frames animate at the state's fps (default 10), loop: false plays once then holds; transitions is an optional authoring aid the runtime does NOT enforce). The state machine is driven by an entity's anim?: string field (behavior-language set-state player.anim "dance" / set-state player.anim null — null is a supported clear, no language change needed) with render-layer motion inference fallback ("walk" while moving, "idle" when still, else initial); an entity whose spriteRef matches an animation def id uses it, otherwise rendering is unchanged. Namespace: validate/create/update/list/get + animation://catalog/animation://{id} resources + animation.create-prompt, exact shapes/errors in the section above. The WS snapshot frame gains an additive animations: AnimationDef[] catalog (same pattern as particles/shaders/shops). server.orient lists the new tools. Smooth movement is render-layer-only (engine stays authoritative instant grid-jumps — moveTo/IntentSystem.move untouched): the render viewport tweens consecutive known tile coords over MOVE_DURATION_MS (250 ms, easeOutQuad) with walk/idle motion inference driving the animation state; no protocol changes. See decisions.md D38. |
mcp.md, api.md, decisions.md (D38), AGENTS.md, packages/core (schema/animation.ts, schema/index.ts, index.ts), packages/mcp (namespaces/animation.ts, index, server), packages/render (tween.ts, viewport, sprite-draw, sync), apps/mcp-server (ws-broadcast, tests), examples/sample-game (content/animations/player.yaml, content/sprites/player-walk.yaml) |
| 2026-08-13 | WS snapshot assets on-demand (D37) — NO MCP surface change (20 namespaces / 111 tools / 32 resources / 14 prompts held). Protocol evolution in the WebSocket world-sync channel only (a render-viewport transport, NOT an MCP namespace): the snapshot frame's assets field (D31 — full base64 table in EVERY snapshot) is replaced by a metadata-only assetsIndex (Record<assetId, {kind, mime, size}> — no bytes); a new client→server {"type":"assets-request","ids":string[]} message (bounded — the client coordinator batches ≤ 64 ids, the server truncates over the cap) is answered per-client with a new {"type":"assets","assets":Record<assetId, AssetEntry{kind,mime,data}>} frame (requested ids only, unknown ids ignored, empty request → empty response). Versioned protocol change: old clients reading snapshot.assets break — connectWs now reads assetsIndex, derives the wanted ids from the snapshot's presentation (map.tiles + sprite image layers + sound.src — pure snapshotAssetRefs), dedupes in-flight/loaded ids (AssetCoordinator, pure node-testable), and feeds {type:"assets"} responses into the viewport/sound via the existing setAssets/async-load paths (late-arriving assets re-render through the viewport's existing redraw-on-ready). New ws-broadcast tests: snapshot carries assetsIndex without base64, request→response roundtrip, unknown ids ignored, empty request → empty assets:{}, 64-id truncation. See decisions.md D37. |
mcp.md, api.md, decisions.md (D37), AGENTS.md, packages/render (sync/ws, sync/asset-coordinator, sync/asset-refs), apps/mcp-server (ws-broadcast + tests) |
| 2026-08-13 | Mod packaging + browser Mod execution + installer generation (D36) — NO MCP surface change (111 tools / 32 resources / 14 prompts / 20 namespaces held). Mods are code, not data (D6), so Mod packaging touches no MCP surface: no new namespace (pack.* still deliberately does not exist — packaging stays an offline build-time tool), no tools, no resources, no prompts, no content-kind registration, no protocol changes. What changed is the engine's Mod mechanism: ContentBundle gains an additive mods?: Record<modId, {manifest: {id, version}, code}> (zod-validated; BUNDLE_VERSION stays 1 — old bundles load unchanged), ModLoader gains loadFromSource(source, id, version?) + reloadFromSources(sources) (ESM source executed via a base64 data:text/javascript dynamic import — the SAME mechanism in Node ≥ 20 and browsers, zero fs access, validation/binding identical to file mods), and createEngine({bundle}) boots packaged Mods from bundle.mods when no modsDir is configured (modsDir wins when both are given). The mod.* namespace surface is unchanged — mod.list/mod.status/mod.reload already read engine.mods, which now reflects bundled Mods in bundle mode (all-at-once reload). farm-pack gains --mods <dir> (packages Mods into bundle.mods) and --installer (implies --desktop; writes an electron-builder.yml + dist/dist:win scripts so the desktop shell can build installers). See decisions.md D36. |
mcp.md, api.md, decisions.md (D36), AGENTS.md, packages/core (content/bundle.ts, mods/loader.ts, engine.ts, index.ts), apps/pack (src/index.ts, src/desktop.ts, test/pack.test.ts), package.json, examples/sample-game/README.md, docs/engine-guide-zh.md |
| 2026-08-13 | In-game UI + player-intent action extension (D35) — MCP surface held at 111 tools / 32 resources / 14 prompts / 20 namespaces. player.intent's inputSchema action enum extends from move/interact to move/interact/buy/sell/talk/choose/select-seed (the tool count stays 1 — no new tools, no new resources/prompts). New args on the schema: itemId/qty/shopId (buy/sell, defaults qty 1), npcId/tile (talk), index (choose), seedItemId (select-seed). Exact action semantics and error strings are documented in the player.* section above (D35) — buy/sell delegate to EconomySystem with the transaction as result detail, talk starts an NPC's dialogue (from the NPC entity's new dialogue field — sample mayor carries dialogue: "mayor"), choose advances the active dialogue, select-seed sets/clears player.selectedSeed. The WS snapshot frame gains an additive shops: ShopDef[] catalog (D35 — the dev UI shop panel reads buy/sell rows from it in ws mode; local mode reads the registry) and the server-side intent gate accepts all seven actions (message shapes unchanged). No other tool/resource/prompt signatures change. See decisions.md D35. |
mcp.md, api.md, decisions.md (D35), AGENTS.md, packages/core (systems/intent.ts, schema/building.ts, systems/placement.ts), packages/mcp (namespaces/player.ts), packages/render (viewport, sync/local, sync/ws), apps/mcp-server (ws-broadcast, tests), apps/dev (index.html, src/main.ts, src/ui.ts + ui.test.ts), examples/sample-game (content/buildings/shop.yaml, content/sprites/shop.yaml, mods) |
| 2026-08-13 | Render-layer completions (D34) — MCP surface now 111 tools / 32 resources / 14 prompts / 20 namespaces. Two additive creation-first namespaces mirroring sprite.*/sound.* (D26): particle.* (content kind particle, content/particles/<id>.yaml — {id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?, fade?} zod strict; pure-visual emission profile; the render layer spawns event-triggered bursts from the particle bus event, which content fires via the existing behavior-language emit-event action) and shader.* (content kind shader, content/shaders/<id>.yaml — {id, filter: blur\|tint\|grayscale\|sepia\|hue\|brightness, params?} zod strict; parameterized BUILT-IN filters, NO GLSL in content; referenced from SpriteDef.filters and the weather visual mapping). Each namespace: validate/create/update/list/get + catalog/{id} resources + create-prompt, with the exact shapes/error conventions documented in their sections. No other tool/resource/prompt signatures change: particles/shader are render-layer + content-kind only — no engine systems, no hooks, no protocol changes (the WS snapshot frame only gains additive optional particles/shaders catalogs; the renderer also gains entity text labels via the plain label entity field and sprite-sheet frame crops — both outside the MCP surface). server.orient lists the new tools. See decisions.md D34. |
mcp.md, api.md, decisions.md (D34), AGENTS.md, packages/core (schema/particle.ts, schema/shader.ts, schema/sprite.ts), packages/render (viewport, particles, filters, weather, labels, sprite-draw, sync), packages/mcp (namespaces/particle.ts, namespaces/shader.ts, index, server), apps/mcp-server (ws-broadcast, tests) |
| 2026-08-13 | Core mechanism completions (D33) — MCP surface now 101 tools / 28 resources / 12 prompts / 18 namespaces. One additive tool: npc.path (debug A* grid pathfinding query — {npcId, to:{x,y}} → {npcId, from, to, path: [{x,y},...]} including start and goal, or {npcId, path: null, reason: "unreachable"\|"out of bounds"\|"blocked"}; errors npc.path: npcId must be a non-empty string / movement: unknown npc: <id> / npc.path: to must have integer x and y; pure query — never moves the npc; backs MovementSystem.findPath). No other tool/resource/prompt signatures change: map walkability is data-driven (optional MapDef.walkable?: Record<tileId, boolean>, declared-true overrides the UNWALKABLE_TILES fallback — zero content change for existing maps; consumed via the new shared isWalkableTile helper in map-grid, replacing the direct constant checks in movement/intent/placement buildable "map"), the behavior language gains the add incremental action ({action:"add", path, value} — set-state stays absolute; missing field = 0; non-numeric field throws; value must be finite), a new single-file content kind interact (content/interact.yaml {id, order: [harvest, water, sow, till]} — content-izes the player interact priority ORDER; applicability stays engine mechanism; NO MCP tools — single-file-kind weather precedent), and save.* covers TimeSystem (optional additive time: {day, season, tickInDay} in WorldSnapshot — old snapshots remain valid, CURRENT_SAVE_VERSION stays 1; snapshot captures TimeSystem.get(); restore calls time.set(day, season, tickInDay)). interact is registered as a content kind but the sample game's def carries the default order, so no MCP authoring tools were added. See decisions.md D33. |
mcp.md, api.md, decisions.md (D33), AGENTS.md, packages/core (map-grid, movement, intent, placement, time, save, behavior-runtime, pathfinding, schema), packages/mcp (npc.ts, behavior.ts, dialogue.ts, server.ts), apps/mcp-server/test/client-surface.test.ts, apps/mcp-server/test/sample-game.test.ts, packages/core/test, packages/mcp/test/npc-namespace.test.ts, examples/sample-game/content |
| 2026-08-13 | Desktop packaging (D32) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). Packaging is an offline build-time tool; the desktop shell adds no runtime engine capability — the Electron app serves the exact same bundle-mode static site over a local HTTP server. Nothing MCP-visible changes: no new namespace (pack.* still deliberately does not exist), no tools, no resources, no prompts, no content-kind registration, no protocol changes. The engine-side mechanisms the shell wraps are unchanged (ContentBundle + ContentLoader.loadBundle + createEngine({bundle}) + LocalSync). See decisions.md D32. |
mcp.md, api.md, decisions.md (D32), architecture.md, AGENTS.md, examples/sample-game/README.md, docs/engine-guide-zh.md, apps/pack/src/index.ts, apps/pack/src/desktop.ts, apps/pack/test/pack.test.ts, package.json, pnpm-workspace.yaml |
| 2026-08-13 | Dev-app/packaged-game day driver fixed (D-op2) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). The apps/dev "Advance day" button (reused verbatim by the packaged static site) previously called time.advance — the D23 clock mover — so a day jump moved the clock and fired day-start triggers/weather but never advanced GrowthSystem; in the packaged game a planted crop never progressed past its first stage. It now calls engine.tick(dayLengthTicks) — the D23 simulation driver (the same path the headless core's engine.tick and MCP time.tick use) — stepping time AND growth together. No engine-core/schema/MCP changes. Regression guard: the apps/pack packGame integration test now asserts the full sow → water → two tick(480) → grown cycle incl. spriteRef progression (parsnip-sprout → parsnip-grown); E2E (headless chromium) re-verified the packaged site's asset-rendered tiles/crops/player and that a watered parsnip reaches the grown sprite in the browser. |
api.md, mcp.md, apps/dev/src/main.ts, apps/pack/test/pack.test.ts, AGENTS.md |
| 2026-08-13 | Asset pipeline (D31) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). Binary assets (content/assets/: .png/.ogg) are filesystem content, not a content kind and not an MCP namespace — there is deliberately no assets.* namespace, no new tools, no new resources/prompts, no schema-kind registration, and no protocol-message changes (the WS snapshot frame only gains an additive assets field). The MCP-visible changes are schema-shape only on existing content kinds: sprite layers accept a third shape {shape:"image", src: <assetId>, x?, y?, w?, h?} (creation tools sprite.validate/create/update accept it; src is validated as a non-empty string — the loader resolves the id against content/assets/ at render time, and a missing asset degrades to the shape fallback), and sound.src is now a real audio asset id (previously reserved). map gains an optional tiles mapping (not MCP-created — maps are authored YAML, map.* is read-only). Rationale (D31.8): binary files are not MCP-creatable in a JSON-tool workflow, not introspection targets (the snapshot assets field is the debug surface), and have no runtime behavior to drive — the "MCP manipulability" arm of the tri-criterion fails, so no namespace is built. See decisions.md D31. |
mcp.md, api.md, decisions.md (D31), architecture.md, boundaries.md, AGENTS.md, examples/sample-game/README.md, docs/engine-guide-zh.md |
| 2026-08-12 | Game packaging (D30) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). Packaging is an offline build-time tool (apps/pack, farm-pack), not a runtime capability, so it deliberately gets no MCP namespace: pack.* tools would be a debug/creation surface (D3) for a step that has no runtime engine counterpart — the engine-side mechanism it serves IS exposed (the ContentBundle type + ContentLoader.loadBundle + createEngine({bundle}), see api.md §2/§9). The MCP creation namespaces' write helpers gained a guard: contentDir is now string \| undefined on the engine (bundle-mode engines are read-only) and the write helpers throw a clear error on undefined — an error result through the gateway, never a crash; surface counts unchanged. See decisions.md D30. |
mcp.md, api.md, decisions.md (D30), architecture.md, AGENTS.md, examples/sample-game/README.md, docs/engine-guide-zh.md |
| 2026-08-12 | Tier 3 content round (D29) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). The only engine change is the onPlayerIntent hook (HOOK_NAMES 15→16, wired in engine.wireEvents from the player-intent bus event; fires per IntentSystem.handle call, success AND failure, with HookContext.payload = the intent result {action, tile, ok, error?, detail?}). Hooks are NOT MCP surface (D-op2) — the hook exists to serve the "Mod hook demand" arm of the tri-criterion: Mods (hooks-only, D6/D13) cannot subscribe to the raw player-intent bus event that content matches via on: "player-intent", and the intent payload is transient (not readable from world state), so the D25.4 no-hook stance is reversed (see decisions.md D29.2). Sample-game content demonstrates the split: the fishing Mod hooks onPlayerIntent to settle catches (fish item — existing item kind, mod:fish-caught/mod:fish-too-tired custom events), and the fishing-treat state machine matches mod:fish-caught for a reward. No new namespaces, no new content kinds, no tool/resource/prompt signature changes. |
mcp.md, api.md, decisions.md (D29), boundaries.md, AGENTS.md, examples/sample-game/README.md |
| 2026-08-12 | server.enable_subsystem/server.disable_subsystem tools implemented (closes the D9 contract gap — the tools were spec'd in this section since the walking skeleton; surface now 100 tools / 28 resources / 12 prompts / 18 namespaces). Two new server.* tools wrapping the gateway's existing enable(name)/disable(name): server.enable_subsystem {name} → {name, enabled: true}, server.disable_subsystem {name} → {name, enabled: false}. Errors: non-string/empty name → <tool>: name must be a non-empty string; unknown namespace → <tool>: unknown subsystem: <name> (the gateway's McpGatewayError throw on a missing namespace is caught and converted, consistent with other namespaces' errorResult patterns); no gateway option → <tool>: gateway not available. Disabling a namespace removes its tools/resources/prompts from listTools/listResources/listPrompts and makes its tools reject calls (namespace "<name>" is disabled); server.info/server.status/server://status reflect {name, enabled: false}; server.enable_subsystem restores them. Guard rail (documented decision): server.disable_subsystem {name:"server"} is rejected with server.disable_subsystem: cannot disable the server subsystem — the server namespace hosts the very tool used to re-enable it. State is per-gateway in-memory (D9 — no persistence, no engine-system impact). server.orient lists the new tools automatically when gateway-backed (fallback static list updated). time/crop/server used as the enable/disable test subjects. |
mcp.md, api.md, decisions.md, AGENTS.md, apps/mcp-server/test/client-surface.test.ts, packages/mcp/test/server-namespace.test.ts |
| 2026-08-12 | rng.* namespace implemented — deterministic seeded RNG (D28; surface now 98 tools / 28 resources / 12 prompts / 18 namespaces). New engine mechanism mirroring LÖVE's love.math.RandomGenerator: packages/core/src/random.ts exports RandomSource ({next() /* [0,1) */, nextInt(max) /* [0,max) */, setSeed(seed), state(): {seed, calls}}) + seedRandom(seed) (mulberry32-style, zero deps — same seed → identical sequence) + randomSeed() (unseeded default from Math.random + Date.now). createEngine({seed?, ...}) seeds the facade's readonly rng: RandomSource; SystemInitCtx gains optional rng?: RandomSource (passed at init). WeatherSystem's default RNG now resolves ctx.rng when available (constructor-injected WeatherRng still wins for tests); PlantingSystem's harvest drop chance draws from ctx.rng.next() instead of bare Math.random — the engine's only two randomness consumers now share one seeded source. MCP surface: rng.get (no args → {seed, calls} — the current anchor + draw count), rng.set ({seed} finite number → {seed, calls: 0}, subsequent world randomness deterministic from that seed; non-finite/non-number → error result rng.set: seed must be a finite number), resource rng://state (same shape), no prompts (debug surface, D3). Save integration: WorldSnapshot gains optional top-level rngState?: {seed, calls} (zod optional — old snapshots remain valid, migration-free); save.snapshot includes it from ctx.rng, save.restore re-anchors via setSeed(rngState.seed) with calls reset to 0 (draw replay not built — documented). See decisions.md D28 (full LÖVE-gap adjudication) and api.md §1/§4/§7. |
mcp.md, api.md, decisions.md (D28), boundaries.md, AGENTS.md |
| 2026-08-12 | sprite.* and sound.* namespaces implemented — presentation pipeline data/protocol half (D26; surface now 96 tools / 27 resources / 12 prompts / 17 namespaces). Two new content kinds: sprite (content/sprites/<id>.yaml, spriteSchema — zod strict; {id, frames: [{name?, durationTicks?, layers: [{shape:"rect"\|"circle", x, y, w?/h? or r?, fill:"#rrggbb"}]}]}, programmatic draw instructions, no atlas/image in v1) and sound (content/sounds/<id>.yaml, soundSchema — zod strict; {id, synth?: {wave:"sine"\|"square"\|"triangle"\|"sawtooth", freq, durationMs, gain?, slideTo?}, src?} with at least one of synth/src). Both namespaces mirror item.* exactly (creation-first): sprite.validate/create/update/list/get + resources sprite://catalog/sprite://{id} + prompt sprite.create-prompt; sound.validate/create/update/list/get + resources sound://catalog/sound://{id} + prompt sound.create-prompt — validate against the schema (no write on failure), write content/<kind>/<id>.yaml, register live (create registers/overwrites, update removes then re-registers). Exact shapes: sprite.list → {sprites:[{id, frameCount}]}; sound.list → {sounds:[{id, hasSynth, hasSrc}]}; catalog resources → {sprites:[defs...]}/{sounds:[defs...]}; {id} resources → one def (missing → MCP error sprite not found: <id>/sound not found: <id>). WS protocol: snapshot frame gains sprites: SpriteDef[] and sounds: SoundDef[] (from registry.list at snapshot time — the render viewport needs the draw/audio instructions in-band); new client→server {"type":"input","input":string} message → the server emits an input bus event {input} broadcast as an event frame (behavior-language-matchable on:"input"), and key:ArrowUp/ArrowDown/ArrowLeft/ArrowRight additionally drive IntentSystem.handle("move", {tile}) one tile from the player's current position (player-centered); unknown prefixes/keys are broadcast-only; a non-string/empty input never crashes — a synthesized input error frame {input, ok:false, error:"input must be a non-empty string"} is broadcast instead. Behavior language: play-sound upgrades from a no-op to emitting the sound-play bus event {soundId} (documented mechanism; render layer plays in round 2). New schema/def fields: CropDef.stages[].spriteRef?, BuildingDef.spriteRef?, AnimalDef.stages[].spriteRef? (all optional strings — missing = renderer falls back to shape drawing); systems maintain entity appearance: PlantingSystem.sow sets plot.spriteRef (crop stage 0), GrowthSystem sets plot.spriteRef on crop stage advance and animal.spriteRef on spawn + animal stage advance, PlacementSystem.place copies building.spriteRef onto the entity, and the engine seeds player.spriteRef = "player". No new systems and no new hooks (sprites/sounds are content kinds; the input mapping wires into the existing IntentSystem from the broadcast server). See the sprite.*/sound.* sections above and decisions.md D26. |
mcp.md, api.md, decisions.md (D26), boundaries.md, architecture.md, AGENTS.md |
| 2026-08-12 | player.* namespace implemented — player input via IntentSystem (D25; surface now 86 tools / 23 resources / 10 prompts / 15 namespaces). New engine primitive IntentSystem (id "intent", auto-booted): handle(action, args) → IntentResult {action, tile, ok, error?, detail?} for move {tile:{x,y}} (validate + set player.x/player.y, reusing the shared map-grid/placement helpers — MovementSystem.moveTo untouched) and interact {tile:{x,y}} (smart context action with the v1 priority order: mature crop → harvest; planted & not watered → water; tilled & empty & seed → sow with the FIRST seed-kind inventory item; not tilled → till; else error). Every handle call emits the player-intent bus event (successes AND failures — HUD/viewport feedback depends on it). MCP surface: one tool player.intent ({action, tile}; errors as errorResult player.intent: <issue>) + one resource player://state (live player entity JSON). WS protocol: snapshot frame gains time: {day, season, tickInDay} (fixes the viewport HUD stuck on "booting…" — ws.ts applyTime now also reads snapshot.time); new client→server {"type":"intent","action","args"} message → IntentSystem.handle → player-intent event frame broadcast (bad input → synthesized error frame, never a crash). Sync handles: LocalSyncHandle/WsSyncHandle both gained intent(action, args) (local delegates to the engine IntentSystem; ws sends the intent message) — both dev modes are now playable. viewport.tileAt(clientX, clientY) → screen→tile. apps/dev gains an Interact/Move toolbar + canvas-click intents + HUD (day/season, seed counts, gold, auto-clearing intent feedback). No schema/content/hook changes; prompts unchanged (10). |
mcp.md, api.md, architecture.md, boundaries.md, decisions.md (D25), AGENTS.md |
| 2026-08-12 | WS multi-viewport sync implemented (D8/D24) — the final Tier 2 item. apps/mcp-server gains an optional --ws-port <port> / FARM_WS_PORT WebSocket broadcast channel running in the same process (zero-flag behavior unchanged). No MCP surface change — the channel is separate, NOT a namespace: 85 tools / 22 resources / 10 prompts / 14 namespaces stay identical. Protocol (transport section above): snapshot on connect (map def + world entities) and on {"type":"sync"}, event frames broadcast to all clients for every bus emission, defensive JSON serialization (non-serializable payloads skipped with a log line, never a crash). packages/render gains sync/ws.ts connectWs (same handle shape as connectLocal) and snapshot entity drawing in the viewport (plots with tilled/watered/crop-stage colors, buildings as footprint blocks, npc/player/animal dots — no art). apps/dev is parameterized via URL query (?contentDir=/?modsDir= default to the repo roots + LocalSync; ?sync=ws&url=ws://... renders a headless core's world instead of booting one). Core change: engine.events (readonly WorldEventBus) exposed on the facade so the broadcast server can subscribe (api.md §1). |
mcp.md, api.md, architecture.md, boundaries.md, decisions.md, AGENTS.md |
| 2026-08-12 | crop.water fixed to mirror the mechanism (MCP-surface only; @farm/mcp; surface unchanged at 85 tools / 22 resources / 10 prompts). The debug tool previously toggled the plot's watered flag (true→false→true), which broke the MCP-driven farming loop (water → tick → water → tick… flipped watered back to false, so waterRequired stages never advanced). It now calls PlantingSystem.water(x,y) (the SET mechanism — same precedent as crop.stage→GrowthSystem.forceStage and crop.till/crop.sow/crop.harvest→PlantingSystem): result stays {plotId, x, y, watered} (always true after a successful water; watering an already-watered plot keeps it watered). Error paths follow crop.till's conventions — shared resolveCoordArgs (crop: provide plotId or x/y coordinates, crop: no plot found: <plotId>), system errors pass through (planting: no plot at (x,y), planting.water: tile (x,y) is not tilled). No engine-core changes, no new tools/hooks/events. |
mcp.md, api.md |
| 2026-08-12 | crop.till / crop.sow / crop.harvest debug tools added (the core farming loop till → sow → water → harvest is now drivable through MCP; surface now 85 tools / 22 resources / 10 prompts). crop.till {x?, y?, plotId?} → {plotId, x, y, tilled} — wraps PlantingSystem.till(x,y) (idempotent: creates the plot entity if absent, re-tills otherwise); no coords/plotId → error result crop: provide plotId or x/y coordinates. crop.sow {x?, y?, plotId?, seedItemId} → {plotId, x, y, cropItemId, seedItemId} — wraps PlantingSystem.sow(x,y,seedItemId): consumes 1 seed from player.inventory (emitting item-use), sets cropItemId to the crop def id resolved from the seed; missing/blank seedItemId → error result crop.sow: seedItemId must be a non-empty string; system errors pass through (planting.sow: tile (x,y) is not tilled, planting.sow: no <seed> in inventory, planting: no plot at (x,y), …). crop.harvest {x?, y?, plotId?} → {plotId, x, y, cropItemId, drops: [{itemId, qty}]} — wraps PlantingSystem.harvest(x,y): requires the final stage, rolls drops, grants them to player.inventory (emitting item-acquire per drop), emits crop-harvest, clears the crop fields (plot stays tilled); system errors pass through (planting.harvest: plot <id> has no crop, planting.harvest: crop <id> on plot <id> is not mature (stage N of M)). All three accept {x, y} or {plotId} (identical coordinate/plotId validation to crop.stage/crop.water; plotId resolves via plotIdFor). Debug tools (D3) wrapping the PlantingSystem mechanism — no schema/content changes, no new hooks, no new events. |
mcp.md, api.md |
| 2026-08-12 | apps/mcp-server entry parameterized + examples/sample-game/ added (transport section above). The stdio entry serves any content/mods pair via exported resolveDirs({argv, env, cwd}) (CLI --content-dir/--mods-dir > env FARM_CONTENT_DIR/FARM_MODS_DIR > monorepo defaults; relative paths against cwd) and main(dirs) (auto-runs only under direct execution). examples/sample-game/ ships complete game content + a mod exercising every namespace; apps/mcp-server/test/sample-game.test.ts proves it boots and plays through MCP. |
mcp.md, api.md, architecture.md |
| 2026-08-12 | Tick loop + animal.* namespace implemented (Tier 2 items 5 & 6 final; surface now 82 tools / 22 resources / 10 prompts). Tick loop (D23): engine.tick(dt) is the headless core's explicit simulation driver (dt in ticks, 1 tick = 1 engine time unit, dayLengthTicks from the time def) — it advances TimeSystem (firing day-start/season-change boundaries as before) and then runs every system's step(dt) in registration order. TimeSystem gained step(dt) which emits the new time-tick bus event {day, season, tickInDay} after each advance; GrowthSystem.step accumulates day-fractions and advances crops/animals on day boundaries. New tool time.tick ({ticks?}, default 1) → the post-step time state {day, season, tickInDay}; it is semantically distinct from time.advance (clock-only, no systems step, no time-tick). Optional auto-tick: createEngine({ tickIntervalMs, tickDeltaTicks? }) drives tick via a node:timers interval (default 1 tick per interval, unref'd, cleared on engine.stop()); OFF by default so tests stay deterministic. onSchedule intra-day form: at: { tickInDay: n } (n ∈ [0, dayLengthTicks)) added to the schedule-at union — evaluated on the time-tick event (schema requires at least one of day/season/tickInDay in the object form; the existing "day-start"/"season-change"/{day?, season?} forms are unchanged and still evaluated only on those boundary events). animal.* namespace — 9 tools: animal.validate/create/update/list/get (definition lifecycle against animalSchema, mirroring crop.* exactly — write content/animals/<id>.yaml, create registers/overwrites, update removes then re-registers) + debug animal.spawn ({animalId,x,y} → {id:"animal:<animalId>:<n>", type:"animal", animalId, x, y, stageIndex:0, daysInStage:0, fed:false}; unknown def → growth: unknown animal def: <id>; bad coords → growth: x and y must be non-negative integers) + animal.feed ({id} → {id, animalId, fed:true}; unknown → growth: unknown animal: <id>; emits animal-fed {animalId, animalDefId}) + animal.stage ({id, stageIndex} → {id, animalId, animalDefId, stageIndex, stage}; delegates to GrowthSystem.forceAnimalStage) + animal.entities (→ {animals:[...]}). Resources animal://catalog + animal://{id} (missing → MCP error animal not found: <id>). Prompt animal.create-prompt. AnimalDef (content kind animal, content/animals/<id>.yaml): { id, stages: [{name, days, feedRequired}], produces: [{itemId, qty, chance?}] } — the growth cycle + produce table (data only); feed/produce rules are behavior-language content. GrowthSystem step advances both type:"plot" (CropDef, zero regression) and type:"animal" entities (AnimalDef): per day, a feedRequired stage gates on the entity's fed flag (mirroring watered); non-final stages accumulate daysInStage then advance stageIndex (resetting daysInStage/fed, emitting animal-grow); a mature (final-stage) animal emits animal-produce per eligible day and its fed resets. New hooks onAnimalGrow/onAnimalProduce wired in engine.wireEvents; animal-fed is bus-only (no hook — behavior-language-matchable). See api.md §4/§7 and decisions.md D23. |
mcp.md, api.md, decisions.md, boundaries.md, AGENTS.md |
| 2026-08-12 | Initial draft (pre-implementation) | mcp.md, api.md, architecture.md, decisions.md, AGENTS.md |
| 2026-08-12 | Walking skeleton implemented: server.*/map.*/time.* namespaces live (7 tools, 3 resources, 1 prompt). item/crop/shop/save/behavior/mod remain spec-only. server.enable_subsystem/disable_subsystem deferred (gateway enable/disable exists, no tools yet). Resource contract: uri template + read(params) (see note above). |
mcp.md |
| 2026-08-12 | Client-surface shapes confirmed by integration tests (apps/mcp-server/test/client-surface.test.ts, 12 cases): success results omit isError (absent, not false); map.list returns {maps:[...]} (id/width/height/tilesetRef summaries); map.get returns the map def plus a player key (spawn entity, null if absent) and defaults to the first map when mapId omitted; time.* args validated (missing/negative ticks → error result). |
mcp.md |
| 2026-08-12 | item.* namespace implemented (creation-first, D3): item.validate/item.create/item.update/item.list/item.get + debug item.query_inventory/item.give; resources item://catalog + item://{id}; prompt item.create-prompt. Creation tools validate the JSON def against itemSchema (no write on failure), then write content/items/<id>.yaml (YAML stringify) and register live directly against the registry — mirrors D13 hot-reload (create registers/overwrites; update removes then re-registers). Exact shapes: item.list → {items:[{id,name,kind}]}; item.validate/item.create/item.update/item.get → normalized ItemDef JSON or error result (item not found: <id>); item.query_inventory → {inventory:{itemId:qty}}; item.give → {itemId,qty,total}; item://catalog → {items:[defs...]}; item://{id} → one def (missing id → MCP error). Duplicate item.create overwrites. Inventory lives on the player entity (player.inventory); give/take emit item-acquire/item-use bus events → hooks onItemAcquire/onItemUse. |
mcp.md, api.md |
| 2026-08-12 | crop.* namespace implemented (creation-first, D3): crop.validate/crop.create/crop.update/crop.list/crop.get + debug crop.stage/crop.water; resources crop://catalog + crop://{id}; prompt crop.create-prompt. Creation tools mirror item.* exactly: validate the JSON def against cropSchema (no write on failure), write content/crops/<id>.yaml (YAML stringify), register live (create registers/overwrites; update removes then re-registers). Exact shapes: crop.list → {crops:[{id,seedItem,stageCount}]}; crop.validate/crop.create/crop.update/crop.get → normalized CropDef JSON or error result (crop not found: <id>); crop.stage → {plotId,x,y,cropItemId,stageIndex,stage} (accepts {x,y,stageIndex} or {plotId,stageIndex}; delegates to GrowthSystem.forceStage, fires onCropGrow); crop.water → {plotId,x,y,watered} (accepts {x,y} or {plotId}; toggles the plot's watered); crop://catalog → {crops:[defs...]}; crop://{id} → one def (missing id → MCP error). cropItemId on a plot is the crop def id, not the seed item id. Growth stage advance itself is simulation (GrowthSystem.step), not an MCP tool — MCP only forces stages for debugging (D3). |
mcp.md, api.md |
| 2026-08-12 | shop.* namespace implemented (creation-first, D3): shop.validate/shop.create/shop.update/shop.list/shop.get + debug shop.buy/shop.sell; resources shop://catalog + shop://{shopId}; prompt shop.create-prompt. Creation tools mirror item.*/crop.* exactly: validate the JSON def against shopSchema (no write on failure), write content/shops/<id>.yaml (YAML stringify), register live (create registers/overwrites; update removes then re-registers). Exact shapes: shop.list → {shops:[{id,buyCount,sellCount}]}; shop.validate/shop.create/shop.update/shop.get → normalized ShopDef JSON or error result (shop not found: <id>); shop.buy → {shopId,itemId,qty,total,gold} or error result (unknown shop, item not sold, insufficient gold, non-positive qty); shop.sell → {shopId,itemId,qty,total,gold} or error result (unknown shop, item not bought, insufficient inventory); shop://catalog → {shops:[defs...]}; shop://{shopId} → one def (missing id → MCP error). Buy/sell delegate to EconomySystem.buy/sell (each fires onTransaction via the transaction bus event; buy also emits item-acquire, sell also emits item-use). Buy/sell convention: sells = shop sells to the player (player pays sells[].price), buys = shop buys from the player (player earns buys[].price). stock is optional and informational in this slice (no depletion/restock). |
mcp.md, api.md |
| 2026-08-12 | save.* namespace implemented (mechanism only, D14): save.snapshot (no args → full WorldSnapshot JSON {format:"farm-save", version, savedAt, world: Entity[]}); save.validate ({snapshot} → {ok:true} or {ok:false, errors:[...]} — always a normal result; validation failure is a report, not an error result); save.restore ({snapshot} → {restored:N} or error result — validates, rejects future versions, migrates to the current version via registered migrations, then replaces ALL world entities; malformed input → error result invalid snapshot); save.migrate ({snapshot, fromVersion?, toVersion?} → the migrated snapshot or error result — no-op when versions equal, throws on backwards migration, versions without a registered migration are identity steps, so the v1 empty registry returns the snapshot unchanged after validation; non-positive fromVersion/toVersion → error result); resource save://state (latest snapshot, live — reflects mutations); prompt save.authoring-prompt (save format, versioning, migration authoring). The snapshot covers world entities only; TimeSystem state is not serialized in v1. Save policy (when to save, slots, autosave, UI) is creator content/Mods (D14) — no policy tools. save has no content files (content/saves/ does not exist). |
mcp.md, api.md |
| 2026-08-12 | behavior.* namespace implemented (v1 core: conditions + triggers, D15), 7 tools + 1 resource + 1 prompt. Creation tools mirror item.*/crop.*/shop.* exactly (tools named trigger.* per the contract, but the content kind is behavior — so files live in content/behaviors/<id>.yaml and the list key is triggers): validate the JSON def against triggerSchema (no write on failure), write the YAML via stringify, register live (create registers/overwrites; update removes then re-registers). Exact shapes: behavior.trigger.list → {triggers:[{id,actionCount}]} (registration order); behavior.trigger.validate/behavior.trigger.create/behavior.trigger.update/behavior.trigger.get → normalized TriggerDef JSON or error result (behavior.trigger.get: trigger not found: <id> / behavior.trigger.update: trigger not found: <id>); behavior.fire ({triggerId}) → {triggerId, fired} — fired: true when the condition evaluated true (actions ran, trigger-fire emitted, onTriggerFire hook fired), fired: false when the condition was false (no-op); unknown trigger → error result (unknown trigger: <id>; non-empty-string validation also errors). behavior.condition.validate ({condition}) → {ok:true} or {ok:false, errors:[...]} — always a normal result (mirrors save.validate); the expression is validated alone against conditionExprSchema. Resource behavior://triggers → {triggers:[defs...]} (exact-URI, no template). Prompt behavior.trigger-prompt → guided authoring (condition grammar, action set, path conventions). No deviations from the behavior.* section contract above beyond the documented shapes; condition evaluation semantics (path resolution convention, trigger-fire loop guard, action behavior) are in api.md §4. |
mcp.md, api.md |
| 2026-08-12 | mod.* namespace implemented (introspection only, D10; last Tier 1 subsystem), 3 tools + 1 resource, no prompts. The engine facade gained a read-only mods surface (api.md §1): engine.mods (the loaded ModStatus[], live) and engine.modsDir (`string |
undefined), plusengine.reloadMods(id?)behindmod.reload. Exact shapes:mod.list(no args) →{mods:[ModStatus...]}where eachModStatus = {id, version, loaded, hooks: string[], errors?: string[]}—versionfrom the Mod's inlinemanifest {id, version}(Mods are code atmods//index.mjs; the loader does NOT read acontent/mods//manifest.yamlor anycontentOverridesfield, so neither is surfaced — reported as-is rather than invented);mod.status({id}) → oneModStatusJSON or error result (mod not found: ; blank id →mod.status: id must be a non-empty string);mod.reload({id?}) →{reloaded: true, mods: ModStatus[]}with fresh statuses (unknown id → error resultmod not found: ; blank id →mod.reload: id must be a non-empty string when provided); resourcemod://{id}→ oneModStatus(missing id → MCP gateway errormod not found: ). **mod.reloadsemantics (v1)**: in-process re-import + re-bind, NOT a process restart —ModLoader.reload(modsDir, id?)unbinds that Mod's previously-registered hook functions (eachHookRegistry.onreturns an unsubscriber; the loader keeps per-mod unsubscriber records) and re-imports the Mod entry with a cache-busting?t=-query so changed.mjsfiles are actually re-read and re-bound without duplicate hook bindings. With noid, reload unbinds all recorded Mods and re-scansmodsDir(newly added Mod dirs are picked up; deleted Mods are unbound). A Mod that was never loaded successfully in this process cannot be reloaded by id (no entry record → treat as unknown). D13's authoritative path remains unchanged: code Mod changes are picked up for real by restarting the headless core (apps/mcp-server);mod.reloadis the in-process convenience for the dev loop. Errors during reload (bad import, bad manifest) surface asloaded: false+errorsin the returned statuses and inengine.mods. Deviation from the draft contract: themod.*` section lists no authoring prompt and none was added (consistent with the contract — introspection only).
|
| 2026-08-12 | Behavior language v2 implemented (state machines + onSchedule time primitive, D15/D20; Tier 2 lead item), 7 tools + 1 resource + 1 prompt added to the behavior.* namespace (surface now 49 tools / 13 resources / 7 prompts). Separate content kind statemachine (content/statemachines/<id>.yaml, stateMachineSchema in schema/statemachine.ts) — not a discriminated union inside behavior — so behavior.trigger.* stays intact. New tools: behavior.statemachine.validate/create/update/list/get (creation-first, mirror trigger.* exactly: validate against stateMachineSchema with cross-field checks — initial/transitions[].from/to/onSchedule[].to must reference declared states, duplicate state ids rejected, each transition needs at least one of when/on; no write on failure; write content/statemachines/<id>.yaml; create registers/overwrites, update removes then re-registers) + debug behavior.statemachine.status ({machineId} → {machineId, state, started} — started: false with state = declared initial before the instance exists) + behavior.statemachine.transition ({machineId, to} → {machineId, from, to, transitioned} — force a transition regardless of declared conditions, fires onEnter/onExit and emits state-change; unknown machine/state → error result). Resource behavior://statemachines → {statemachines:[defs...]}. Prompt behavior.statemachine-prompt → guided authoring (states, transitions, schedule entries, quest-progress convention). Design decisions (D20): (1) separate kind vs behavior-union — separate keeps trigger tool names truthful and content dirs 1:1; (2) onSchedule (schedule entries over the existing day-start/season-change bus events) instead of a tick loop — the engine has no auto-driven tick, so no new game loop; intra-day timing awaits a tick loop; (3) TriggerSystem extended in place (id stays "trigger") so triggers and machines share one match pass and one re-entrancy guard; (4) machine state lives on a world entity {id: "sm:<machineId>", type: "statemachine", machineId, state} (lazily created on first transition) so save.* snapshots it for free and MCP inspects it. Each transition emits state-change {machineId, from, to} → new hook onStateChange. |
mcp.md, api.md, boundaries.md, decisions.md |
| 2026-08-12 | dialogue.* and weather.* namespaces specced (MCP-first, D-op1; Tier 2 items 2 & 3) before implementation. dialogue.*: 7 tools (5 lifecycle + debug start/choose), 2 resources (catalog + {id}), 1 prompt (create-prompt). weather.*: 2 debug tools (get/set), 1 resource (weather://state), no prompts — single-file content kind makes creation tools meaningless. Design decisions (D21) recorded in the sections above: dialogue state on a world entity dialogue:active; linear to nodes surface a synthesized Continue choice; available choices are when-filtered and choose indexes into that list; terminal/all-gated nodes auto-end; weather state on a boot-created weather entity; injectable RNG; initial weather = current season's first table entry. |
mcp.md |
| 2026-08-12 | placement.* and npc.* namespaces specced (MCP-first, D-op1; Tier 2 items 4 & 5) before implementation. placement.*: 9 tools (5 building lifecycle + debug place/remove/list/occupies), 3 resources (placement://buildings, placement://{id}, placement://placed), 1 prompt (create-prompt). npc.*: 5 debug tools (list/get/spawn/move/teleport), 1 resource (npc://{npcId}), no prompts (runtime-entity namespace — no content kind). Design decisions (D22) recorded in the sections above: BuildingDef buildable as "map"/{tiles}/"any" union; buildings as world entities; buildings-block-buildings v1 occupancy; placement-change event + onPlacementChange hook; MovementSystem resolving PlacementSystem from the host; tile walkability as a "water" tile-id convention; buildings block NPCs but NPCs do not block each other (v1); no onNpcMove hook (the npc-move bus event is behavior-language-matchable). |
mcp.md |
| 2026-08-12 | placement.* and npc.* namespaces implemented (Tier 2 items 4 & 5; surface now 72 tools / 20 resources / 9 prompts). placement.* — 9 tools: placement.building.validate/create/update/list/get (creation-first, mirror item.* exactly against buildingSchema — footprint positive ints, buildable as "map"/{tiles:[...]}/"any" union with non-empty tile lists, required blocksMovement boolean; no write on failure; write content/buildings/<id>.yaml; create registers/overwrites, update removes then re-registers) + debug placement.place ({buildingId,x,y} → placed entity {id:"building:<buildingId>:<x>:<y>", type:"building", buildingId, x, y, w, h, blocksMovement} or error result: placement: unknown building: <id>, placement: footprint out of bounds at (x,y), placement: tile (<cx>,<cy>) is not buildable for <id>, placement: footprint overlaps existing building <id>) + placement.remove ({x,y} → removed entity or error placement: no building at (x,y)) + placement.list (→ {buildings:[placed...]}) + placement.occupies ({x,y} → building entity or null). Resources placement://buildings → {buildings:[defs...]} + placement://{id} → one def (missing → MCP error building not found: <id>) + placement://placed → {buildings:[placed...]} (live). Prompt placement.create-prompt → guided building authoring. npc.* — 5 debug tools (D3): npc.list (→ {npcs:[{id,x,y}]}), npc.get ({npcId} → entity or error npc not found: <id>), npc.spawn ({id,x,y} → created entity; rejects blank id / bad coords / duplicate id), npc.move ({npcId,x,y} → {npcId, from, to} or error result: movement: unknown npc: <id> / movement: target out of bounds: (x,y) / movement: tile (x,y) is not walkable / movement: blocked by building <id> at (x,y)), npc.teleport ({npcId,x,y} → same shape, no bounds/walkability/blocking checks); resource npc://{npcId} → live entity (missing → MCP error). npc.* has no prompts (NPCs are runtime entities — no authored content kind). New content kind building (content/buildings/), new hook onPlacementChange wired to the placement-change bus event. Movement blocks on blocksMovement: true buildings read via PlacementSystem.occupies; tile walkability uses the "water" convention. |
mcp.md, api.md |
| 2026-08-12 | dialogue.* and weather.* namespaces implemented (Tier 2 items 2 & 3; surface now 58 tools / 16 resources / 8 prompts). dialogue.* — 7 tools: dialogue.validate/create/update/list/get (creation-first, mirror item.* exactly against dialogueSchema with cross-field checks — unique node ids, startNode/to/choice to must be declared nodes, choices+to mutually exclusive, non-empty choices; no write on failure; write content/dialogues/<id>.yaml; create registers/overwrites, update removes then re-registers) + debug dialogue.start ({dialogueId} → {dialogueId, nodeId, text, choices: [{text, to}]} — choices are the when-filtered available list; unknown → error result dialogue: unknown dialogue: <id>) + dialogue.choose ({index} → next node state; index into the available list; errors: dialogue.choose: index must be a non-negative integer, dialogue: no active dialogue, dialogue: choice index out of range: <index>; a terminal/all-gated node ends the dialogue — text delivered in the result, dialogue-end emitted, active entity removed). Resources dialogue://catalog → {dialogues:[defs...]} + dialogue://{id} → one def (missing → MCP error). Prompt dialogue.create-prompt → guided authoring (nodes/choices/when/actions). weather.* — 2 debug tools (D3): weather.get (no args → {weatherId, name?, season}) + weather.set ({weatherId} → {weatherId, name?, season, from?} — from present only on change; unknown id → error result weather: unknown weather id: <id>); resource weather://state → current state; no prompts (single-file content kind — no creation tools by design). Both systems' state persists on world entities (dialogue:active, weather), so save.* covers them. New hooks onDialogueStart/onDialogueEnd/onWeatherChange wired in engine.wireEvents. Deviation from the spec's parenthetical surface-count estimate (+1 dialogue resource; see the dialogue.* section — catalog + {id} follow the item.*/crop.*/shop.* pattern). |
mcp.md, api.md, boundaries.md, decisions.md |