neon-sprawl/server/README.md

308 lines
23 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 **`*.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.
## 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"
```
## 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 three 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 **three 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). |
When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**.
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 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)
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 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). **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`).