574 lines
54 KiB
Markdown
574 lines
54 KiB
Markdown
# Game server (`NeonSprawl.Server`)
|
||
|
||
ASP.NET Core **.NET 10** host for authoritative simulation (prototype: HTTP + health + position state via in-memory **or** PostgreSQL).
|
||
|
||
## Prerequisites
|
||
|
||
- **.NET 10 SDK** (matches `TargetFramework` in `NeonSprawl.Server.csproj`). Check with `dotnet --list-sdks`.
|
||
|
||
## Run
|
||
|
||
```bash
|
||
cd server/NeonSprawl.Server
|
||
dotnet run
|
||
```
|
||
|
||
- Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`).
|
||
- Check `GET /health` for a JSON heartbeat.
|
||
|
||
**Optional prototype API stdout (manual QA / dev):** set **`NEON_SPRAWL_API_LOG`** to **`1`**, **`true`**, **`yes`**, or **`on`** (case-insensitive). While enabled, each **`POST …/ability-cast`** that returns JSON **`AbilityCastResponse`** (HTTP 200) prints one line to the process console: either **`ability_cast_requested`** (accept) or **`ability_cast_denied`** with **`reason=`** (deny). **`400`** / **`404`** paths do not emit these lines. Implementation: `Diagnostics/PrototypeApiConsoleLog.cs` (wired from `AbilityCastApi`). Example:
|
||
|
||
```bash
|
||
NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj
|
||
```
|
||
|
||
IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable).
|
||
|
||
## Skill catalog (`content/skills`, NEO-34)
|
||
|
||
On startup the host loads every **`*_skills.json`** under the skills directory, validates each row against **`content/schemas/skill-def.schema.json`**, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||
|
||
| Config | Meaning |
|
||
|--------|---------|
|
||
| **`Content:SkillsDirectory`** | Optional. Absolute path, or path relative to the server **content root** (the project directory for `dotnet run`). When unset, the host walks **ancestors of `AppContext.BaseDirectory`** until it finds a **`content/skills`** directory (works for `dotnet run` from `server/NeonSprawl.Server` without extra config). |
|
||
| **`Content:SkillDefSchemaPath`** | Optional override for **`skill-def.schema.json`**. When unset, resolved as **`{parent of skills directory}/schemas/skill-def.schema.json`** (repo layout: `content/schemas` next to `content/skills`). |
|
||
|
||
**Docker / CI:** mount or copy the repo **`content/`** tree (at least **`content/skills`** and **`content/schemas`**) and set **`Content__SkillsDirectory`** / **`Content__SkillDefSchemaPath`** if the layout differs from the default discovery rule.
|
||
|
||
On success, **Information** logs include the resolved skills directory path and distinct skill count.
|
||
|
||
## Item catalog (`content/items`, NEO-51)
|
||
|
||
On startup the host loads every **`*_items.json`** under the items directory, validates each row against **`content/schemas/item-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||
|
||
| Config | Meaning |
|
||
|--------|---------|
|
||
| **`Content:ItemsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/items`**. |
|
||
| **`Content:ItemDefSchemaPath`** | Optional override for **`item-def.schema.json`**. When unset, **`{parent of items directory}/schemas/item-def.schema.json`**. |
|
||
|
||
**Docker / CI:** include **`content/items`** and **`content/schemas/item-def.schema.json`** in the mounted **`content/`** tree; set **`Content__ItemsDirectory`** when layout differs.
|
||
|
||
On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52).
|
||
|
||
## Resource-node catalog (`content/resource-nodes`, NEO-58)
|
||
|
||
On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||
|
||
| Config | Meaning |
|
||
|--------|---------|
|
||
| **`Content:ResourceNodesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/resource-nodes`**. |
|
||
| **`Content:ResourceNodeDefSchemaPath`** | Optional override for **`resource-node-def.schema.json`**. When unset, **`{parent of resource-nodes directory}/schemas/resource-node-def.schema.json`**. |
|
||
| **`Content:ResourceYieldRowSchemaPath`** | Optional override for **`resource-yield-row.schema.json`**. When unset, **`{parent of resource-nodes directory}/schemas/resource-yield-row.schema.json`**. |
|
||
|
||
**Docker / CI:** include **`content/resource-nodes`**, **`content/items`**, and the two resource-node schemas in the mounted **`content/`** tree; set **`Content__ResourceNodesDirectory`** when layout differs.
|
||
|
||
On success, **Information** logs include the resolved resource-nodes directory path, distinct node count, yield row count, and catalog file counts.
|
||
|
||
**`IResourceNodeDefinitionRegistry`** (NEO-59) is the preferred lookup surface for gather engine, depletion, interact, and HTTP callers — resolve node defs and yield rows by **`nodeDefId`**, enumerate defs in id order. The catalog singleton remains for fail-fast startup only; do not inject **`ResourceNodeCatalog`** in new game code.
|
||
|
||
## Recipe catalog (`content/recipes`, NEO-66)
|
||
|
||
On startup the host loads every **`*_recipes.json`** under the recipes directory, validates each row against **`content/schemas/recipe-def.schema.json`** (with **`recipe-io-row.schema.json`** for I/O `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate recipe `id`** values across files, cross-checks every input/output **`itemId`** against the **item catalog** and every **`requiredSkillId`** against the **skill catalog** (both loaded first), and enforces the **prototype Slice 3** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||
|
||
| Config | Meaning |
|
||
|--------|---------|
|
||
| **`Content:RecipesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/recipes`**. |
|
||
| **`Content:RecipeDefSchemaPath`** | Optional override for **`recipe-def.schema.json`**. When unset, **`{parent of recipes directory}/schemas/recipe-def.schema.json`**. |
|
||
| **`Content:RecipeIoRowSchemaPath`** | Optional override for **`recipe-io-row.schema.json`**. When unset, **`{parent of recipes directory}/schemas/recipe-io-row.schema.json`**. |
|
||
|
||
**Docker / CI:** include **`content/recipes`**, **`content/items`**, **`content/skills`**, and the two recipe schemas in the mounted **`content/`** tree; set **`Content__RecipesDirectory`** when layout differs.
|
||
|
||
On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code.
|
||
|
||
## Ability catalog (`content/abilities`, NEO-77)
|
||
|
||
On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id 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: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).
|
||
|
||
## 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"
|
||
```
|
||
|
||
## Recipe definitions (NEO-68)
|
||
|
||
**`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.
|
||
|
||
```bash
|
||
curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
|
||
```
|
||
|
||
## Craft engine (NEO-69) and craft HTTP (NEO-70)
|
||
|
||
**`CraftOperations.TryCraft`** in **`Game/Crafting/`** resolves server-internal **`CraftResult`**: recipe lookup via **`IRecipeDefinitionRegistry`**, read-only pre-flight for inputs and output capacity (simulated adds on a snapshot clone), input removal and output grant via **`PlayerInventoryOperations`**, then **`refine`** skill XP via **`SkillProgressionGrantOperations`** + **`RefineSkillXpConstants`** (same path as **`RefineActivitySkillXpGrant`**). On output-grant or XP failure after inputs were removed, the engine **rolls back** inventory before denying. **NEO-70** exposes **`POST /game/players/{id}/craft`** with wire **`CraftResponse`** (promoted **`CraftResult`** fields). Client craft UI is **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**.
|
||
|
||
| Reason code | When |
|
||
|-------------|------|
|
||
| **`unknown_recipe`** | **`recipeId`** not in **`IRecipeDefinitionRegistry`**. |
|
||
| **`insufficient_materials`** | Pre-flight input totals below scaled recipe cost. |
|
||
| **`inventory_full`** | Pre-flight output simulation could not place full scaled yield (NEO-54 all-or-nothing). |
|
||
| **`invalid_quantity`** | Craft batch **`quantity`** is **≤ 0**. |
|
||
| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory rollback). |
|
||
| **`inventory_store_missing`** | Player inventory store could not read or write. |
|
||
|
||
**POST** body (**`schemaVersion` 1**): **`recipeId`**, optional **`quantity`** (defaults to **1** when omitted). Wire request type: **`PlayerCraftRequest`**. Response: **`success`**, **`reasonCode`** (`null` on success), **`inputsConsumed`**, **`outputsGranted`**, **`xpGrantSummary`** on success. **400** on schema mismatch or empty **`recipeId`**; **404** on unknown player or store-missing codes; **200** with **`success: false`** + stable deny codes on rule failure.
|
||
|
||
```bash
|
||
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/craft" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"schemaVersion":1,"recipeId":"refine_scrap_standard"}'
|
||
```
|
||
|
||
Sample **200** success body (after seeding **5×** **`scrap_metal_bulk`** via gather or inventory POST):
|
||
|
||
```json
|
||
{"schemaVersion":1,"success":true,"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"activity"}}
|
||
```
|
||
|
||
Plan: [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); [NEO-70 implementation plan](../../docs/plans/NEO-70-implementation-plan.md); [NEO-71 implementation plan](../../docs/plans/NEO-71-implementation-plan.md). Bruno: `bruno/neon-sprawl-server/craft/`.
|
||
|
||
### Craft telemetry hooks (NEO-71)
|
||
|
||
Comment-only hook sites in **`CraftOperations.TryCraft`** for future E9.M1 catalog events. **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/craft`** calls the engine only (no duplicate API-layer hooks).
|
||
|
||
- **`item_crafted`** — on every successful craft (inputs removed, outputs granted, refine XP applied).
|
||
- **`craft_failed`** — with stable **`reasonCode`** on every structured deny via private **`Deny`** (all **`CraftReasonCodes`**, including store-missing and post-mutation rollback paths).
|
||
|
||
Successful craft output grants also pass through **`PlayerInventoryOperations`** (**`item_created`** hook, [NEO-56](#player-inventory-neo-54-store-neo-55-http)). Epic 3 Slice 3 metric **`time-to-first-craft`** is derived at E9.M1 from **`item_crafted`** + session context (no separate prototype hook). Plan: [NEO-71 implementation plan](../../docs/plans/NEO-71-implementation-plan.md).
|
||
|
||
## Resource node definitions (NEO-60)
|
||
|
||
**`GET /game/world/resource-node-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`gatherLens`**, **`maxGathers`**, and nested **`yield`** (`itemId`, `quantity`). Plan: [NEO-60 implementation plan](../../docs/plans/NEO-60-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-60.md`](../../docs/manual-qa/NEO-60.md); Bruno: `bruno/neon-sprawl-server/resource-node-definitions/`.
|
||
|
||
```bash
|
||
curl -sS -i "http://localhost:5253/game/world/resource-node-definitions"
|
||
```
|
||
|
||
## Resource node instance depletion (NEO-61)
|
||
|
||
World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gathering/`** as **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`**. Capacity is **lazy-initialized** from catalog **`maxGathers`** on first successful-gather commit; prototype wiring uses **`interactableId == nodeDefId`**. **`TryCommitSuccessfulGatherDecrement`** atomically decrements and returns stable deny codes:
|
||
|
||
| Reason code | When |
|
||
|-------------|------|
|
||
| **`node_depleted`** | Row exists and **`remainingGathers`** is already **0** (decrement rejected). |
|
||
| **`unknown_node`** | **`interactableId`** not found in **`IResourceNodeDefinitionRegistry`**. |
|
||
|
||
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as inventory — **`resource_node_instance`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V006__resource_node_instance.sql`](../db/migrations/V006__resource_node_instance.sql)), otherwise in-memory fallback (empty at startup). Interact deny wiring: [NEO-63](#gather-via-interact-neo-63); plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md).
|
||
|
||
## Gather engine (NEO-62)
|
||
|
||
**`GatherOperations.TryGather`** in **`Game/Gathering/`** resolves server-internal **`GatherResult`**: capacity pre-check (may **initialize** a depletion row without decrementing), inventory grant via **`PlayerInventoryOperations`**, **`salvage`** skill XP via **`SkillProgressionGrantOperations`** + **`GatherSkillXpConstants`**, then depletion commit via **`ResourceNodeInstanceOperations`** (decrement **last**, only when inventory + XP succeed). On XP failure after inventory add, the engine **rolls back** the item grant before denying.
|
||
|
||
| Reason code | When |
|
||
|-------------|------|
|
||
| **`unknown_node`** | **`interactableId`** not in **`IResourceNodeDefinitionRegistry`** or missing yield row. |
|
||
| **`node_depleted`** | **`remainingGathers`** is **0** (or decrement rejected). |
|
||
| **`inventory_full`** | Full yield quantity could not be placed (NEO-54 all-or-nothing). |
|
||
| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory remove). |
|
||
| **`inventory_store_missing`** | Player inventory store could not write. |
|
||
|
||
**Interact wiring** (replace NEO-41 inline XP, map denies to **`InteractionResponse`**) is **[NEO-63](#gather-via-interact-neo-63)**; plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md).
|
||
|
||
### Gather telemetry hooks (NEO-64)
|
||
|
||
Comment-only hook sites in **`GatherOperations.TryGather`** for future E9.M1 catalog events — **`resource_gathered`** on every successful gather (inventory + XP + depletion committed); **`gather_node_depleted`** when that success drives **`remainingGathers`** to **0** (not on pre-gather **`node_depleted`** deny). **`TODO(E9.M1)`** — no production ingest. Successful gathers also pass through **`PlayerInventoryOperations`** (**`item_created`** hook, [NEO-56](#player-inventory-neo-54-store-neo-55-http)). HTTP **`POST …/interact`** calls the engine only (no duplicate API-layer hooks). Manual QA: [`docs/manual-qa/NEO-64.md`](../../docs/manual-qa/NEO-64.md); plan: [NEO-64 implementation plan](../../docs/plans/NEO-64-implementation-plan.md).
|
||
|
||
## Item definitions (NEO-53)
|
||
|
||
**`GET /game/world/item-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-53 implementation plan](../../docs/plans/NEO-53-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-53.md`](../../docs/manual-qa/NEO-53.md); Bruno: `bruno/neon-sprawl-server/item-definitions/`.
|
||
|
||
```bash
|
||
curl -sS -i "http://localhost:5253/game/world/item-definitions"
|
||
```
|
||
|
||
## Player inventory (NEO-54 store, NEO-55 HTTP)
|
||
|
||
Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`**. **NEO-55** exposes versioned **`GET` / `POST /game/players/{id}/inventory`** for manual QA and future client. Plan: [NEO-55 implementation plan](../../docs/plans/NEO-55-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/inventory/`.
|
||
|
||
| Container | Fixed slots | Accepts `inventorySlotKind` |
|
||
|-----------|-------------|-------------------------------|
|
||
| **Bag** | **24** (indices 0–23) | `bag` |
|
||
| **Equipment** | **1** (index 0) | `equipment` |
|
||
|
||
**GET** returns **`schemaVersion` 1**, **`playerId`**, **`bagSlots`** (length 24), **`equipmentSlots`** (length 1). Empty slots use **`quantity: 0`** with **`itemId`** omitted. **404** when the player is unknown (position gate) or missing from the inventory store.
|
||
|
||
**POST** body (**`schemaVersion` 1**): **`mutationKind`** **`add`** or **`remove`**, **`itemId`**, **`quantity`**. Response: **`applied`**, **`reasonCode`** (`null` on success), **`inventory`** snapshot echo. **400** on schema mismatch or unknown **`mutationKind`**; **404** on unknown player; **200** with **`applied: false`** + stable deny codes on rule failure.
|
||
|
||
```bash
|
||
curl -sS "http://localhost:5253/game/players/dev-local-1/inventory"
|
||
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/inventory" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"schemaVersion":1,"mutationKind":"add","itemId":"scrap_metal_bulk","quantity":5}'
|
||
```
|
||
|
||
**Engine rules (NEO-54):** **`TryAddStack`** and **`TryRemoveStack`** route items by catalog metadata, merge stacks up to **`ItemDef.stackMax`**, and use **all-or-nothing** adds (no partial silent loss). Stable deny **`reasonCode`** values:
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| **`inventory_full`** | Full requested quantity could not be placed |
|
||
| **`invalid_item`** | Unknown `itemId` (not in catalog) |
|
||
| **`insufficient_quantity`** | Remove requested more than held |
|
||
| **`invalid_quantity`** | Non-positive quantity |
|
||
|
||
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as skill progression — **`player_inventory`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V005__player_inventory.sql`](../db/migrations/V005__player_inventory.sql), sparse occupied-slot rows), otherwise in-memory fallback seeding the configured dev player. Store/engine plan: [NEO-54 implementation plan](../../docs/plans/NEO-54-implementation-plan.md).
|
||
|
||
**NEO-56 telemetry hooks:** comment-only hook sites in **`PlayerInventoryOperations`** for future E9.M1 catalog events — **`item_created`** on successful **`TryAddStack`** (`Applied`); **`inventory_transfer_denied`** + stable **`reasonCode`** on every rule **`Denied`** path (early validation via **`Deny`** and post-mutation denies such as **`inventory_full`** / **`insufficient_quantity`**). **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/inventory`** calls the engine only (no duplicate API-layer hooks). Manual QA: [`docs/manual-qa/NEO-56.md`](../../docs/manual-qa/NEO-56.md); plan: [NEO-56 implementation plan](../../docs/plans/NEO-56-implementation-plan.md).
|
||
|
||
## Mastery catalog (`content/mastery`, NEO-46)
|
||
|
||
After the skill catalog loads, the host loads every **`*_mastery.json`** under the mastery directory, validates each file against **`content/schemas/mastery-catalog.schema.json`**, cross-checks track **`skillId`** values against **`ISkillDefinitionRegistry`**, and enforces the same post-schema gates as **`scripts/validate_content.py`** (unknown perk references, duplicate perk ids, branch-set equality tier-to-tier, unreferenced perks, prototype **Slice 4** single **`salvage`** track). The server also requires **`tierIndex`** values per track to be **unique and sequential 1..N** (stricter than CI today). Invalid data **exits during startup**—no silent fallback.
|
||
|
||
| Config | Meaning |
|
||
|--------|---------|
|
||
| **`Content:MasteryDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** for **`content/mastery`**. |
|
||
| **`Content:MasteryCatalogSchemaPath`** | Optional override for **`mastery-catalog.schema.json`**. When unset, **`{parent of mastery directory}/schemas/mastery-catalog.schema.json`**. |
|
||
|
||
**Docker / CI:** include **`content/mastery`** and **`content/schemas/mastery-catalog.schema.json`** in the mounted **`content/`** tree; set **`Content__MasteryDirectory`** when layout differs.
|
||
|
||
On success, **Information** logs include the resolved mastery directory, track count, and perk count. Game code should use **`IMasteryCatalogRegistry`** for lookups (NEO-47+).
|
||
|
||
## Perk unlock engine and telemetry hooks (NEO-47, NEO-49)
|
||
|
||
**`PerkUnlockEngine`** (`Game/Mastery/`) is the authoritative server path for branch picks and perk unlocks: **`TrySelectBranch`**, **`ReevaluateAfterLevelUp`** (called from **`SkillProgressionGrantOperations`** on skill level-up). Persistence: **`IPlayerPerkStateStore`** + Flyway **`V004`** ([NEO-47 plan](../../docs/plans/NEO-47-implementation-plan.md)).
|
||
|
||
**NEO-49:** comment-only **telemetry hook site** for future catalog event **`perk_unlock`** in **`PerkUnlockEngine.TryUnlockPerks`** when new perk ids are persisted (one emit per unlocked perk; **`TODO(E9.M1)`** — no production ingest). Manual QA: [`docs/manual-qa/NEO-49.md`](../../docs/manual-qa/NEO-49.md); plan: [NEO-49 implementation plan](../../docs/plans/NEO-49-implementation-plan.md).
|
||
|
||
## Perk state (NEO-48)
|
||
|
||
**`GET /game/players/{id}/perk-state`** returns versioned JSON (`schemaVersion` **1**, **`playerId`**, **`branchPicks`**, **`unlockedPerkIds`**). **`branchPicks`** is an array of `{ skillId, picks: [{ tierIndex, branchId }] }` — key by **`skillId`**; array order is not part of the contract. **`unlockedPerkIds`** lists perk ids currently unlocked for the player.
|
||
|
||
**`POST /game/players/{id}/perk-state`** commits one mastery branch pick (`schemaVersion` **1**, **`skillId`**, **`tierIndex`**, **`branchId`**). Delegates to **`PerkUnlockEngine.TrySelectBranch`** (NEO-47). **404** when the player is unknown to position state; **400** when **`schemaVersion`** is missing or not **1**. Persistence uses **`IPlayerPerkStateStore`** + Flyway **`V004`** when Postgres is configured (same [NEO-29-style](#position-persistence-neo-8) split as skill progression).
|
||
|
||
**Structured responses (HTTP 200):** **`selected`** (`true`/`false`), optional **`reasonCode`** on deny, authoritative **`perkState`** (same shape as GET), and **`unlockedEvents`** on success (empty on deny). Stable deny codes (see `PerkUnlockReasonCodes`): **`unknown_track`**, **`unknown_tier`**, **`unknown_branch`**, **`level_too_low`**, **`branch_already_chosen`**, **`tier_branch_not_selectable`**, **`player_not_found`**. On **`selected: true`**, **`unlockedEvents`** lists perks newly unlocked on this request (`perkId`, `skillId`, `tierIndex`, `branchId`, **`source`**: **`branch_pick`** | **`level_up`**). Tier-1 prototype salvage picks may emit zero events; path-auto tier-2 unlocks at level 4+ may emit **`level_up`** events on the same POST when level gates are already met.
|
||
|
||
**Dev mastery fixture (NEO-48, Bruno/manual QA):** When `Game:EnableMasteryFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/mastery-fixture`** accepts `schemaVersion` **1**, optional **`resetPerkState`** (clear branch picks + unlocked perks), and optional **`skillXp`** map (**absolute** XP per `skillId`; omitted skills unchanged). Fixture writes set totals **directly** — they do **not** run **`SkillProgressionGrantOperations`** or perk **level-up reevaluation**; use normal **`POST …/skill-progression`** when testing unlock side effects. Apply order: batch **`skillXp`** first, then **`resetPerkState`** (XP rolls back if reset fails). **400** for bad schema or invalid **`skillXp`**; **404** when disabled, player unknown, or store write fails. Not for production.
|
||
|
||
Plan: [NEO-48 implementation plan](../../docs/plans/NEO-48-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-48.md`](../../docs/manual-qa/NEO-48.md); Bruno: `bruno/neon-sprawl-server/perk-state/`.
|
||
|
||
```bash
|
||
curl -sS -i "http://localhost:5253/game/players/dev-local-1/perk-state"
|
||
curl -sS -i -X POST "http://localhost:5253/game/players/dev-local-1/perk-state" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"schemaVersion":1,"skillId":"salvage","tierIndex":1,"branchId":"scrap_efficiency"}'
|
||
```
|
||
|
||
## Skill definitions (NEO-36)
|
||
|
||
**`GET /game/world/skill-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`skills`**) backed by **`ISkillDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-36 implementation plan](../../docs/plans/NEO-36-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-36.md`](../../docs/manual-qa/NEO-36.md); Bruno: `bruno/neon-sprawl-server/skill-definitions/`.
|
||
|
||
```bash
|
||
curl -sS -i "http://localhost:5253/game/world/skill-definitions"
|
||
```
|
||
|
||
## Skill progression snapshot (NEO-37)
|
||
|
||
**`GET /game/players/{id}/skill-progression`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`skills`**) with one row per registered skill (**`id`**, **`xp`**, **`level`**). Treat **`skills`** as **unordered** and index rows by **`id`** — array order is not part of the contract (the server may sort for stable serialization). Levels are derived from the startup-loaded content curve at **`content/skills/prototype_level_curve.json`** (validated by **`content/schemas/level-curve.schema.json`** and CI **`scripts/validate_content.py`**). Unknown players (**no row in position state**) receive **404**, same gate as **`GET …/cooldown-snapshot`**. Plan: [NEO-37 implementation plan](../../docs/plans/NEO-37-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-37.md`](../../docs/manual-qa/NEO-37.md); Bruno: `bruno/neon-sprawl-server/skill-progression/Get skill progression.bru`.
|
||
|
||
```bash
|
||
curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression"
|
||
```
|
||
|
||
## Skill progression grant (NEO-38)
|
||
|
||
**`POST /game/players/{id}/skill-progression`** accepts a versioned body (`schemaVersion` **1**, **`skillId`**, **`amount`** positive int, **`sourceKind`**) and validates the skill against **`ISkillDefinitionRegistry`**, then validates **`sourceKind`** against that skill’s **`allowedXpSourceKinds`** (case-insensitive). **404** when the player is unknown to position state; **400** when **`schemaVersion`** is missing or not **1**. Persisted XP totals use the same **[NEO-29-style](#position-persistence-neo-8) split** as hotbar: **`player_skill_progression`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V003__player_skill_progression.sql`](../db/migrations/V003__player_skill_progression.sql)), otherwise the in-memory fallback (dev bucket only, matching hotbar semantics).
|
||
|
||
**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"}'
|
||
```
|
||
|
||
## Position persistence (NEO-8)
|
||
|
||
When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request).
|
||
|
||
If **`ConnectionStrings:NeonSprawl`** is missing or blank, behavior matches NEO-6/NEO-7 (**in-memory** only). The **test** project’s `postgres.runsettings` sets `ConnectionStrings__NeonSprawl` for the test host so Postgres integration tests run in CI/Rider/`dotnet test`; non-Postgres tests use `InMemoryWebApplicationFactory` and do not require a live DB. Remove or rename `postgres.runsettings` (and the `RunSettingsFilePath` property) if you want those Postgres integration tests to **skip** again when the variable is unset.
|
||
|
||
**Environment variables** (typical — same mapping as other ASP.NET Core config):
|
||
|
||
| Mechanism | Example |
|
||
|-----------|---------|
|
||
| Shell / CI | `export ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev'` |
|
||
| User secrets | `dotnet user-secrets set ConnectionStrings:NeonSprawl "<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 (~**8–14 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so **`move-stream`** from the floor into the pedestal still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so streaming from it toward the floor is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled.
|
||
|
||
Request body (example):
|
||
|
||
```bash
|
||
curl -s -X POST http://localhost:5253/game/players/dev-local-1/move \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"schemaVersion":1,"target":{"x":1.5,"y":0,"z":-2}}'
|
||
```
|
||
|
||
- **200** — body matches **`PositionStateResponse`** (same shape as `GET` … `/position`).
|
||
- **400** — malformed command (missing/invalid body or wrong `schemaVersion`) **or** move rejected by NEO-10 rules. Rejection responses are JSON **`MoveCommandRejectedResponse`**: `schemaVersion` (`1`) + **`reasonCode`** (`horizontal_step_exceeded`, `vertical_step_exceeded`, `out_of_bounds`). Malformed requests return **400** with **no** rejection body.
|
||
- **404** — unknown player id.
|
||
|
||
See XML on `MoveCommandRequest`, `PositionStateResponse`, and `MoveCommandRejectedResponse` in the server project.
|
||
|
||
For a **PR-ready** contract blurb (formatted JSON + field table), see the [NEO-6 implementation plan — Pull request description](../../docs/plans/NEO-6-implementation-plan.md#pull-request-description-linear-ac) (GET shape) and [NEO-7 implementation plan](../../docs/plans/NEO-7-implementation-plan.md#pull-request-description-linear-ac--draft-for-merge) (MoveCommand + sequence semantics). NEO-10: [NEO-10 implementation plan](../../docs/plans/NEO-10-implementation-plan.md).
|
||
|
||
## Interaction (NEO-9)
|
||
|
||
**`POST /game/players/{id}/interact`** with a versioned **InteractionRequest** body asks whether the given player may use a prototype interactable **now**. The server reads **authoritative** `PositionState` from `IPositionStateStore` (same id rules as position APIs: trim + ordinal case-insensitive match). **Horizontal reach** uses **X and Z only**; **Y is ignored** for distance (floor-plane prototype). See `HorizontalReach` in `NeonSprawl.Server/Game/World/` and `PrototypeInteractableRegistry` in `Game/Interaction/`.
|
||
|
||
Request (example):
|
||
|
||
```bash
|
||
curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"schemaVersion":1,"interactableId":"prototype_terminal"}'
|
||
```
|
||
|
||
**HTTP**
|
||
|
||
| Status | Meaning |
|
||
|--------|---------|
|
||
| **200** | Body is **InteractionResponse** v1: `allowed: true` (optional `payload`, `interactableId` echo) or `allowed: false` with required **`reasonCode`**. |
|
||
| **400** | Not a valid v1 attempt (bad/missing `schemaVersion`, malformed JSON, missing **`interactableId`**, or empty after trim). |
|
||
| **404** | Unknown **player** only (no row in the position store). |
|
||
|
||
**`reasonCode` (v1, when `allowed` is false)**
|
||
|
||
| Code | When |
|
||
|------|------|
|
||
| `out_of_range` | Horizontal distance on X/Z is **greater than** the interactable’s `interactionRadius` (on the radius is **allowed** — inclusive `<=`). |
|
||
| `unknown_interactable` | Id not in the prototype registry (after trim + case-insensitive lookup). |
|
||
| `node_depleted` | **`resource_node`** gather: instance capacity exhausted ([NEO-63](#gather-via-interact-neo-63)). |
|
||
| `inventory_full` | **`resource_node`** gather: yield could not fit in bag (all-or-nothing; [NEO-54](#player-inventory-neo-54-store-neo-55-http)). |
|
||
| `unknown_node` | **`resource_node`** gather: registry miss (defensive; should not occur for registered interactables). |
|
||
| `progression_store_missing` | **`resource_node`** gather: skill progression store could not apply XP. |
|
||
|
||
When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**.
|
||
|
||
### Gather via interact (NEO-63)
|
||
|
||
When the interactable’s **`kind`** is **`resource_node`**, **`POST …/interact`** delegates to **`GatherOperations.TryGather`** ([NEO-62](#gather-engine-neo-62)): inventory grant, **`salvage`** skill XP (`activity`, 10 XP), and depletion commit. On success **`allowed: true`**; gather denies map to **`allowed: false`** + stable **`reasonCode`** (including **`node_depleted`**, **`inventory_full`**). **Four** prototype nodes are registered in **`PrototypeInteractableRegistry`** (alpha **(12, −6)**, beta **(18, −6)**, gamma **(12, 0)**, delta **(18, 0)** on X/Z, radius **3**). Verify with **`GET …/inventory`** and **`GET …/skill-progression`**. Plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-63.md`](../../docs/manual-qa/NEO-63.md); Bruno: `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`, `Post interact gather then get inventory.bru`, `Post interact depleted node deny.bru`.
|
||
|
||
**Historical:** [NEO-41](#gather-via-interact-neo-63) previously granted XP only on allowed interact without inventory or depletion; superseded by NEO-63.
|
||
|
||
### Craft / refine hook → skill XP (NEO-42)
|
||
|
||
**E3.M2** craft/refine success paths call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`RefineSkillXpConstants`** (**10** **`refine`** XP, **`sourceKind: activity`**) — the same stack as **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** and **`POST …/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)). **`CraftOperations.TryCraft`** ([NEO-69](#craft-engine-neo-69-and-craft-http-neo-70)) owns the live call site; craft HTTP is **`POST …/craft`** ([NEO-70](#craft-engine-neo-69-and-craft-http-neo-70)). Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); [NEO-70 implementation plan](../../docs/plans/NEO-70-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md).
|
||
|
||
### Mission / quest reward → skill XP (NEO-43)
|
||
|
||
**E7.M2** quest/mission payout paths should call **`MissionRewardSkillXpGrant.GrantFromMissionReward`** with content-driven **`skillId`** and **`amount`**; the helper always uses **`sourceKind: mission_reward`** through **`SkillProgressionGrantOperations.TryApplyGrant`** (same validation, **`allowedXpSourceKinds`**, and persistence as **[NEO-38](#skill-progression-grant-neo-38)**). **Combat encounter** rewards must **not** use this path for default clears — gig XP stays separate ([`docs/game-design/progression.md`](../docs/game-design/progression.md)). Until the router exists, verify **`mission_reward`** with **`POST …/skill-progression`**. Plan: [NEO-43 implementation plan](../../docs/plans/NEO-43-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-43.md`](../../docs/manual-qa/NEO-43.md).
|
||
|
||
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
|
||
|
||
## Interactable descriptors (NEO-25)
|
||
|
||
**`GET /game/world/interactables`** returns a versioned **projection** of the prototype **`PrototypeInteractableRegistry`** (no player id; global list). Use this for client discovery of anchors, radii, and **`kind`** instead of duplicating registry constants in GDScript.
|
||
|
||
Example:
|
||
|
||
```bash
|
||
curl -s http://localhost:5253/game/world/interactables
|
||
```
|
||
|
||
**HTTP**
|
||
|
||
| Status | Meaning |
|
||
|--------|---------|
|
||
| **200** | Body is **`InteractablesListResponse`** v1: `schemaVersion` (`1`) + **`interactables`** array. |
|
||
|
||
**`interactables[]` fields (v1)**
|
||
|
||
| Field | Meaning |
|
||
|--------|---------|
|
||
| `interactableId` | Lowercase canonical id (matches **`POST …/interact`**). |
|
||
| `kind` | Stable machine string (`terminal`, `resource_node`, …). |
|
||
| `anchor` | Object with **`x`**, **`y`**, **`z`** (world meters). |
|
||
| `interactionRadius` | Horizontal reach on X/Z; inclusive boundary (same rule as NEO-9). |
|
||
|
||
Rows are sorted by ascending **`interactableId`**. Plan: [NEO-25 implementation plan](../../docs/plans/NEO-25-implementation-plan.md).
|
||
|
||
## Targeting (NEO-23)
|
||
|
||
Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9.
|
||
|
||
**Soft lock:** the server keeps the persisted **`lockedTargetId`** until clear or a successful swap. If the player moves out of stub range, **`validity`** becomes **`out_of_range`** on **`GET`** and on **`POST`** echoes until they move back in range, clear, or select another valid target (see [NEO-23 implementation plan](../../docs/plans/NEO-23-implementation-plan.md)).
|
||
|
||
Stub registry: `PrototypeTargetRegistry` in `NeonSprawl.Server/Game/Targeting/` — ids **`prototype_target_alpha`**, **`prototype_target_beta`** (ascending id order for future tab cycle).
|
||
|
||
**`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_target_alpha"}'
|
||
```
|
||
|
||
**HTTP**
|
||
|
||
| Status | Meaning |
|
||
|--------|---------|
|
||
| **200** | **GET:** `PlayerTargetStateResponse` v1. **POST:** `TargetSelectResponse` v1 with **`targetState`** always set; **`selectionApplied`** `true` or `false`; when `false`, required **`reasonCode`**. |
|
||
| **400** | Invalid v1 body (wrong `schemaVersion`, malformed JSON, or **`targetId`** whitespace-only when provided). |
|
||
| **404** | Unknown **player** only (same as interact). |
|
||
|
||
**`reasonCode` (POST v1, when `selectionApplied` is `false`)**
|
||
|
||
| Code | When |
|
||
|------|------|
|
||
| `unknown_target` | Id not in the prototype target registry (after trim + case-insensitive lookup). |
|
||
| `out_of_range` | Horizontal distance on X/Z is **greater than** the target’s `lockRadius` (on the radius is **allowed** — inclusive `<=`). |
|
||
|
||
When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success).
|
||
|
||
## Hotbar loadout (NEO-29)
|
||
|
||
Prototype server-owned hotbar bindings are available at:
|
||
|
||
- **`GET /game/players/{id}/hotbar-loadout`** → returns `HotbarLoadoutResponse` v1 with fixed `slotCount` **8** and `slots` array entries (`slotIndex`, nullable `abilityId`) for every slot.
|
||
- **`POST /game/players/{id}/hotbar-loadout`** with `HotbarLoadoutUpdateRequest` v1 (`schemaVersion`, `slots[]`) upserts one or more slot bindings and returns `HotbarLoadoutUpdateResponse` v1.
|
||
|
||
**Prototype policy (kickoff decision):**
|
||
|
||
- Scope is **per player id** (same keying strategy as `PositionState` / `TargetState`).
|
||
- Persistence mirrors NEO-8/NS-17: **Postgres when `ConnectionStrings:NeonSprawl` exists**, otherwise **in-memory fallback**.
|
||
|
||
**Validation deny reason codes (POST 200 with `updated=false`):**
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| `slot_out_of_bounds` | `slotIndex` is outside v1 range `0..7`. |
|
||
| `unknown_ability` | `abilityId` is empty/whitespace or not in the prototype allowlist. |
|
||
| `duplicate_slot` | Request repeats the same `slotIndex` more than once. |
|
||
|
||
Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`.
|
||
|
||
## Ability cast (NEO-31)
|
||
|
||
Prototype **cast intent** (no full combat resolution — see E5.M1). **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 **`PrototypeTargetRegistry`**, **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; on accept, starts the prototype global cooldown for that slot. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`).
|
||
|
||
**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 accept (NEO-32). |
|
||
|
||
Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above).
|
||
|
||
## Cooldown snapshot (NEO-32)
|
||
|
||
Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **Global duration** `AbilityPrototypeCooldown.GlobalDuration` applies on each successful cast after all NEO-28 gates pass.
|
||
|
||
- **`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`).
|