neon-sprawl/server
VinPropane 29a6c6d1a7 NEO-41: address code review (E3.M1 status, deny-path test) 2026-05-10 19:13:56 -04:00
..
NeonSprawl.Server NEO-41: address code review (E3.M1 status, deny-path test) 2026-05-10 19:13:56 -04:00
NeonSprawl.Server.Tests NEO-41: address code review (E3.M1 status, deny-path test) 2026-05-10 19:13:56 -04:00
db/migrations NEO-39: add data-driven level curve with startup schema validation 2026-05-06 23:10:06 -04:00
README.md NEO-41: grant salvage activity XP on resource_node interact 2026-05-10 19:10:35 -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.

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:

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.

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; manual QA: docs/manual-qa/NEO-36.md; Bruno: bruno/neon-sprawl-server/skill-definitions/.

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; manual QA: docs/manual-qa/NEO-37.md; Bruno: bruno/neon-sprawl-server/skill-progression/Get skill progression.bru.

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 skills 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 split as hotbar: player_skill_progression in PostgreSQL when ConnectionStrings:NeonSprawl is set (DDL 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; manual QA: docs/manual-qa/NEO-38.md; Bruno: bruno/neon-sprawl-server/skill-progression/ (happy POST + deny smoke).

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_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 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 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 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, 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.

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

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.

Gather prototype → skill XP (NEO-41)

When allowed is true and the interactables 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) 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; manual QA: 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.

Contract details and PR blurb: NEO-9 implementation plan.

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:

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.

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

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:

curl -s http://localhost:5253/game/players/dev-local-1/target
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-snapshotCooldownSnapshotResponse 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 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).