# Game server (`NeonSprawl.Server`) ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + position state via in-memory **or** PostgreSQL). ## Prerequisites - **.NET 10 SDK** (matches `TargetFramework` in `NeonSprawl.Server.csproj`). Check with `dotnet --list-sdks`. ## Run ```bash cd server/NeonSprawl.Server dotnet run ``` - Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`). - Check `GET /health` for a JSON heartbeat. **Optional prototype API stdout (manual QA / dev):** set **`NEON_SPRAWL_API_LOG`** to **`1`**, **`true`**, **`yes`**, or **`on`** (case-insensitive). While enabled, each **`POST …/ability-cast`** that returns JSON **`AbilityCastResponse`** (HTTP 200) prints one line to the process console: either **`ability_cast_requested`** (accept) or **`ability_cast_denied`** with **`reason=`** (deny). **`400`** / **`404`** paths do not emit these lines. Implementation: `Diagnostics/PrototypeApiConsoleLog.cs` (wired from `AbilityCastApi`). Example: ```bash NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj ``` IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable). ## Skill catalog (`content/skills`, NEO-34) On startup the host loads every **`*_skills.json`** under the skills directory, validates each row against **`content/schemas/skill-def.schema.json`**, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:SkillsDirectory`** | Optional. Absolute path, or path relative to the server **content root** (the project directory for `dotnet run`). When unset, the host walks **ancestors of `AppContext.BaseDirectory`** until it finds a **`content/skills`** directory (works for `dotnet run` from `server/NeonSprawl.Server` without extra config). | | **`Content:SkillDefSchemaPath`** | Optional override for **`skill-def.schema.json`**. When unset, resolved as **`{parent of skills directory}/schemas/skill-def.schema.json`** (repo layout: `content/schemas` next to `content/skills`). | **Docker / CI:** mount or copy the repo **`content/`** tree (at least **`content/skills`** and **`content/schemas`**) and set **`Content__SkillsDirectory`** / **`Content__SkillDefSchemaPath`** if the layout differs from the default discovery rule. On success, **Information** logs include the resolved skills directory path and distinct skill count. ## Item catalog (`content/items`, NEO-51) On startup the host loads every **`*_items.json`** under the items directory, validates each row against **`content/schemas/item-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:ItemsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/items`**. | | **`Content:ItemDefSchemaPath`** | Optional override for **`item-def.schema.json`**. When unset, **`{parent of items directory}/schemas/item-def.schema.json`**. | **Docker / CI:** include **`content/items`** and **`content/schemas/item-def.schema.json`** in the mounted **`content/`** tree; set **`Content__ItemsDirectory`** when layout differs. On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52). ## Resource-node catalog (`content/resource-nodes`, NEO-58) On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:ResourceNodesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/resource-nodes`**. | | **`Content:ResourceNodeDefSchemaPath`** | Optional override for **`resource-node-def.schema.json`**. When unset, **`{parent of resource-nodes directory}/schemas/resource-node-def.schema.json`**. | | **`Content:ResourceYieldRowSchemaPath`** | Optional override for **`resource-yield-row.schema.json`**. When unset, **`{parent of resource-nodes directory}/schemas/resource-yield-row.schema.json`**. | **Docker / CI:** include **`content/resource-nodes`**, **`content/items`**, and the two resource-node schemas in the mounted **`content/`** tree; set **`Content__ResourceNodesDirectory`** when layout differs. On success, **Information** logs include the resolved resource-nodes directory path, distinct node count, yield row count, and catalog file counts. **`IResourceNodeDefinitionRegistry`** (NEO-59) is the preferred lookup surface for gather engine, depletion, interact, and HTTP callers — resolve node defs and yield rows by **`nodeDefId`**, enumerate defs in id order. The catalog singleton remains for fail-fast startup only; do not inject **`ResourceNodeCatalog`** in new game code. ## Recipe catalog (`content/recipes`, NEO-66) On startup the host loads every **`*_recipes.json`** under the recipes directory, validates each row against **`content/schemas/recipe-def.schema.json`** (with **`recipe-io-row.schema.json`** for I/O `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate recipe `id`** values across files, cross-checks every input/output **`itemId`** against the **item catalog** and every **`requiredSkillId`** against the **skill catalog** (both loaded first), and enforces the **prototype Slice 3** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:RecipesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/recipes`**. | | **`Content:RecipeDefSchemaPath`** | Optional override for **`recipe-def.schema.json`**. When unset, **`{parent of recipes directory}/schemas/recipe-def.schema.json`**. | | **`Content:RecipeIoRowSchemaPath`** | Optional override for **`recipe-io-row.schema.json`**. When unset, **`{parent of recipes directory}/schemas/recipe-io-row.schema.json`**. | **Docker / CI:** include **`content/recipes`**, **`content/items`**, **`content/skills`**, and the two recipe schemas in the mounted **`content/`** tree; set **`Content__RecipesDirectory`** when layout differs. On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code. ## Ability catalog (`content/abilities`, NEO-77) On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype ability catalog** seven-id roster gate (four player hotbar abilities plus three NPC attack abilities; same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:AbilitiesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/abilities`**. | | **`Content:AbilityDefSchemaPath`** | Optional override for **`ability-def.schema.json`**. When unset, **`{parent of abilities directory}/schemas/ability-def.schema.json`**. | **Docker / CI:** include **`content/abilities`** and **`content/schemas/ability-def.schema.json`** in the mounted **`content/`** tree; set **`Content__AbilitiesDirectory`** when layout differs. On success, **Information** logs include the resolved abilities directory path, distinct ability count, and catalog file count. Game code should use **`IAbilityDefinitionRegistry`** for lookups (NEO-79). The catalog singleton remains for fail-fast startup only; do not inject **`AbilityDefinitionCatalog`** in new game code. Hotbar loadout and ability cast routes validate ability ids via **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (backed by the loaded catalog, not a separate static allowlist). ## NPC behavior catalog (`content/npc-behaviors`, NEO-88) On startup the host loads every **`*_npc_behaviors.json`** under the npc-behaviors directory, validates each row against **`content/schemas/npc-behavior-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, enforces the **prototype E5M2** three-id roster gate, and requires **`leashRadius > aggroRadius`** per row (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:NpcBehaviorsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/npc-behaviors`**. | | **`Content:NpcBehaviorDefSchemaPath`** | Optional override for **`npc-behavior-def.schema.json`**. When unset, **`{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json`**. | **Docker / CI:** include **`content/npc-behaviors`** and **`content/schemas/npc-behavior-def.schema.json`** in the mounted **`content/`** tree; set **`Content__NpcBehaviorsDirectory`** when layout differs. On success, **Information** logs include the resolved npc-behaviors directory path, distinct behavior count, and catalog file count. Game code should use **`INpcBehaviorDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-89). The catalog singleton remains for fail-fast startup only; do not inject **`NpcBehaviorDefinitionCatalog`** in new game code. Stable prototype behavior id constants: **`PrototypeNpcBehaviorRegistry`**. ## Reward table catalog (`content/reward-tables`, NEO-101) On startup the host loads every **`*_reward_tables.json`** under the reward-tables directory, validates each row against **`content/schemas/reward-table.schema.json`** (with **`reward-grant-row.schema.json`** for grant `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate reward-table `id`** values across files, cross-checks every **`fixedGrants[].itemId`** against the **item catalog** (loaded first), rejects duplicate **`itemId`** within a table, and enforces the **prototype E5M3** one-table roster + frozen grant quantity gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:RewardTablesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/reward-tables`**. | | **`Content:RewardTableDefSchemaPath`** | Optional override for **`reward-table.schema.json`**. When unset, **`{parent of reward-tables directory}/schemas/reward-table.schema.json`**. | | **`Content:RewardGrantRowSchemaPath`** | Optional override for **`reward-grant-row.schema.json`**. When unset, **`{parent of reward-tables directory}/schemas/reward-grant-row.schema.json`**. | **Docker / CI:** include **`content/reward-tables`**, **`content/items`**, and the two reward schemas in the mounted **`content/`** tree; set **`Content__RewardTablesDirectory`** when layout differs. On success, **Information** logs include the resolved reward-tables directory path, distinct reward-table count, and catalog file count. Game code should use **`IRewardTableDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`RewardTableDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as ability/hotbar routes. ## Encounter catalog (`content/encounters`, NEO-101) On startup the host loads every **`*_encounters.json`** under the encounters directory **after** the reward-table catalog, validates each row against **`content/schemas/encounter-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate encounter `id`** values across files, cross-checks each **`rewardTableId`** against loaded reward-table ids and each **`requiredNpcInstanceIds`** set against **`PrototypeNpcRegistry`**, and enforces the **prototype E5M3** one-encounter roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:EncountersDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/encounters`**. | | **`Content:EncounterDefSchemaPath`** | Optional override for **`encounter-def.schema.json`**. When unset, **`{parent of encounters directory}/schemas/encounter-def.schema.json`**. | **Docker / CI:** include **`content/encounters`**, **`content/reward-tables`**, and **`encounter-def.schema.json`** in the mounted **`content/`** tree; set **`Content__EncountersDirectory`** when layout differs. On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as ability/hotbar routes. ## Quest catalog (`content/quests`, NEO-113) On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, and encounter catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`** and **`quest-objective-def.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, and enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| | **`Content:QuestsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/quests`**. | | **`Content:QuestDefSchemaPath`** | Optional override for **`quest-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-def.schema.json`**. | | **`Content:QuestStepDefSchemaPath`** | Optional override for **`quest-step-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-step-def.schema.json`**. | | **`Content:QuestObjectiveDefSchemaPath`** | Optional override for **`quest-objective-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-objective-def.schema.json`**. | **Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**), and the three quest schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes. ## Quest definitions (NEO-115) **`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/quest-definitions" ``` ## Quest progress store (NEO-116) Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP read/accept routes land in E7M1-08/09 (NEO-119/NEO-120). **Store interface methods:** - **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`). - **`TryGetProgress`** — read one row; false when not started. - **`TryActivate`** — first accept (`not_started` → `active`). - **`TryAdvanceStep`** — bump step index and clear counters for the new step (denies when completed or index does not increase). - **`TryUpdateObjectiveCounter`** — set one objective counter on the current step (non-negative). - **`TryMarkComplete`** — first completion returns `true`; store replay returns `false` without changing **`completedAt`**. Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids are validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** in **`QuestStateOperations`**. **Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP). ### Quest state operations (NEO-117) **`QuestStateOperations`** (static) wraps the store with catalog validation and structured **`reasonCode`** denials. Returns **`QuestStateOperationResult`** (`success`, optional `reasonCode`, optional progress snapshot). | Method | Role | |--------|------| | **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. | | **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. | | **`TryMarkComplete`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). | **Reason codes:** | Code | When | |------|------| | **`unknown_quest`** | Quest id not in catalog (accept, advance, complete). | | **`prerequisite_incomplete`** | Accept when a listed prerequisite is not **completed** (implicit `not_started` fails). | | **`already_completed`** | Accept or advance when row is already **completed**. | | **`already_active`** | Accept when row is already **active**. | | **`unknown_player`** | Player id cannot be written (no dev bucket / missing from Postgres `player_position`). | | **`not_active`** | Advance or complete when quest was never accepted or is not **active**. | | **`invalid_step_index`** | Advance when index does not increase or is past the last step. | Objective wiring (gather/craft/encounter hooks) is E7M1-07 ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)). Plan: [NEO-117 implementation plan](../../docs/plans/NEO-117-implementation-plan.md). ## Encounter definitions (NEO-103) **`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/encounter-definitions" ``` ## Encounter progress + completion stores (NEO-104) Per-player encounter state is server-owned in **`IEncounterProgressStore`** (activation + defeated required NPC ids) and **`IEncounterCompletionStore`** (idempotent **`completedAt`** flag after rewards — marking used by NEO-105). Prototype uses in-memory singletons registered in **`AddEncounterAndRewardCatalogs`**. **`EncounterProgressOperations`** (static): - **`TryActivateOnFirstEngagement(playerId, npcInstanceId, …)`** — resolves encounter from NPC via **`IEncounterDefinitionRegistry`**, starts progress when not completed. - **`TryMarkTargetDefeated(…)`** — requires prior activation; accumulates defeats in any order; idempotent per NPC. - **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — set equality vs catalog **`requiredNpcInstanceIds`**. **Per-player HTTP read:** see [Per-player encounter progress (NEO-108)](#per-player-encounter-progress-neo-108). Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md). **Combat wiring (NEO-106):** see [Encounter combat wiring (NEO-106)](#encounter-combat-wiring-neo-106) — **`EncounterCombatWiring`** on **`POST …/ability-cast`** accept path. ## Encounter completion + inventory grants (NEO-105) **`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward table’s **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. On success, records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** and returns the same payload in **`EncounterCompletionResult`**. | Reason code | When | |-------------|------| | **`already_completed`** | Encounter already marked complete for this player. | | **`not_ready`** | Not all **`requiredNpcInstanceIds`** defeated yet. | | **`unknown_encounter`** | **`encounterId`** not in **`IEncounterDefinitionRegistry`**. | | **`unknown_reward_table`** | Encounter’s **`rewardTableId`** missing or empty grants. | | **`inventory_full`** | Pre-flight or apply could not place full grant quantities. | | **`inventory_store_missing`** | Player inventory store could not read/write. | Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** from **`prototype_combat_pocket_clear`**. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Wired from **`AbilityCastApi`** via **`EncounterCombatWiring`** (NEO-106). Plan: [NEO-105 implementation plan](../../docs/plans/NEO-105-implementation-plan.md). ## Encounter complete event record (NEO-107) **`IEncounterCompleteEventStore`** persists **`EncounterCompleteEvent`** exactly once per player+encounter when grants commit successfully. Prototype uses **`InMemoryEncounterCompleteEventStore`** (registered in **`AddEncounterAndRewardCatalogs`** alongside progress/completion stores). | Field | Role | |-------|------| | **`PlayerId`**, **`EncounterId`** | Normalized keys (same as progress/completion stores). | | **`RewardTableId`** | Catalog reward table applied. | | **`GrantedItems`** | Snapshot of successful fixed grants at commit time. | | **`CompletedAt`** | UTC timestamp from **`TimeProvider`** at completion mark. | | **`IdempotencyKey`** | Stable **`{playerId}:{encounterId}`** — future **`RewardDeliveryEvent`** dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md). | **E7.M2 hook (comment-only):** after **`TryRecord`**, **`EncounterCompletionOperations`** documents the future **`QuestRewardBundle`** / **`reward_delivery`** consumer — prototype quest credit is **`contract_handoff_token`** item loot plus this event record; full router deferred to E7.M2. If **`TryRecord`** returns `false` after a fresh completion mark (unexpected race), grants and completion flag are retained; treat as idempotent success. Plan: [NEO-107 implementation plan](../../docs/plans/NEO-107-implementation-plan.md). ## Per-player encounter progress (NEO-108) **`GET /game/players/{id}/encounter-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`encounters`**) projecting in-memory stores — no client-side defeat inference. Unknown players return **404** (same **`IPositionStateStore`** gate as gig/skill progression). Each row (catalog order via **`IEncounterDefinitionRegistry.GetDefinitionsInIdOrder()`**): | Field | Source | |-------|--------| | **`encounterId`** | Catalog encounter **`id`**. | | **`state`** | **`inactive`** (no progress), **`active`** (started, not completed), **`completed`**. | | **`defeatedTargetIds`** | **`IEncounterProgressStore`** defeated NPC ids (ordinal sort); `[]` when inactive. | | **`completedAt`** | **`IEncounterCompletionStore`** when **`completed`**; omitted otherwise. | | **`rewardGrantSummary`** | **`IEncounterCompleteEventStore.GrantedItems`** when **`completed`**; omitted otherwise. | Prototype: defeat all three E5.M2 NPCs via cast → row **`prototype_combat_pocket`** becomes **`completed`** with **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** in **`rewardGrantSummary`**. Plan: [NEO-108 implementation plan](../../docs/plans/NEO-108-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-progress/`. ```bash curl -sS -i "http://localhost:5253/game/players/dev-local-1/encounter-progress" ``` ## Encounter telemetry hooks (NEO-109) Comment-only hook sites for Epic 5 **Slice 3** catalog events ([epic_05 Slice 3](../../docs/decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3)). **`TODO(E9.M1)`** — no production ingest. **`POST …/ability-cast`** and **`EncounterCombatWiring`** delegate to the engines below (no duplicate API-layer hooks). | Event | Anchor | |-------|--------| | **`encounter_start`** | **`EncounterProgressOperations.TryActivateOnFirstEngagement`** — hook anchor after successful **`TryActivate`** (first engagement only). Activates NEO-84 reserved **`encounter_start`** pointer in **`CombatOperations`**. | | **`encounter_complete`** | **`EncounterCompletionOperations.TryCompleteAndGrant`** — once per successful grant + completion mark commit. | | **`reward_attribution`** | Same success path — once per completion (batch **`grantedItems`**); per-stack **`item_created`** remains in **`PlayerInventoryOperations`** ([NEO-56](#player-inventory-neo-54-store-neo-55-http)). | | **`encounter_complete_denied`** | **`EncounterCompletionOperations.Deny`** — structured deny + **`EncounterCompletionReasonCodes`**. | Plan: [NEO-109 implementation plan](../../docs/plans/NEO-109-implementation-plan.md). ## Encounter combat wiring (NEO-106) On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring.TryProcessCastOutcome`** (best-effort; does not change cast JSON): | Cast outcome | Encounter effect | |--------------|------------------| | **`damageDealt > 0`** vs a required NPC | **`EncounterProgressOperations.TryActivateOnFirstEngagement`** — starts **`prototype_combat_pocket`** progress (**`encounter_start`** hook site, [NEO-109](#encounter-telemetry-hooks-neo-109)). | | **`targetDefeated: true`** | **`TryMarkTargetDefeated`**; when all three E5.M2 NPC ids defeated, **`EncounterCompletionOperations.TryCompleteAndGrant`**. | | Inventory full on completion | Defeat progress retained; completion/grants fail closed (retry when bag has space). | **Call order on lethal accept:** aggro → cooldown → NPC runtime stop → **`CombatDefeatGigXpGrant`** (NEO-44) → **`EncounterCombatWiring`**. Gig XP per defeat is unchanged (**25** to **`breach`** each). Verify loot with **`GET …/inventory`** or **`GET …/encounter-progress`** (NEO-108). Plan: [NEO-106 implementation plan](../../docs/plans/NEO-106-implementation-plan.md). ## NPC behavior definitions (NEO-90) **`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions" ``` ## Ability definitions (NEO-78) **`GET /game/world/ability-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`abilities`**) backed by **`IAbilityDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`baseDamage`**, **`cooldownSeconds`**, and **`abilityKind`** when present in content. Plan: [NEO-78 implementation plan](../../docs/plans/NEO-78-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/ability-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/ability-definitions" ``` ## Combat entity health (NEO-80, NEO-91) Server-owned HP for prototype NPC combat instances lives in **`Game/Combat/`** as **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`**. Rows are keyed by **`PrototypeNpcRegistry`** instance ids (**`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`**) only. Max HP comes from each instance's bound **`NpcBehaviorDef.maxHp`** via **`INpcBehaviorDefinitionRegistry`** (melee **100**, ranged **80**, elite **200** — four **`prototype_pulse`** casts defeat melee). | Policy | Behavior | |--------|----------| | **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id; **`currentHp`** starts at catalog **`maxHp`**. | | **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Re-hit on defeated targets still applies at store layer (HP stays 0); structured deny is **NEO-81** (`CombatOperations`). | | **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives an instance for tests/fixtures — not exposed over HTTP in Slice 1. | | **HTTP read** | **`GET /game/world/combat-targets`** (NEO-83) — authoritative HP snapshot for client HUD; cast wiring is **NEO-82**. | Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md); instance registry migration: [NEO-91 implementation plan](../../docs/plans/NEO-91-implementation-plan.md); HTTP read: [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83). **Breaking change (NEO-91):** **`prototype_target_alpha`** / **`prototype_target_beta`** are removed. Godot markers and tab cycle must migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) (`client/scripts/prototype_target_constants.gd`). ## Combat targets snapshot (NEO-83, NEO-91) **`GET /game/world/combat-targets`** returns a versioned JSON body (`schemaVersion` **1**, **`targets`**) backed by **`ICombatEntityHealthStore`** — the same authoritative HP rows mutated by cast resolution (no client-side damage math). Each row includes **`targetId`**, **`maxHp`**, **`currentHp`**, and **`defeated`**. All three **`PrototypeNpcRegistry`** instances are always returned in ascending **`targetId`** order; lazy-init on first read matches NEO-80/NEO-91. Plan: [NEO-83 implementation plan](../../docs/plans/NEO-83-implementation-plan.md); [NEO-91 implementation plan](../../docs/plans/NEO-91-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-targets/`. | Field | Meaning | |-------|---------| | **`targetId`** | Lowercase NPC instance id (`prototype_npc_elite`, `prototype_npc_melee`, `prototype_npc_ranged`). | | **`maxHp`** | Catalog max from bound behavior def (200 / 100 / 80). | | **`currentHp`** | Authoritative HP after cast-applied damage; floored at zero. | | **`defeated`** | **`true`** when **`currentHp`** is **0**. | ```bash curl -sS -i "http://localhost:5253/game/world/combat-targets" ``` **Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), restores dev player combat HP to full (**NEO-95**), and clears in-memory encounter progress/completion/event rows for the dev player across catalog encounters (**NEO-108** Bruno). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js` (request scripts use `./scripts/…` relative to the collection). ## Threat / aggro state (NEO-92) Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`InMemoryThreatStateStore`**, keyed by **`PrototypeNpcRegistry`** instance id. Each row stores an optional **`AggroHolderPlayerId`** (lowercase route player id). | Rule | Behavior | |------|----------| | **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. | | **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **2 m**, ranged shot **10 m**, elite slam **4 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). | | **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. | | **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). | | **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. | | **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). | Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md). ## NPC runtime behavior state (NEO-93) Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** + **`InMemoryNpcRuntimeStateStore`**, keyed by **`PrototypeNpcRegistry`** instance id. **`NpcRuntimeOperations.AdvanceAll`** drives lazy tick advance (monotonic clock, per-call delta cap **5.0 s**) using catalog **`attackCooldownSeconds`** and **`telegraphWindupSeconds`**. | State | Meaning | |-------|---------| | **`idle`** | No aggro holder; no telegraph. | | **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. | | **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. | | **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies the behavior's **`attackAbilityId`** **`baseDamage`** when holder is within that ability's **`maxRange`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)). | | **`recover`** | Post-attack cooldown wait before next windup. | | Rule | Behavior | |------|----------| | **Holder sync** | Empty holder → **`idle`**; holder while **`idle`** → **`aggro`**. No proximity auto-aggro (first-hit holder from NEO-92 only). | | **Lazy advance** | **`AdvanceAll`** simulates up to **5.0 s** per call; invoked from **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). | | **Fixture reset** | Dev combat-target fixture clears runtime rows alongside HP + threat. | | **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — per-NPC **`state`**, optional nested **`activeTelegraph`** ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). | Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.md). ### NPC runtime telemetry hooks (NEO-96) Comment-only hook sites in **`NpcRuntimeOperations.AdvanceAll`** for future E9.M1 catalog events ([Epic 5 Slice 2](../../docs/decomposition/epics/epic_05_pve_combat.md#epic-5-slice-2)) — **`telegraph_fired`** at each **`telegraph_windup`** entry (aggro→telegraph and recover→telegraph); **`npc_state_transition`** on every committed state change during **`AdvanceAll`** (including holder clear → **`idle`**). Dev fixture **`ResetAllPrototypeRows`** writes idle directly and bypasses **`AdvanceOne`** hooks (optional E9.M1 anchor noted on reset path). **`TODO(E9.M1)`** — no production ingest. Snapshot GET invokes **`AdvanceAll`** before read (no duplicate API-layer hooks). Plan: [NEO-96 implementation plan](../../docs/plans/NEO-96-implementation-plan.md). ## NPC runtime snapshot (NEO-94) **`GET /game/world/npc-runtime-snapshot`** returns a versioned JSON body (`schemaVersion` **1**, **`serverTimeUtc`**, **`npcInstances`**) backed by **`INpcRuntimeStateStore`** + **`IThreatStateStore`** after **`NpcRuntimeOperations.AdvanceAll`** (lazy tick, **5.0 s** delta cap). Each row includes **`npcInstanceId`**, **`behaviorDefId`**, **`state`** (stable snake_case), **`aggroHolderPlayerId`**, and optional nested **`activeTelegraph`** during **`telegraph_windup`**. All three **`PrototypeNpcRegistry`** instances are always returned in ascending **`npcInstanceId`** order. Plan: [NEO-94 implementation plan](../../docs/plans/NEO-94-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-runtime-snapshot/`. | Field | Meaning | |-------|---------| | **`npcInstanceId`** | Lowercase prototype NPC instance id. | | **`behaviorDefId`** | Frozen behavior catalog id bound to the instance. | | **`state`** | **`idle`**, **`aggro`**, **`telegraph_windup`**, **`attack_execute`**, or **`recover`**. | | **`aggroHolderPlayerId`** | Lowercase player id from **`IThreatStateStore`**, or **`null`**. | | **`activeTelegraph`** | Non-null during **`telegraph_windup`**: **`telegraphId`**, **`npcInstanceId`**, **`windupRemainingSeconds`**, **`archetypeKind`**. | ```bash curl -sS -i "http://localhost:5253/game/world/npc-runtime-snapshot" ``` **Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, the behavior's **`attackAbilityId`** **`baseDamage`** applies to the aggro holder when within that ability's **`maxRange`** via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). ## Session player combat HP (NEO-95) Session player HP for incoming NPC damage lives in **`Game/Combat/`** as **`IPlayerCombatHealthStore`** + **`InMemoryPlayerCombatHealthStore`**. Rows are keyed by lowercase player id; lazy-init **`currentHp`** / **`maxHp`** **100** on first access. No Postgres in Slice 2. | Rule | Behavior | |------|----------| | **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. | | **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes, aggro holder still matches, and holder is within the attack ability's **`maxRange`** of the NPC anchor. | | **Holder re-check** | No damage when holder cleared during windup (leash clear). | | **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. | **`GET /game/players/{id}/combat-health`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`maxHp`**, **`currentHp`**, **`defeated`**) backed by **`IPlayerCombatHealthStore`**. **404** when the player has no position row (same gate as cooldown snapshot). Plan: [NEO-95 implementation plan](../../docs/plans/NEO-95-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-health/`. ```bash curl -sS -i "http://localhost:5253/game/players/dev-local-1/combat-health" ``` **Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) polls this GET alongside **`npc-runtime-snapshot`** for player HP + telegraph HUD. ## Combat engine (NEO-81) **`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept. | Reason code | When | |-------------|------| | **`unknown_ability`** | **`abilityId`** failed registry normalization (empty/garbage/off-list). | | **`unknown_target`** | **`targetId`** not in **`PrototypeNpcRegistry`** (health store gate). | | **`target_defeated`** | Target HP is already **0** before resolve; no damage applied (includes zero-damage utility). | **`CombatResult`** fields: **`success`**, **`reasonCode`** (`null` on success), **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**. On **`target_defeated`** deny, **`targetRemainingHp`** is **0** and **`targetDefeated`** on the envelope is **`false`** — use **`reasonCode`** for deny semantics. Plan: [NEO-81 implementation plan](../../docs/plans/NEO-81-implementation-plan.md); cast wiring: [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82). ### Combat telemetry hooks (NEO-84) Comment-only hook sites in **`CombatOperations.TryResolve`** for future E9.M1 catalog events ([Epic 5 Slice 1](../../docs/decomposition/epics/epic_05_pve_combat.md#epic-5-slice-1)). **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/ability-cast`** calls the engine only (no duplicate API-layer hooks for these names). - **`ability_used`** — on every successful resolve (zero-damage abilities included). - **`enemy_defeat`** — only when lethal damage drives **`after.Defeated`** to **`true`** (re-hit on defeated targets denies at **`target_defeated`** before damage). - **Reserved (comments only):** **`encounter_start`** — E5.M3 anchor: [Encounter telemetry hooks (NEO-109)](#encounter-telemetry-hooks-neo-109). - **`player_death`** — only when lethal NPC damage drives player **`currentHp`** to **0** (comment hook in NEO-95; full event deferred). E1.M4 cast funnel events **`ability_cast_requested`** / **`ability_cast_denied`** remain in **`AbilityCastApi`** ([NEO-30](#ability-cast-neo-31-neo-82)). E9.M1 ingest should correlate route **`playerId`** with engine payload fields at accept. Plan: [NEO-84 implementation plan](../../docs/plans/NEO-84-implementation-plan.md). ## Recipe definitions (NEO-68) **`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/recipe-definitions" ``` ## Craft engine (NEO-69) and craft HTTP (NEO-70) **`CraftOperations.TryCraft`** in **`Game/Crafting/`** resolves server-internal **`CraftResult`**: recipe lookup via **`IRecipeDefinitionRegistry`**, read-only pre-flight for inputs and output capacity (simulated adds on a snapshot clone), input removal and output grant via **`PlayerInventoryOperations`**, then **`refine`** skill XP via **`SkillProgressionGrantOperations`** + **`RefineSkillXpConstants`** (same path as **`RefineActivitySkillXpGrant`**). On output-grant or XP failure after inputs were removed, the engine **rolls back** inventory before denying. **NEO-70** exposes **`POST /game/players/{id}/craft`** with wire **`CraftResponse`** (promoted **`CraftResult`** fields). Client craft UI is **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**. | Reason code | When | |-------------|------| | **`unknown_recipe`** | **`recipeId`** not in **`IRecipeDefinitionRegistry`**. | | **`insufficient_materials`** | Pre-flight input totals below scaled recipe cost. | | **`inventory_full`** | Pre-flight output simulation could not place full scaled yield (NEO-54 all-or-nothing). | | **`invalid_quantity`** | Craft batch **`quantity`** is **≤ 0**. | | **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory rollback). | | **`inventory_store_missing`** | Player inventory store could not read or write. | **POST** body (**`schemaVersion` 1**): **`recipeId`**, optional **`quantity`** (defaults to **1** when omitted). Wire request type: **`PlayerCraftRequest`**. Response: **`success`**, **`reasonCode`** (`null` on success), **`inputsConsumed`**, **`outputsGranted`**, **`xpGrantSummary`** on success. **400** on schema mismatch or empty **`recipeId`**; **404** on unknown player or store-missing codes; **200** with **`success: false`** + stable deny codes on rule failure. ```bash curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/craft" \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"recipeId":"refine_scrap_standard"}' ``` Sample **200** success body (after seeding **5×** **`scrap_metal_bulk`** via gather or inventory POST): ```json {"schemaVersion":1,"success":true,"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"activity"}} ``` Plan: [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); [NEO-70 implementation plan](../../docs/plans/NEO-70-implementation-plan.md); [NEO-71 implementation plan](../../docs/plans/NEO-71-implementation-plan.md). Bruno: `bruno/neon-sprawl-server/craft/`. ### Craft telemetry hooks (NEO-71) Comment-only hook sites in **`CraftOperations.TryCraft`** for future E9.M1 catalog events. **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/craft`** calls the engine only (no duplicate API-layer hooks). - **`item_crafted`** — on every successful craft (inputs removed, outputs granted, refine XP applied). - **`craft_failed`** — with stable **`reasonCode`** on every structured deny via private **`Deny`** (all **`CraftReasonCodes`**, including store-missing and post-mutation rollback paths). Successful craft output grants also pass through **`PlayerInventoryOperations`** (**`item_created`** hook, [NEO-56](#player-inventory-neo-54-store-neo-55-http)). Epic 3 Slice 3 metric **`time-to-first-craft`** is derived at E9.M1 from **`item_crafted`** + session context (no separate prototype hook). Plan: [NEO-71 implementation plan](../../docs/plans/NEO-71-implementation-plan.md). ## Resource node definitions (NEO-60) **`GET /game/world/resource-node-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`gatherLens`**, **`maxGathers`**, and nested **`yield`** (`itemId`, `quantity`). Plan: [NEO-60 implementation plan](../../docs/plans/NEO-60-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-60.md`](../../docs/manual-qa/NEO-60.md); Bruno: `bruno/neon-sprawl-server/resource-node-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/resource-node-definitions" ``` ## Resource node instance depletion (NEO-61) World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gathering/`** as **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`**. Capacity is **lazy-initialized** from catalog **`maxGathers`** on first successful-gather commit; prototype wiring uses **`interactableId == nodeDefId`**. **`TryCommitSuccessfulGatherDecrement`** atomically decrements and returns stable deny codes: | Reason code | When | |-------------|------| | **`node_depleted`** | Row exists and **`remainingGathers`** is already **0** (decrement rejected). | | **`unknown_node`** | **`interactableId`** not found in **`IResourceNodeDefinitionRegistry`**. | **Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as inventory — **`resource_node_instance`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V006__resource_node_instance.sql`](../db/migrations/V006__resource_node_instance.sql)), otherwise in-memory fallback (empty at startup). Interact deny wiring: [NEO-63](#gather-via-interact-neo-63); plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md). ## Gather engine (NEO-62) **`GatherOperations.TryGather`** in **`Game/Gathering/`** resolves server-internal **`GatherResult`**: capacity pre-check (may **initialize** a depletion row without decrementing), inventory grant via **`PlayerInventoryOperations`**, **`salvage`** skill XP via **`SkillProgressionGrantOperations`** + **`GatherSkillXpConstants`**, then depletion commit via **`ResourceNodeInstanceOperations`** (decrement **last**, only when inventory + XP succeed). On XP failure after inventory add, the engine **rolls back** the item grant before denying. | Reason code | When | |-------------|------| | **`unknown_node`** | **`interactableId`** not in **`IResourceNodeDefinitionRegistry`** or missing yield row. | | **`node_depleted`** | **`remainingGathers`** is **0** (or decrement rejected). | | **`inventory_full`** | Full yield quantity could not be placed (NEO-54 all-or-nothing). | | **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory remove). | | **`inventory_store_missing`** | Player inventory store could not write. | **Interact wiring** (replace NEO-41 inline XP, map denies to **`InteractionResponse`**) is **[NEO-63](#gather-via-interact-neo-63)**; plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md). ### Gather telemetry hooks (NEO-64) Comment-only hook sites in **`GatherOperations.TryGather`** for future E9.M1 catalog events — **`resource_gathered`** on every successful gather (inventory + XP + depletion committed); **`gather_node_depleted`** when that success drives **`remainingGathers`** to **0** (not on pre-gather **`node_depleted`** deny). **`TODO(E9.M1)`** — no production ingest. Successful gathers also pass through **`PlayerInventoryOperations`** (**`item_created`** hook, [NEO-56](#player-inventory-neo-54-store-neo-55-http)). HTTP **`POST …/interact`** calls the engine only (no duplicate API-layer hooks). Manual QA: [`docs/manual-qa/NEO-64.md`](../../docs/manual-qa/NEO-64.md); plan: [NEO-64 implementation plan](../../docs/plans/NEO-64-implementation-plan.md). ## Item definitions (NEO-53) **`GET /game/world/item-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-53 implementation plan](../../docs/plans/NEO-53-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-53.md`](../../docs/manual-qa/NEO-53.md); Bruno: `bruno/neon-sprawl-server/item-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/item-definitions" ``` ## Player inventory (NEO-54 store, NEO-55 HTTP) Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`**. **NEO-55** exposes versioned **`GET` / `POST /game/players/{id}/inventory`** for manual QA and future client. Plan: [NEO-55 implementation plan](../../docs/plans/NEO-55-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/inventory/`. | Container | Fixed slots | Accepts `inventorySlotKind` | |-----------|-------------|-------------------------------| | **Bag** | **24** (indices 0–23) | `bag` | | **Equipment** | **1** (index 0) | `equipment` | **GET** returns **`schemaVersion` 1**, **`playerId`**, **`bagSlots`** (length 24), **`equipmentSlots`** (length 1). Empty slots use **`quantity: 0`** with **`itemId`** omitted. **404** when the player is unknown (position gate) or missing from the inventory store. **POST** body (**`schemaVersion` 1**): **`mutationKind`** **`add`** or **`remove`**, **`itemId`**, **`quantity`**. Response: **`applied`**, **`reasonCode`** (`null` on success), **`inventory`** snapshot echo. **400** on schema mismatch or unknown **`mutationKind`**; **404** on unknown player; **200** with **`applied: false`** + stable deny codes on rule failure. ```bash curl -sS "http://localhost:5253/game/players/dev-local-1/inventory" curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/inventory" \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"mutationKind":"add","itemId":"scrap_metal_bulk","quantity":5}' ``` **Engine rules (NEO-54):** **`TryAddStack`** and **`TryRemoveStack`** route items by catalog metadata, merge stacks up to **`ItemDef.stackMax`**, and use **all-or-nothing** adds (no partial silent loss). Stable deny **`reasonCode`** values: | Code | Meaning | |------|---------| | **`inventory_full`** | Full requested quantity could not be placed | | **`invalid_item`** | Unknown `itemId` (not in catalog) | | **`insufficient_quantity`** | Remove requested more than held | | **`invalid_quantity`** | Non-positive quantity | **Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as skill progression — **`player_inventory`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V005__player_inventory.sql`](../db/migrations/V005__player_inventory.sql), sparse occupied-slot rows), otherwise in-memory fallback seeding the configured dev player. Store/engine plan: [NEO-54 implementation plan](../../docs/plans/NEO-54-implementation-plan.md). **NEO-56 telemetry hooks:** comment-only hook sites in **`PlayerInventoryOperations`** for future E9.M1 catalog events — **`item_created`** on successful **`TryAddStack`** (`Applied`); **`inventory_transfer_denied`** + stable **`reasonCode`** on every rule **`Denied`** path (early validation via **`Deny`** and post-mutation denies such as **`inventory_full`** / **`insufficient_quantity`**). **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/inventory`** calls the engine only (no duplicate API-layer hooks). Manual QA: [`docs/manual-qa/NEO-56.md`](../../docs/manual-qa/NEO-56.md); plan: [NEO-56 implementation plan](../../docs/plans/NEO-56-implementation-plan.md). ## Mastery catalog (`content/mastery`, NEO-46) After the skill catalog loads, the host loads every **`*_mastery.json`** under the mastery directory, validates each file against **`content/schemas/mastery-catalog.schema.json`**, cross-checks track **`skillId`** values against **`ISkillDefinitionRegistry`**, and enforces the same post-schema gates as **`scripts/validate_content.py`** (unknown perk references, duplicate perk ids, branch-set equality tier-to-tier, unreferenced perks, prototype **Slice 4** single **`salvage`** track). The server also requires **`tierIndex`** values per track to be **unique and sequential 1..N** (stricter than CI today). Invalid data **exits during startup**—no silent fallback. | Config | Meaning | |--------|---------| | **`Content:MasteryDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** for **`content/mastery`**. | | **`Content:MasteryCatalogSchemaPath`** | Optional override for **`mastery-catalog.schema.json`**. When unset, **`{parent of mastery directory}/schemas/mastery-catalog.schema.json`**. | **Docker / CI:** include **`content/mastery`** and **`content/schemas/mastery-catalog.schema.json`** in the mounted **`content/`** tree; set **`Content__MasteryDirectory`** when layout differs. On success, **Information** logs include the resolved mastery directory, track count, and perk count. Game code should use **`IMasteryCatalogRegistry`** for lookups (NEO-47+). ## Perk unlock engine and telemetry hooks (NEO-47, NEO-49) **`PerkUnlockEngine`** (`Game/Mastery/`) is the authoritative server path for branch picks and perk unlocks: **`TrySelectBranch`**, **`ReevaluateAfterLevelUp`** (called from **`SkillProgressionGrantOperations`** on skill level-up). Persistence: **`IPlayerPerkStateStore`** + Flyway **`V004`** ([NEO-47 plan](../../docs/plans/NEO-47-implementation-plan.md)). **NEO-49:** comment-only **telemetry hook site** for future catalog event **`perk_unlock`** in **`PerkUnlockEngine.TryUnlockPerks`** when new perk ids are persisted (one emit per unlocked perk; **`TODO(E9.M1)`** — no production ingest). Manual QA: [`docs/manual-qa/NEO-49.md`](../../docs/manual-qa/NEO-49.md); plan: [NEO-49 implementation plan](../../docs/plans/NEO-49-implementation-plan.md). ## Perk state (NEO-48) **`GET /game/players/{id}/perk-state`** returns versioned JSON (`schemaVersion` **1**, **`playerId`**, **`branchPicks`**, **`unlockedPerkIds`**). **`branchPicks`** is an array of `{ skillId, picks: [{ tierIndex, branchId }] }` — key by **`skillId`**; array order is not part of the contract. **`unlockedPerkIds`** lists perk ids currently unlocked for the player. **`POST /game/players/{id}/perk-state`** commits one mastery branch pick (`schemaVersion` **1**, **`skillId`**, **`tierIndex`**, **`branchId`**). Delegates to **`PerkUnlockEngine.TrySelectBranch`** (NEO-47). **404** when the player is unknown to position state; **400** when **`schemaVersion`** is missing or not **1**. Persistence uses **`IPlayerPerkStateStore`** + Flyway **`V004`** when Postgres is configured (same [NEO-29-style](#position-persistence-neo-8) split as skill progression). **Structured responses (HTTP 200):** **`selected`** (`true`/`false`), optional **`reasonCode`** on deny, authoritative **`perkState`** (same shape as GET), and **`unlockedEvents`** on success (empty on deny). Stable deny codes (see `PerkUnlockReasonCodes`): **`unknown_track`**, **`unknown_tier`**, **`unknown_branch`**, **`level_too_low`**, **`branch_already_chosen`**, **`tier_branch_not_selectable`**, **`player_not_found`**. On **`selected: true`**, **`unlockedEvents`** lists perks newly unlocked on this request (`perkId`, `skillId`, `tierIndex`, `branchId`, **`source`**: **`branch_pick`** | **`level_up`**). Tier-1 prototype salvage picks may emit zero events; path-auto tier-2 unlocks at level 4+ may emit **`level_up`** events on the same POST when level gates are already met. **Dev mastery fixture (NEO-48, Bruno/manual QA):** When `Game:EnableMasteryFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/mastery-fixture`** accepts `schemaVersion` **1**, optional **`resetPerkState`** (clear branch picks + unlocked perks), and optional **`skillXp`** map (**absolute** XP per `skillId`; omitted skills unchanged). Fixture writes set totals **directly** — they do **not** run **`SkillProgressionGrantOperations`** or perk **level-up reevaluation**; use normal **`POST …/skill-progression`** when testing unlock side effects. Apply order: batch **`skillXp`** first, then **`resetPerkState`** (XP rolls back if reset fails). **400** for bad schema or invalid **`skillXp`**; **404** when disabled, player unknown, or store write fails. Not for production. Plan: [NEO-48 implementation plan](../../docs/plans/NEO-48-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-48.md`](../../docs/manual-qa/NEO-48.md); Bruno: `bruno/neon-sprawl-server/perk-state/`. ```bash curl -sS -i "http://localhost:5253/game/players/dev-local-1/perk-state" curl -sS -i -X POST "http://localhost:5253/game/players/dev-local-1/perk-state" \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"skillId":"salvage","tierIndex":1,"branchId":"scrap_efficiency"}' ``` ## Skill definitions (NEO-36) **`GET /game/world/skill-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`skills`**) backed by **`ISkillDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-36 implementation plan](../../docs/plans/NEO-36-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-36.md`](../../docs/manual-qa/NEO-36.md); Bruno: `bruno/neon-sprawl-server/skill-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/skill-definitions" ``` ## Skill progression snapshot (NEO-37) **`GET /game/players/{id}/skill-progression`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`skills`**) with one row per registered skill (**`id`**, **`xp`**, **`level`**). Treat **`skills`** as **unordered** and index rows by **`id`** — array order is not part of the contract (the server may sort for stable serialization). Levels are derived from the startup-loaded content curve at **`content/skills/prototype_level_curve.json`** (validated by **`content/schemas/level-curve.schema.json`** and CI **`scripts/validate_content.py`**). Unknown players (**no row in position state**) receive **404**, same gate as **`GET …/cooldown-snapshot`**. Plan: [NEO-37 implementation plan](../../docs/plans/NEO-37-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-37.md`](../../docs/manual-qa/NEO-37.md); Bruno: `bruno/neon-sprawl-server/skill-progression/Get skill progression.bru`. ```bash curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression" ``` ## Skill progression grant (NEO-38) **`POST /game/players/{id}/skill-progression`** accepts a versioned body (`schemaVersion` **1**, **`skillId`**, **`amount`** positive int, **`sourceKind`**) and validates the skill against **`ISkillDefinitionRegistry`**, then validates **`sourceKind`** against that skill’s **`allowedXpSourceKinds`** (case-insensitive). **404** when the player is unknown to position state; **400** when **`schemaVersion`** is missing or not **1**. Persisted XP totals use the same **[NEO-29-style](#position-persistence-neo-8) split** as hotbar: **`player_skill_progression`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V003__player_skill_progression.sql`](../db/migrations/V003__player_skill_progression.sql)), otherwise the in-memory fallback (dev bucket only, matching hotbar semantics). **Combat encounters do not grant skill XP by default** — prototype defeat awards **gig XP** on the main gig via **[NEO-44](#gig-progression-snapshot-neo-44)** ([`docs/game-design/progression.md`](../docs/game-design/progression.md)). Gather/craft use **`activity`**; mission payouts use **`mission_reward`** ([NEO-43](#mission--quest-skill-xp-neo-43)). **Structured responses (HTTP 200):** **`granted`** (`true`/`false`), optional **`reasonCode`** on deny, plus **`progression`** mirroring the GET snapshot. Stable deny codes: **`unknown_skill`**, **`source_kind_not_allowed`**, **`invalid_amount`**. On **`granted: true`**, **`levelUps`** lists skills whose level increased on this request (`skillId`, `previousLevel`, `newLevel`). If a single grant crosses **multiple** level boundaries (e.g. a large XP jump under the placeholder curve), the server emits **one** object per skill with **`previousLevel`** and **`newLevel`** set to the **start and end** levels after the grant — not one array entry per boundary crossed. A future issue can add per-step events if product needs them. Plan: [NEO-38 implementation plan](../../docs/plans/NEO-38-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-38.md`](../../docs/manual-qa/NEO-38.md); Bruno: `bruno/neon-sprawl-server/skill-progression/` (happy **POST** + deny smoke). ```bash curl -sS -i -X POST "http://localhost:5253/game/players/dev-local-1/skill-progression" \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"skillId":"salvage","amount":10,"sourceKind":"activity"}' ``` ## Gig progression snapshot (NEO-44) **`GET /game/players/{id}/gig-progression`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`mainGigId`**, **`gigs`**) with prototype **`mainGigId`** **`breach`** and one row (**`id`**, **`xp`**, **`level`**). **`level`** is fixed at **1** until gig level-curve content exists; **`xp`** accumulates from combat defeat grants only in this slice. Unknown players receive **404** (same gate as skill progression GET). **Combat defeat → gig XP:** when **`POST …/ability-cast`** accepts with **`combatResolution.targetDefeated: true`**, the server best-effort applies **25** gig XP to **`breach`** via **`CombatDefeatGigXpGrant`** — **not** **`SkillProgressionGrantOperations`**. Re-hits on a defeated target (`target_defeated` deny) grant no additional gig XP. Persisted totals use the NEO-29-style split: **`player_gig_progression`** in PostgreSQL when configured (DDL [`V007__player_gig_progression.sql`](../db/migrations/V007__player_gig_progression.sql)), otherwise in-memory. Plan: [NEO-44 implementation plan](../../docs/plans/NEO-44-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-44.md`](../../docs/manual-qa/NEO-44.md); Bruno: `bruno/neon-sprawl-server/gig-progression/`. ```bash curl -sS -i "http://localhost:5253/game/players/dev-local-1/gig-progression" ``` ## Position persistence (NEO-8) When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request). If **`ConnectionStrings:NeonSprawl`** is missing or blank, behavior matches NEO-6/NEO-7 (**in-memory** only). The **test** project’s `postgres.runsettings` sets `ConnectionStrings__NeonSprawl` for the test host so Postgres integration tests run in CI/Rider/`dotnet test`; non-Postgres tests use `InMemoryWebApplicationFactory` and do not require a live DB. Remove or rename `postgres.runsettings` (and the `RunSettingsFilePath` property) if you want those Postgres integration tests to **skip** again when the variable is unset. **Environment variables** (typical — same mapping as other ASP.NET Core config): | Mechanism | Example | |-----------|---------| | Shell / CI | `export ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev'` | | User secrets | `dotnet user-secrets set ConnectionStrings:NeonSprawl ""` (run from `server/NeonSprawl.Server`) | Match credentials with [root `docker-compose.yml`](../../docker-compose.yml) for local development. **Restart check (acceptance criteria):** start Postgres (`docker compose up -d` from repo root), set the connection string, `dotnet run`, `POST …/move`, stop the server, `dotnet run` again, `GET …/position` should return the last committed state. **Postgres integration tests** require `ConnectionStrings__NeonSprawl` (set automatically in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml)). The test project sets this via **`NeonSprawl.Server.Tests/postgres.runsettings`** (`RunSettingsFilePath` in the `.csproj`) so **`dotnet test`** and **Rider** pick it up without shell `export`. **`launchSettings.json`** in the same project is for IDE launch profiles and is **not** the xUnit “config file” in Rider settings. **Contributors / PRs:** With **`postgres.runsettings`** checked in, **`dotnet test`** always injects that connection string, so the **four Postgres integration tests run** (they are **not** skipped). If nothing is listening on Postgres and **Docker** cannot start Compose (or you have no DB), those tests **fail**—that is expected, not a random flake. **Mitigations:** `docker compose up -d` from the repo root (tests may auto-start Compose locally when not in CI), or temporarily remove **`RunSettingsFilePath`** from `NeonSprawl.Server.Tests.csproj` while hacking. **GitHub Actions** provides Postgres in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml), so merges stay green when the workflow runs. **Rider:** Under **Settings → … → Unit Testing → xUnit.net**, leave **“Use specific configuration file”** off unless you add a real **`xunit.runner.json`**. Do **not** point that field at `launchSettings.json`. **Local Docker (optional automation):** When **not** in CI (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `TF_BUILD`), the Postgres test harness tries to connect first. If the server is unreachable, it runs **`docker compose up -d`** from the repo root (same compose file as [root `docker-compose.yml`](../../docker-compose.yml)), waits for the DB, runs tests, then runs **`docker compose down`** **only if** it started Compose itself. If Postgres was already listening (existing container or native install), tests do **not** stop your database afterward. Initialization is **async** (no sync-over-async on the xUnit sync context) so **Rider** / **Visual Studio** do not leave Postgres tests stuck **Pending** after Compose starts. ## Position state (NEO-6) Authoritative player position is served over HTTP whether the backing store is **in memory** or **PostgreSQL** (NEO-8). The HTTP JSON shape is versioned with top-level `schemaVersion` (see XML docs on `PositionStateResponse` in the server project). Path `{id}` is **trimmed** and matched **case-insensitively** against stored players; `playerId` in the JSON body echoes the path segment from the request. Stored player ids use **invariant lowercase + trim** in Postgres for lookup parity. Example (dev player id defaults to `dev-local-1` in `appsettings.json`): ```bash curl -s http://localhost:5253/game/players/dev-local-1/position ``` Sample response (default spawn matches Godot capsule and NEO-9 walk-in range demo): ```json {"schemaVersion":1,"playerId":"dev-local-1","position":{"x":-5,"y":0.9,"z":-5},"sequence":0} ``` Unknown player ids return **404**. Full zone sync / replication is still out of scope for these spikes. ## Move command (NEO-7, NEO-10) **`POST /game/players/{id}/move`** with JSON body applies a v1 **MoveCommand**: authoritative position **snaps** to `target` immediately; `sequence` in responses increases by one per successful apply. **`POST /game/players/{id}/move-stream` (NEO-22):** JSON body **`MoveStreamRequest`** (`schemaVersion` **1**, **`targets`**: ordered array of world positions, max **24** per request). The server **validates the entire chain** against **`Game:MovementValidation`** (same rules as a single move) **before** applying anything, then applies each target in order; **`sequence`** increases by **one per applied target**. Response body matches **`PositionStateResponse`**. On the first failing leg the handler returns **400** with **`MoveCommandRejectedResponse`** and **does not** change stored position. **Client navigation (NEO-11):** The Godot prototype uses a **baked navigation mesh** for **local** vertical routing / step assist when applicable; **WASD** sends **`move-stream`** samples and the server validates **straight-line** legs (NEO-10) from the **last authoritative position** to each **requested** position. The server does **not** simulate navmesh. Cheating or divergent paths are out of scope for this slice. **NEO-10 validation (reject-only):** Before applying, the server checks the move against **`Game:MovementValidation`**. Limits use **horizontal** distance on **X/Z** only and **|ΔY|** separately (see `MoveCommandValidation`). Unknown players return **404** before validation. | Config key | Meaning | Default (see `appsettings.json`) | |------------|---------|-----------------------------------| | `Game:MovementValidation:MaxHorizontalStep` | Max XZ displacement per command (m); inclusive at limit | `18` | | `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `2.2` | | `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` | | `Game:MovementValidation:DistrictMinX` … `DistrictMaxZ` | District extents when enabled | ±12 / Y −2…24 | **Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**8–14 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so **`move-stream`** from the floor into the pedestal still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so streaming from it toward the floor is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled. Request body (example): ```bash curl -s -X POST http://localhost:5253/game/players/dev-local-1/move \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"target":{"x":1.5,"y":0,"z":-2}}' ``` - **200** — body matches **`PositionStateResponse`** (same shape as `GET` … `/position`). - **400** — malformed command (missing/invalid body or wrong `schemaVersion`) **or** move rejected by NEO-10 rules. Rejection responses are JSON **`MoveCommandRejectedResponse`**: `schemaVersion` (`1`) + **`reasonCode`** (`horizontal_step_exceeded`, `vertical_step_exceeded`, `out_of_bounds`). Malformed requests return **400** with **no** rejection body. - **404** — unknown player id. See XML on `MoveCommandRequest`, `PositionStateResponse`, and `MoveCommandRejectedResponse` in the server project. For a **PR-ready** contract blurb (formatted JSON + field table), see the [NEO-6 implementation plan — Pull request description](../../docs/plans/NEO-6-implementation-plan.md#pull-request-description-linear-ac) (GET shape) and [NEO-7 implementation plan](../../docs/plans/NEO-7-implementation-plan.md#pull-request-description-linear-ac--draft-for-merge) (MoveCommand + sequence semantics). NEO-10: [NEO-10 implementation plan](../../docs/plans/NEO-10-implementation-plan.md). ## Interaction (NEO-9) **`POST /game/players/{id}/interact`** with a versioned **InteractionRequest** body asks whether the given player may use a prototype interactable **now**. The server reads **authoritative** `PositionState` from `IPositionStateStore` (same id rules as position APIs: trim + ordinal case-insensitive match). **Horizontal reach** uses **X and Z only**; **Y is ignored** for distance (floor-plane prototype). See `HorizontalReach` in `NeonSprawl.Server/Game/World/` and `PrototypeInteractableRegistry` in `Game/Interaction/`. Request (example): ```bash curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"interactableId":"prototype_terminal"}' ``` **HTTP** | Status | Meaning | |--------|---------| | **200** | Body is **InteractionResponse** v1: `allowed: true` (optional `payload`, `interactableId` echo) or `allowed: false` with required **`reasonCode`**. | | **400** | Not a valid v1 attempt (bad/missing `schemaVersion`, malformed JSON, missing **`interactableId`**, or empty after trim). | | **404** | Unknown **player** only (no row in the position store). | **`reasonCode` (v1, when `allowed` is false)** | Code | When | |------|------| | `out_of_range` | Horizontal distance on X/Z is **greater than** the interactable’s `interactionRadius` (on the radius is **allowed** — inclusive `<=`). | | `unknown_interactable` | Id not in the prototype registry (after trim + case-insensitive lookup). | | `node_depleted` | **`resource_node`** gather: instance capacity exhausted ([NEO-63](#gather-via-interact-neo-63)). | | `inventory_full` | **`resource_node`** gather: yield could not fit in bag (all-or-nothing; [NEO-54](#player-inventory-neo-54-store-neo-55-http)). | | `unknown_node` | **`resource_node`** gather: registry miss (defensive; should not occur for registered interactables). | | `progression_store_missing` | **`resource_node`** gather: skill progression store could not apply XP. | When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**. ### Gather via interact (NEO-63) When the interactable’s **`kind`** is **`resource_node`**, **`POST …/interact`** delegates to **`GatherOperations.TryGather`** ([NEO-62](#gather-engine-neo-62)): inventory grant, **`salvage`** skill XP (`activity`, 10 XP), and depletion commit. On success **`allowed: true`**; gather denies map to **`allowed: false`** + stable **`reasonCode`** (including **`node_depleted`**, **`inventory_full`**). **Four** prototype nodes are registered in **`PrototypeInteractableRegistry`** (alpha **(12, −6)**, beta **(18, −6)**, gamma **(12, 0)**, delta **(18, 0)** on X/Z, radius **3**). Verify with **`GET …/inventory`** and **`GET …/skill-progression`**. Plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-63.md`](../../docs/manual-qa/NEO-63.md); Bruno: `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`, `Post interact gather then get inventory.bru`, `Post interact depleted node deny.bru`. **Historical:** [NEO-41](#gather-via-interact-neo-63) previously granted XP only on allowed interact without inventory or depletion; superseded by NEO-63. ### Craft / refine hook → skill XP (NEO-42) **E3.M2** craft/refine success paths call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`RefineSkillXpConstants`** (**10** **`refine`** XP, **`sourceKind: activity`**) — the same stack as **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** and **`POST …/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)). **`CraftOperations.TryCraft`** ([NEO-69](#craft-engine-neo-69-and-craft-http-neo-70)) owns the live call site; craft HTTP is **`POST …/craft`** ([NEO-70](#craft-engine-neo-69-and-craft-http-neo-70)). Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); [NEO-70 implementation plan](../../docs/plans/NEO-70-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md). ### Mission / quest reward → skill XP (NEO-43) **E7.M2** quest/mission payout paths should call **`MissionRewardSkillXpGrant.GrantFromMissionReward`** with content-driven **`skillId`** and **`amount`**; the helper always uses **`sourceKind: mission_reward`** through **`SkillProgressionGrantOperations.TryApplyGrant`** (same validation, **`allowedXpSourceKinds`**, and persistence as **[NEO-38](#skill-progression-grant-neo-38)**). **Combat encounter** rewards must **not** use this path for default clears — gig XP stays separate ([`docs/game-design/progression.md`](../docs/game-design/progression.md)). Until the router exists, verify **`mission_reward`** with **`POST …/skill-progression`**. Plan: [NEO-43 implementation plan](../../docs/plans/NEO-43-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-43.md`](../../docs/manual-qa/NEO-43.md). Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md). ## Interactable descriptors (NEO-25) **`GET /game/world/interactables`** returns a versioned **projection** of the prototype **`PrototypeInteractableRegistry`** (no player id; global list). Use this for client discovery of anchors, radii, and **`kind`** instead of duplicating registry constants in GDScript. Example: ```bash curl -s http://localhost:5253/game/world/interactables ``` **HTTP** | Status | Meaning | |--------|---------| | **200** | Body is **`InteractablesListResponse`** v1: `schemaVersion` (`1`) + **`interactables`** array. | **`interactables[]` fields (v1)** | Field | Meaning | |--------|---------| | `interactableId` | Lowercase canonical id (matches **`POST …/interact`**). | | `kind` | Stable machine string (`terminal`, `resource_node`, …). | | `anchor` | Object with **`x`**, **`y`**, **`z`** (world meters). | | `interactionRadius` | Horizontal reach on X/Z; inclusive boundary (same rule as NEO-9). | Rows are sorted by ascending **`interactableId`**. Plan: [NEO-25 implementation plan](../../docs/plans/NEO-25-implementation-plan.md). ## Targeting (NEO-23) Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9. **Soft lock:** the server keeps the persisted **`lockedTargetId`** until clear or a successful swap. If the player moves out of stub range, **`validity`** becomes **`out_of_range`** on **`GET`** and on **`POST`** echoes until they move back in range, clear, or select another valid target (see [NEO-23 implementation plan](../../docs/plans/NEO-23-implementation-plan.md)). Stub registry: **`PrototypeNpcRegistry`** in `NeonSprawl.Server/Game/Npc/` — ids **`prototype_npc_elite`**, **`prototype_npc_melee`**, **`prototype_npc_ranged`** (ascending id order for tab cycle; client mirror in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)). **`GET /game/players/{id}/target`** | Field | Meaning | |--------|--------| | `schemaVersion` | `1` | | `playerId` | Echo of route `{id}` | | `lockedTargetId` | Lowercase stub id when locked; JSON **`null`** when no lock (**key always present**) | | `validity` | `none` (no lock), `ok`, `out_of_range`, or `invalid_target` (unknown id in store — should not occur in normal use) | | `sequence` | Increments when the persisted lock **id** changes (clear or swap); unchanged on idempotent re-select of the same id | **`POST /game/players/{id}/target/select`** — body `schemaVersion` (`1`), optional **`targetId`**. Omit **`targetId`** or send JSON **`null`** to **clear** the lock. Non-null **`targetId`** is trimmed; **whitespace-only** after trim → **400**. Examples: ```bash curl -s http://localhost:5253/game/players/dev-local-1/target ``` ```bash curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"targetId":"prototype_npc_melee"}' ``` **HTTP** | Status | Meaning | |--------|---------| | **200** | **GET:** `PlayerTargetStateResponse` v1. **POST:** `TargetSelectResponse` v1 with **`targetState`** always set; **`selectionApplied`** `true` or `false`; when `false`, required **`reasonCode`**. | | **400** | Invalid v1 body (wrong `schemaVersion`, malformed JSON, or **`targetId`** whitespace-only when provided). | | **404** | Unknown **player** only (same as interact). | **`reasonCode` (POST v1, when `selectionApplied` is `false`)** | Code | When | |------|------| | `unknown_target` | Id not in the prototype target registry (after trim + case-insensitive lookup). | | `out_of_range` | Horizontal distance on X/Z is **greater than** the target’s `lockRadius` (on the radius is **allowed** — inclusive `<=`). | When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). ## Hotbar loadout (NEO-29) Prototype server-owned hotbar bindings are available at: - **`GET /game/players/{id}/hotbar-loadout`** → returns `HotbarLoadoutResponse` v1 with fixed `slotCount` **8** and `slots` array entries (`slotIndex`, nullable `abilityId`) for every slot. - **`POST /game/players/{id}/hotbar-loadout`** with `HotbarLoadoutUpdateRequest` v1 (`schemaVersion`, `slots[]`) upserts one or more slot bindings and returns `HotbarLoadoutUpdateResponse` v1. **Prototype policy (kickoff decision):** - Scope is **per player id** (same keying strategy as `PositionState` / `TargetState`). - Persistence mirrors NEO-8/NS-17: **Postgres when `ConnectionStrings:NeonSprawl` exists**, otherwise **in-memory fallback**. **Validation deny reason codes (POST 200 with `updated=false`):** | Code | Meaning | |------|---------| | `slot_out_of_bounds` | `slotIndex` is outside v1 range `0..7`. | | `unknown_ability` | `abilityId` is empty/whitespace or not in the prototype allowlist. | | `duplicate_slot` | Request repeats the same `slotIndex` more than once. | Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`. ## Ability cast (NEO-31, NEO-82) Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**. - **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeNpcRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**. **Accept payload (`combatResolution`, present only when `accepted: true`):** | Field | Meaning | |-------|---------| | `abilityId` | Normalized ability id (matches request/binding). | | `targetId` | Normalized prototype target id (lowercase registry key). | | `damageDealt` | Catalog **`baseDamage`** applied (0 for utility abilities). | | `targetRemainingHp` | Authoritative HP after resolve. | | `targetDefeated` | `true` when post-resolve HP is zero. | **HTTP status:** | Status | When | |--------|------| | **200** | Body parses as `AbilityCastResponse`; `accepted` is `true` or `false` with a stable `reasonCode` on deny. | | **400** | Missing body or wrong `schemaVersion`. | | **404** | Unknown player id (no position row / unknown player for this prototype). | **Deny `reasonCode` values (200, `accepted=false`):** | Code | Meaning | |------|---------| | `slot_out_of_bounds` | `slotIndex` outside `0..7`. | | `slot_unbound` | No ability bound on that slot in stored loadout. | | `loadout_mismatch` | `abilityId` does not match the ability bound on that slot. | | `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). | | `invalid_target` | `targetId` missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). | | `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). | | `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful combat resolve (NEO-32). | | `target_defeated` | Target HP is already zero; combat engine deny on re-hit (NEO-82 / NEO-81). No cooldown committed. No gig XP grant (NEO-44). | **NEO-44:** on accept when **`combatResolution.targetDefeated`** is **`true`**, the server grants **25** gig XP to prototype main gig **`breach`** via **`CombatDefeatGigXpGrant`** (outside E2.M2). Verify with **`GET …/gig-progression`**; cast response has no gig block in this slice. **NEO-106:** on every successful accept, **`EncounterCombatWiring.TryProcessCastOutcome`** activates encounter progress on first damaging hit and applies completion grants when all required NPCs are defeated ([Encounter combat wiring (NEO-106)](#encounter-combat-wiring-neo-106)). Verify loot with **`GET …/inventory`**. Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above). ## Cooldown snapshot (NEO-32) Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **NEO-82:** duration comes from ability catalog **`cooldownSeconds`** on each successful combat resolve (e.g. **`prototype_pulse`** 3.0s, **`prototype_guard`** 6.0s). - **`GET /game/players/{id}/cooldown-snapshot`** → `CooldownSnapshotResponse` v1 (`schemaVersion`, `playerId`, `serverTimeUtc`, `slots[]` with `slotIndex` and optional `cooldownEndsAtUtc` per slot). **404** when the player id is unknown to position state (same rule as loadout GET). Implementation: `CooldownSnapshotApi.cs`, `CooldownSnapshotDtos.cs`, `IPlayerAbilityCooldownStore` / `InMemoryPlayerAbilityCooldownStore.cs`. **NEO-30 (Slice 3 telemetry):** authoritative hook-site comments in `AbilityCastApi.cs` for product names `ability_cast_requested` (accept) and `ability_cast_denied` (each JSON deny + `reasonCode`); cataloged emit deferred to **E9.M1** (`TODO` in source). ## Solution From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`. ## CI Pull requests targeting `main` run build and tests via [`.github/workflows/dotnet.yml`](../.github/workflows/dotnet.yml) on GitHub Actions (`ubuntu-latest` runner, **Release** configuration). ## Linux + Rider: “only 8.x in /usr/share/dotnet” If the debugger prints **`.NET location: /usr/share/dotnet`** and **cannot find `Microsoft.NETCore.App` 10.x** while you have .NET 10 under **`~/.dotnet`**: - **`launchSettings.json` `environmentVariables`** (e.g. `DOTNET_ROOT`) often do **not** apply to the **native apphost** Rider launches first; the host probes `/usr/share/dotnet` before your app starts. - This repo sets **`false`** for **Debug** builds so the IDE runs **`dotnet …/NeonSprawl.Server.dll`** using the **.NET CLI** you configured in the toolchain (**`/home/don/.dotnet/dotnet`**), which then loads the 10.x shared runtime from the same install. - Alternatives: install [.NET 10 into the default location](https://learn.microsoft.com/en-us/dotnet/core/install/linux) so `/usr/share/dotnet` has 10.x, or add **`DOTNET_ROOT=/home/don/.dotnet`** in **Run → Edit Configurations → Environment variables** (IDE-level, not only `launchSettings.json`).