141 lines
12 KiB
Markdown
141 lines
12 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.
|
||
|
||
## Position persistence (NS-17)
|
||
|
||
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 NS-15/NS-16 (**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 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 (Jira AC):** 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 (NS-15)
|
||
|
||
Authoritative player position is served over HTTP whether the backing store is **in memory** or **PostgreSQL** (NS-17). 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 NS-18 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 (NS-16, NS-19)
|
||
|
||
**`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.
|
||
|
||
**Client navigation (NS-23):** The Godot prototype uses a **baked navigation mesh** only for **presentation**; the client may follow waypoints when active, but **automatic obstacle detours on a single click are not guaranteed** (tradeoff for smooth movement on stepped geometry). The server does **not** simulate navmesh; it validates **straight-line** step limits (NS-19) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice.
|
||
|
||
**NS-19 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 | `1.25` |
|
||
| `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 a floor-level click yields **`vertical_step_exceeded`**; **far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`**. The client’s **`ground_pick.gd`** only accepts ray hits whose surface **normal** is mostly **upward** (`MIN_WALKABLE_NORMAL_DOT_UP`), so **vertical faces** on the pedestal do not emit move targets—this complements server per-step limits and prevents “stair-stepping” up walls.
|
||
|
||
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 NS-19 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/Jira-ready** contract blurb (formatted JSON + field table), see the [NS-15 implementation plan — Pull request description](../../docs/plans/NS-15-implementation-plan.md#pull-request-description-jira-ac) (GET shape) and [NS-16 implementation plan](../../docs/plans/NS-16-implementation-plan.md#pull-request-description-jira-ac--draft-for-merge) (MoveCommand + sequence semantics). NS-19: [NS-19 implementation plan](../../docs/plans/NS-19-implementation-plan.md).
|
||
|
||
## Interaction (NS-18)
|
||
|
||
**`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**.
|
||
|
||
Contract details and PR blurb: [NS-18 implementation plan](../../docs/plans/NS-18-implementation-plan.md).
|
||
|
||
## 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`).
|