neon-sprawl/server/README.md

1192 lines
121 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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).
## Faction catalog (`content/factions`, NEO-134)
On startup the host loads every **`*_factions.json`** under the factions directory **after** the skill catalog and **before** the quest catalog, validates each row against **`content/schemas/faction-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate faction `id`** values across files, enforces the **prototype E7M3** two-faction roster, freeze table (display names + standing band), and standing-band ordering (`minStanding <= neutralStanding <= maxStanding`), then the quest loader cross-checks **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against loaded faction ids and enforces **prototype E7M3** completion bundle freeze (item + skill + reputation grants) and **grid-contract** quest shape (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:FactionsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/factions`**. |
| **`Content:FactionDefSchemaPath`** | Optional override for **`faction-def.schema.json`**. When unset, **`{parent of factions directory}/schemas/faction-def.schema.json`**. |
**Docker / CI:** include **`content/factions`** and **`content/schemas/faction-def.schema.json`** in the mounted **`content/`** tree; set **`Content__FactionsDirectory`** when layout differs.
On success, **Information** logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use **`IFactionDefinitionRegistry`** for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject **`FactionDefinitionCatalog`** in new game code.
## Faction standing store (NEO-135)
Per-player faction reputation snapshots live in **`IFactionStandingStore`**, keyed by **`(playerId, factionId)`**. A missing row reads as neutral **`0`**, clamped to the faction definition **`minStanding`…`maxStanding`**. Unknown faction ids return structured deny with reason code **`unknown_faction`** at the store boundary. Game code should call **`ReputationOperations`** (NEO-136) for auditable apply — not the standing store alone — so source attribution and audit rows stay consistent.
**Standing store methods:**
- **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`).
- **`TryGetStanding`** — read one faction; missing row ⇒ **`0`** (clamped).
- **`TryApplyStandingDelta`** — internal primitive: add signed delta, clamp result, persist. Does **not** append audit rows.
Append-only **`ReputationDelta`** audit rows live in **`IReputationDeltaStore`**:
- **`TryAppend`** — first row id returns `true`; duplicate ids return `false`.
- **`GetDeltasForPlayer`** — ordered by **`recordedAt`** then **`id`** (prototype test/audit read).
**Postgres orchestration:** audit rows FK to **`player_position`** — append after standing apply succeeds (NEO-136 **`ReputationOperations`**); a missing player row causes insert failure rather than a structured `false` from **`TryAppend`**.
**Storage:** in-memory singletons when **`ConnectionStrings:NeonSprawl`** is unset (standing store seeds configured dev player only). When Postgres is configured, **`PostgresFactionStandingStore`** persists to **`player_faction_standing`** ([`V009__player_faction_standing.sql`](../db/migrations/V009__player_faction_standing.sql)) and **`PostgresReputationDeltaStore`** appends to **`reputation_delta_audit`** ([`V010__reputation_delta_audit.sql`](../db/migrations/V010__reputation_delta_audit.sql)). Plan: [NEO-135 implementation plan](../../docs/plans/NEO-135-implementation-plan.md).
## Faction standing read (NEO-139)
**`GET /game/players/{id}/faction-standing`** returns a versioned snapshot of current standing for every faction in the loaded catalog. Path `{id}` is **trimmed**; unknown players (no position row) return **404** before building a body — same gate as skill/gig progression reads.
Example (dev player defaults):
```bash
curl -s http://localhost:5253/game/players/dev-local-1/faction-standing
```
Sample response (neutral standing on fresh dev player):
```json
{"schemaVersion":1,"playerId":"dev-local-1","factions":[{"id":"prototype_faction_grid_operators","standing":0},{"id":"prototype_faction_rust_collective","standing":0}]}
```
After **`prototype_quest_operator_chain`** completion (organic reward delivery), Grid Operators standing is **15**:
```json
{"schemaVersion":1,"playerId":"dev-local-1","factions":[{"id":"prototype_faction_grid_operators","standing":15},{"id":"prototype_faction_rust_collective","standing":0}]}
```
**Consumers:** key rows by **`id`** — array order is not part of the public contract. Display names are not included; join from client content until a world faction-definitions route exists. Godot HUD: **NEO-142**.
Bruno smoke: `bruno/neon-sprawl-server/faction-standing/`. Plan: [NEO-139 implementation plan](../../docs/plans/NEO-139-implementation-plan.md).
## ReputationOperations (NEO-136)
Game code applies standing changes through **`ReputationOperations.TryApplyDelta`** — not **`IFactionStandingStore.TryApplyStandingDelta`** alone — so every mutation records source attribution and an append-only audit row.
**`TryApplyDelta`** parameters: `playerId`, `factionId`, signed `deltaAmount`, caller-supplied `deltaId` (UUID string), `sourceKind`, `sourceId`, plus injected **`IFactionStandingStore`**, **`IReputationDeltaStore`**, and **`TimeProvider`**.
**Commit order:** standing apply first, audit append second. Audit row **`deltaAmount`** records the **applied** change (`newStanding - previousStanding`), not the requested delta when clamping occurs. On audit append failure (`duplicate` id or invalid row), the operation **compensating-rollback** standing via the inverse applied delta and returns **`audit_append_failed`**.
**Pre-flight denies (no store mutation):** `deltaAmount == 0`**`invalid_delta`**; empty `deltaId`**`invalid_delta_id`**; empty `sourceKind` or `sourceId`**`invalid_source`**. Store boundary denies (**`unknown_faction`**, **`player_not_writable`**) pass through unchanged.
**Prototype source kind:** **`quest_completion`** (`ReputationDeltaSourceKinds.QuestCompletion`); quest id as `sourceId`. Quest bundle wiring: **`RewardRouterOperations.TryDeliverQuestCompletion`** (NEO-138).
Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md).
## FactionGateOperations (NEO-137)
Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperations.TryEvaluate`** before activation. Wired from **`QuestStateOperations.TryAccept`** after prerequisite checks and before **`TryActivate`**.
**Semantics:** all rules must pass (AND). Empty rule list succeeds immediately. Each rule compares player standing from **`IFactionStandingStore.TryGetStanding`** against **`minStanding`** with **`standing >= minStanding`**. Unknown faction ids fail closed via the standing store boundary (startup catalog cross-ref prevents bad content at load).
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract, **`resetFactionIds`** for both prototype factions (clears stale standing from prior seq **11** runs), plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept.
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
### Faction telemetry hooks (NEO-141)
Comment-only hook sites for future E9.M1 catalog events ([Epic 7 Slice 3](../../docs/decomposition/modules/E7_M3_FactionReputationLedger.md#related-implementation-slices)). **`TODO(E9.M1)`** — no production ingest. Quest accept and reward router delegate to these ops layers only (no duplicate API-layer or router-layer hooks for these names). Quest rep grants emit **`reputation_delta`** via **`ReputationOperations`** — see [Reward telemetry hooks (NEO-130)](#reward-telemetry-hooks-neo-130) cross-link.
| Event | Anchor | When |
|-------|--------|------|
| **`reputation_delta`** | **`ReputationOperations.TryApplyDelta`** | After successful audit append (auditable apply committed). |
| **`faction_gate_blocked`** | **`FactionGateOperations.TryEvaluate`** | On any rule deny (threshold or standing read failure). |
Plan: [NEO-141 implementation plan](../../docs/plans/NEO-141-implementation-plan.md).
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined):
- **`resetQuestIds`** — deletes each quest's progress row for the player and clears matching **`IRewardDeliveryStore`** + quest-completion **`IReputationDeltaStore`** audit rows (idempotent when absent).
- **`resetFactionIds`** — clears each faction's standing row for the player (missing row reads as **0**; idempotent when absent).
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged unless **`resetFactionIds`** also sent). Idempotent when a quest is already completed.
**400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`completedQuestIds`**; order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**.
## 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, encounter, **skill**, and **faction** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**, **`faction-gate-rule.schema.json`**, and **`reputation-grant-row.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, enforces the **prototype E7M3** five-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents (item + skill grants), item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets, and enforces **prototype E7M3** faction cross-refs on gates and reputation grants, E7M3 completion bundle freeze (including **`reputationGrants`**), and **grid-contract** quest shape (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`**. |
Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**) resolve as siblings under **`{parent of quests directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those three files next to the quest schemas (NEO-125).
**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**, **`content/factions`**), and quest/bundle/faction 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.
## Contract template catalog (`content/contracts`, NEO-145)
On startup the host loads every **`*_contract_templates.json`** under the contracts directory **after** the item, recipe, encounter, skill, faction, and quest catalogs, validates each row against **`content/schemas/contract-template.schema.json`** (with **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**, **`faction-gate-rule.schema.json`**, and **`reputation-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate template `id`** values across files, cross-checks **`encounterTemplateId`** against the loaded encounter catalog and bundle item/skill/faction refs against frozen prototype catalogs, enforces the **prototype E7M4** one-template roster and freeze table (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant 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:ContractsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/contracts`**. |
| **`Content:ContractTemplateSchemaPath`** | Optional override for **`contract-template.schema.json`**. When unset, **`{parent of contracts directory}/schemas/contract-template.schema.json`**. |
Bundle- and gate-related schemas resolve as siblings under **`{parent of contracts directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those files next to the contract template schema (NEO-145).
**Docker / CI:** include **`content/contracts`**, upstream catalogs (**`content/encounters`**, **`content/skills`**, **`content/factions`**, **`content/items`**), and contract/bundle/faction schemas in the mounted **`content/`** tree; set **`Content__ContractsDirectory`** when layout differs.
On success, **Information** logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use **`IContractTemplateRegistry`** for lookups (`TryGetDefinition`, `GetDefinitionsInIdOrder`; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject **`ContractTemplateCatalog`** in new game code.
## 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`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), 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); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-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 accept: **`POST …/quests/{questId}/accept`** (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: `bruno/neon-sprawl-server/quest-progress/`.
### 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`** | **NEO-128:** delivers **`completionRewardBundle`** via **`RewardRouterOperations`** (deliver-then-mark), then marks active quest complete. **Idempotent:** when already **completed**, returns **`success: true`** without calling the router; replay after first-time complete also skips re-delivery. On delivery deny, quest stays **active** and **`reasonCode`** passthroughs router codes (e.g. **`inventory_full`**). |
**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. |
### Quest objective wiring (NEO-118)
**`QuestObjectiveWiring`** (static, best-effort) evaluates active quests on server-side gather/craft/encounter success and on **`inventory_has_item`** snapshot passes. When every objective on the current step is satisfied, wiring calls **`QuestStateOperations.TryAdvanceStep`** or **`TryMarkComplete`** (terminal step). Failures are logged at debug and never fail the primary gather/craft/encounter operation.
| Hook site | Objective kind | Trigger |
|-----------|----------------|---------|
| **`GatherOperations.TryGather`** success | **`gather_item`** | Item id + yield quantity credited to matching active quests; counter capped at objective **`quantity`**. |
| **`CraftOperations.TryCraft`** success | **`craft_recipe`** | Recipe id + batch quantity. |
| **`EncounterCompletionOperations.TryCompleteAndGrant`** success | **`encounter_complete`** | Encounter id (counter → 1). |
| Inventory mutations above + **`QuestStateOperations.TryAccept`** / **`TryAdvanceStep`** | **`inventory_has_item`** | Bag snapshot read; counter set to `min(held, required)`. |
**`inventory_has_item`** is **not** re-evaluated for other objective kinds on accept/step entry (event-driven only for gather/craft/encounter). Plan: [NEO-118 implementation plan](../../docs/plans/NEO-118-implementation-plan.md).
### Per-player quest progress (NEO-119)
**`GET /game/players/{id}/quest-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`quests`**) backed by **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`**. Unknown or blank player ids return **404** (position gate, same as encounter-progress). Each row includes **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`** (objective id → count for the current step only), optional **`completedAt`** when **`completed`**, and optional **`completionRewardSummary`** when **`completed`** and a delivery event exists in **`IRewardDeliveryStore`**. Quests are ordered by catalog **`id`** (ordinal).
| Field | When present |
|-------|----------------|
| **`completedAt`** | **`status: completed`** |
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`), **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`), and **`GrantedReputation`** → **`reputationGrants`** (`factionId`, `amount`). Omitted when not completed or when no delivery record exists. |
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`**, empty **`itemGrants`**, and empty **`reputationGrants`**. Operator chain completion adds **`reputationGrants: [{ factionId: prototype_faction_grid_operators, amount: 15 }]`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); rep projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`, `Get quest progress after operator chain complete.bru`.
Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md).
```bash
curl -sS "http://localhost:5253/game/players/dev-local-1/quest-progress"
```
Sample default response (no quests accepted):
```json
{"schemaVersion":1,"playerId":"dev-local-1","quests":[{"questId":"prototype_quest_combat_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_gather_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_operator_chain","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_refine_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}}]}
```
### Quest accept POST (NEO-120)
**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started``active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted). Faction-gated quests (NEO-137) deny with **`faction_gate_blocked`** when standing is below any **`FactionGateRule.minStanding`** after prerequisites pass.
Response (`schemaVersion` **1**): **`accepted`** (`true`/`false`), optional **`reasonCode`** on deny, optional **`quest`** row (same shape as GET list entries). Structured denies return **HTTP 200** with **`accepted: false`** (inventory/craft precedent). Accept-time **`inventory_has_item`** wiring runs inside **`TryAccept`** (NEO-118).
| Outcome | `accepted` | `reasonCode` | `quest` row |
|---------|------------|--------------|-------------|
| Success | `true` | omitted | active snapshot |
| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete`, `faction_gate_blocked` | omitted |
| Deny (existing row) | `false` | e.g. `already_active`, `already_completed` | current snapshot |
Plan: [NEO-120 implementation plan](../../docs/plans/NEO-120-implementation-plan.md); Bruno `bruno/neon-sprawl-server/quest-progress/` (accept spine).
```bash
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/quests/prototype_quest_gather_intro/accept" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1}'
```
Sample success:
```json
{"schemaVersion":1,"accepted":true,"quest":{"questId":"prototype_quest_gather_intro","status":"active","currentStepIndex":0,"objectiveCounters":{}}}
```
### Quest telemetry hooks (NEO-121)
Comment-only hook sites in **`QuestStateOperations`** for future E9.M1 catalog events ([Epic 7 Slice 1](../../docs/decomposition/modules/E7_M1_QuestStateMachine.md#risks-and-telemetry)). **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/accept`** and **`QuestObjectiveWiring`** delegate to the engine only (no duplicate API-layer or wiring-layer hooks).
| Event | Anchor | When |
|-------|--------|------|
| **`quest_start`** | **`TryAccept`** | After successful **`TryActivate`** (first activation only). |
| **`quest_step_complete`** | **`TryAdvanceStep`** | After successful store commit (one emit per advance; chain quests may emit multiple per wiring pass). |
| **`quest_complete`** | **`TryMarkComplete`** | After first-time completion commit (not idempotent replay when already **`completed`**). |
| **`quest_accept_denied`** | **`TryAccept`** deny paths | Every structured accept deny with stable **`reasonCode`**. |
Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: [NEO-121 implementation plan](../../docs/plans/NEO-121-implementation-plan.md).
## Reward delivery store (NEO-126)
**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**).
| Field | Role |
|-------|------|
| **`PlayerId`**, **`QuestId`** | Normalized keys (trim + lowercase). |
| **`DeliveredAt`** | UTC timestamp at delivery record commit. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) at commit time. |
| **`GrantedReputation`** | Snapshot of applied reputation rows (`factionId`, `amount`) at commit time (NEO-138). |
| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. |
**Store interface:**
- **`TryRecord`** — first delivery returns `true`; replays for the same player+quest return `false` without overwriting the snapshot.
- **`TryGet`** — read one delivery event for HTTP **`completionRewardSummary`** projection (NEO-129).
Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest completion rewards use this store only. Postgres persistence deferred. Plan: [NEO-126 implementation plan](../../docs/plans/NEO-126-implementation-plan.md).
## Reward router (NEO-127)
**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows), **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), and **`ReputationOperations.TryApplyDelta`** with **`ReputationDeltaSourceKinds.QuestCompletion`** (reputation rows — NEO-138), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**.
| Reason code | When |
|-------------|------|
| **`already_delivered`** | Success replay — store already has a delivery for this player+quest (no re-grant). |
| **`invalid_player_id`**, **`invalid_quest_id`** | Normalized id empty after trim. |
| **`inventory_store_missing`** | Player inventory store could not read/write. |
| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. |
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md).
### Reward telemetry hooks (NEO-130)
Comment-only hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** for future E9.M1 catalog events ([Epic 7 Slice 2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#related-implementation-slices)). **`TODO(E9.M1)`** — no production ingest. **`QuestStateOperations`** and HTTP quest routes delegate to the router only (no duplicate API-layer or quest-state hooks for these names). **`quest_complete`** remains in **`QuestStateOperations`** ([NEO-121](#quest-telemetry-hooks-neo-121)); **`reward_delivery`** fires earlier on the deliver-then-mark path (NEO-128).
| Event | Anchor | When |
|-------|--------|------|
| **`reward_delivery`** | **`TryDeliverQuestCompletion`** | After **`TryRecord`** returns **`true`** on first-time delivery (not idempotent **`TryGet`** replay or race-loser paths). |
| **`unlock_granted`** | **`TryDeliverQuestCompletion`** (stub) | Future **`UnlockGrant`** apply from bundle rows — prototype bundles have item + skill XP only; no runtime unlock apply in Slice 2. |
**`reputation_delta`** from quest bundle rep rows emits via **`ReputationOperations.TryApplyDelta`** ([NEO-141](#faction-telemetry-hooks-neo-141)) — not duplicated in the router.
**`perk_unlock`** side effects from skill XP grants use **`PerkUnlockEngine`** ([NEO-49](#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49)) — distinct from content **`UnlockGrant`** rows. Manual QA: [`docs/manual-qa/NEO-130.md`](../../docs/manual-qa/NEO-130.md); plan: [NEO-130 implementation plan](../../docs/plans/NEO-130-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 tables **`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`** | Encounters **`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**), clears **`IPlayerAbilityCooldownStore`** slot cooldowns for the configured dev player, 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).
**Dev resource-node fixture (Bruno/manual QA):** When `Game:EnableResourceNodeFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/resource-node-fixture`** with `schemaVersion` **1** upserts every catalog node to **`maxGathers`** via **`TrySetRemainingGathers`**. **404** when disabled. Bruno: `scripts/bruno-dev-fixture-helper.js` (`resetPrototypeResourceNodes`).
## 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 023) | `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 skills **`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 stores 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** projects `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 "<connection string>"` (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 (~**814 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 interactables `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 interactables **`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 targets `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 3named** 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 **`<UseAppHost>false</UseAppHost>`** 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`).