361 lines
31 KiB
Markdown
361 lines
31 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.
|
||
|
||
## 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).
|
||
|
||
## 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). |
|
||
|
||
When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**.
|
||
|
||
### Gather prototype → skill XP (NEO-41)
|
||
|
||
When **`allowed` is `true`** and the interactable’s **`kind`** is **`resource_node`** (today: **`prototype_resource_node_alpha`** from **`GET /game/world/interactables`**), the server applies **10** **`salvage`** skill XP with **`sourceKind: activity`** using the **same validation and persistence** as **`POST /game/players/{id}/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)) via `SkillProgressionGrantOperations` — not a separate client-side XP bump. Move into horizontal range first (anchor **(12, −6)** m on X/Z, radius **3**). Plan: [NEO-41 implementation plan](../../docs/plans/NEO-41-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-41.md`](../../docs/manual-qa/NEO-41.md); Bruno: `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` then `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`.
|
||
|
||
### Craft / refine hook → skill XP (NEO-42)
|
||
|
||
**E3.M2** recipe/refine success paths should call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`**, which applies **10** **`refine`** skill XP with **`sourceKind: activity`** through **`SkillProgressionGrantOperations.TryApplyGrant`** (same validation, **`allowedXpSourceKinds`**, and persistence as **[NEO-38](#skill-progression-grant-neo-38)**). There is no separate craft HTTP in NEO-42; verify behavior with **`POST …/skill-progression`** for **`refine`**/**`activity`** until the craft module wires the helper. Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-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`).
|