neon-sprawl/server
VinPropane 7e17ff563b chore: point docs and rules at Linear NEO issue ids
Migrate Jira NEON-* references to Linear NEO-* and linear.app links in
docs, Cursor rules, READMEs, and traceability comments. Renamed
implementation plans and dated reviews to NEO filenames.

Branch is based on origin/main with this commit only (no NEON-29
gameplay chain). client/README follows main’s district/map copy;
player.gd matches main aside from NEO-14 debug trace prefixes.
2026-04-13 13:15:32 -04:00
..
NeonSprawl.Server chore: point docs and rules at Linear NEO issue ids 2026-04-13 13:15:32 -04:00
NeonSprawl.Server.Tests NEON-15: dispose shared NpgsqlDataSource on host shutdown 2026-04-08 21:42:33 -04:00
db/migrations NS-17: PostgreSQL position persistence and integration tests 2026-03-30 22:55:17 -04:00
README.md chore: point docs and rules at Linear NEO issue ids 2026-04-13 13:15:32 -04:00

README.md

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

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 (NEO-8)

When ConnectionStrings:NeonSprawl is set to a valid PostgreSQL connection string, the server uses the player_position table (relational columns pos_xsequence, not JSONB). DDL lives in server/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 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). 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, 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), 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):

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):

{"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.

Client navigation (NEO-11): 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 (NEO-10) from the last authoritative position to the requested target. 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 1.25
Game:MovementValidation:DistrictBoundsEnabled Axis-aligned box on target (inclusive min/max) false
Game:MovementValidation:DistrictMinXDistrictMaxZ 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 a floor-level click yields vertical_step_exceeded; far pad is beyond default horizontal reach for horizontal_step_exceeded. The clients 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):

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 (GET shape) and NEO-7 implementation plan (MoveCommand + sequence semantics). NEO-10: NEO-10 implementation plan.

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):

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.

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 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 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).