17 KiB
NEO-9 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NEO-9 |
| Title | E1.M1: InteractionRequest with server-side range check |
| Linear | NEO-9 |
| Parent context | Epic 1 — Core Player Runtime · E1.M1 InputAndMovementRuntime |
| Decomposition | E1.M1 — InputAndMovementRuntime (range / authority); seeds E1.M3 — InteractionAndTargetingLayer contracts |
Delivery: Ship docs/plans/NEO-9-implementation-plan.md on the same branch / PR as the NEO-9 implementation (plan + code pushed together).
Goal, scope, and out-of-scope
Goal: Server-authoritative answer to “can this player use this interactable now?” using the same position source of truth as movement (NEO-6 / NEO-7 / NEO-8).
Design decision — floor plane only (Y ignored for reach)
The game is 3D, but prototype interaction and movement tuning treat all meaningful play as on the floor. Reach / range checks use X and Z only; Y is ignored for distance (positions may still carry Y for rendering and future use). There is no vertical stacking, mezzanine, or multi-floor requirement in this story. When we add multiple floors or vertical gameplay, we will revisit distance rules (e.g. per-floor partition, vertical tolerance, or full 3D radius) and update contracts—out of scope until then.
In scope
- Shared distance helper (server library): single place for horizontal reach on X/Z only (Y not used for threshold), documented for reuse. Matches
player.gdhorizontal steering and keeps server logic aligned with “actions on the floor.” - Static prototype interactable table in code (id → world position + max interaction radius). One row is enough (e.g.
prototype_terminalnear the play space). - HTTP API: versioned
InteractionRequestJSON; server loads authoritativePositionStatefor{id}fromIPositionStateStore, compares to interactable position via the helper, returns allowed + optional small payload, or denied + stablereasonCodestring. - Godot: one placeholder interactable in the world (visible mesh + collision optional); minimal client path that POSTs an interaction for a fixed id after a key press (e.g. E) so QA can stand in range vs out of range without building full targeting.
Out of scope (per Linear)
- Inventory, rich UI prompts, full E1.M3 selection / hover / target-lock polish, multiple interactable types or data-driven defs beyond the static table.
Dependencies
- Authoritative
PositionState+ move path (NEO-6, NEO-7); optional Postgres (NEO-8) unchanged—interaction reads current store only.
Acceptance criteria checklist
- In range → 200 +
allowed: true, optionalpayload,reasonCodeomitted (exactly at radius counts as in range — inclusive<=). - Out of range → 200 +
allowed: falseand requiredreasonCode(e.g.out_of_range) — wheneverallowedisfalse,reasonCodemust always be present (non-empty). Document in DTO XML + README. - Moving the player via existing POST move → GET position flow changes the outcome—no client-only bypass (server always re-reads store).
- Client scene: placeholder interactable plus two or more small marker objects that glow (e.g. emission / bright modulate) when the player is within
interactionRadiuson X/Z (inclusive<=), and appear dim when out of range—so you can see proximity while walking without a floor ring. Glow logic is client preview only (see §6);POST /interactremains authoritative. - Unit tests for distance helper: at threshold, just inside, just outside (floating-point safe).
- Integration: unknown player → 404; unknown interactable → 200 +
allowed: false,unknown_interactable.
Technical approach
1. Distance helper (C#)
- New static or injectable type, e.g.
WorldReachorInteractionRangeMath, in a small folder underNeonSprawl.Server/Game/(e.g.Game/World/or next to interaction feature). HorizontalDistance(a, b)→doubleusing only X and Z (Y intentionally ignored per design decision above).IsWithinHorizontalRadius(player, target, radius)→bool— inclusive boundary:distance <= radiuscounts as in range (exactly on the radius is allowed). Deny only whendistance > radius.
2. Prototype interactable registry
- Static read-only map:
interactableId(string, lowercase canonical keys) → position (x,y,z) +interactionRadius(double).
Prototype defaults (source of truth: C# registry)
Authoritative values live in PrototypeInteractableRegistry.cs. Historical note: the prototype client originally duplicated anchors in prototype_interaction_constants.gd (NEO-9). NEO-25 replaced that with GET /game/world/interactables + runtime spawn; tune interactables by editing C# first, then verifying the GET JSON and client visuals.
| Value | Rationale | |
|---|---|---|
interactableId |
prototype_terminal |
Single prototype row. |
| World position | x = 0, y = 0.5, z = 0 |
Map center on X/Z (floor ±10); Y ~mid prop above floor. Default spawn is (-5, 0.9, -5) — horizontal distance to (0, 0) is √50 ≈ 7.07, so a short walk tests out of range → in range. |
interactionRadius |
3.0 |
~3 m reach on the floor plane; easy to see glow / E flip without covering the whole arena. |
interactableId matching (locked — default)
- Trim leading/trailing whitespace on the request value.
- Case-insensitive lookup: compare using ordinal ignore-case (or normalize to lowercase before dictionary lookup); registry keys remain lowercase.
- Missing
interactableId, wrong type, or empty after trim → 400 (notunknown_interactable). - After trim + case fold, if no registry entry → 200 +
allowed: false,reasonCode: unknown_interactable.
3. Request / response DTOs (JSON v1)
InteractionRequest(POST body):schemaVersion(1),interactableId(string, required).InteractionResponse(200 body):schemaVersion(1),allowed(bool). Field rules (locked):allowed == false:reasonCodeis required — non-empty stable string (unknown_interactable,out_of_range, …). Never return a denial without it.allowed == true:reasonCodeomitted (or explicitly documented as unused on success—clients must not require it).payloadoptional.- Do not use
reasonCodefor unknown player—that case is 404, not a JSON denial body.
- Route:
POST /game/players/{id}/interact— player id in path (trim + case-insensitive match consistent with position APIs).
HTTP status (locked)
- 400 — Request not processed as a valid v1 interaction attempt (missing/invalid
schemaVersion, malformed JSON, missinginteractableId,interactableIdempty after trim, etc.). - 404 — Unknown player only (no row in
IPositionStateStoreafter id normalization)—same spirit asGET/POSTposition APIs. - 200 — Request accepted and handled without throwing: body is always
InteractionResponsev1, includingallowed: false+reasonCodefor unknown interactable, out of range, or any other logical denial. No 403 for “denied” outcomes in v1.
4. Handler flow
- Validate JSON body and
schemaVersion; requireinteractableIdpresent and non-empty after trim → else 400. - Normalize
interactableIdfor lookup (trim + case-insensitive match against lowercase registry keys). - Normalize player id;
TryGetPositionfromIPositionStateStore. If missing → 404 (end; no interaction body). - Resolve interactable from static table; if missing → 200 +
allowed: false,reasonCode: unknown_interactable. - Else compute horizontal distance; if
> radius(boundary inclusive<=is in range) → 200 +allowed: false,reasonCode: out_of_range. - Else → 200 +
allowed: true+ optional placeholder payload.
5. Composition
- New
InteractionApi.MapInteractionApi(this WebApplication app)(or merge into a thinGameApilater);Program.csadds one line. - Inject or pass
IPositionStateStore+ registry (static for prototype).
6. Godot client (minimal)
- Add a visible placeholder for the interactable (e.g.
StaticBody3D+ mesh, orMeshInstance3D) at the same X/Z (and floor Y) as the server registry row (document inclient/README.md). - Radius QA — glowing markers (required for manual test): Add two or more decorative meshes (small boxes, orbs, pylons—no collision needed) near the interactable so they read clearly in the isometric view. A small script (e.g.
interaction_radius_indicators.gd) each frame (or when the player moves) compares the authoritative player position — theCharacterBody3Dglobal_positionafter server snap — to the interactable anchor in X/Z only, using the sameinteractionRadiusand inclusive<=rule as the server. When in range, bump StandardMaterial3D.emission (or unshaded + modulate) on those meshes to glow; when out of range, dim them. This is preview-only for human testing;POST /interactstill decides allow/deny. Document inclient/README.md: glow usesprototype_interaction_constants.gd— same numbers as §2 prototype defaults /PrototypeInteractableRegistry.cs(see table above). - New small script (e.g.
interaction_request_client.gd) or extendposition_authority_client.gd: on key (e.g.ui_accept/ E),POST/game/players/{dev_player_id}/interactwith fixedinteractableId; printallowed/reasonCodeto Output for prototype verification (no HUD required). main.gd: wire exports/node paths for player, interactable anchor, indicator script, interaction client; keep thin composer rule (godot-client-script-organization.md).
7. Documentation
server/README.md: subsection Interaction (NEO-9) — endpoint, curl example,reasonCodetable, note on horizontal distance.docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md(and optionallydocumentation_and_implementation_alignment.md): one-line snapshot that InteractionRequest + range check landed (NEO-9).
Files to add
| Path | Purpose |
|---|---|
server/NeonSprawl.Server/Game/Interaction/InteractionRequest.cs |
Versioned POST body DTO + XML. |
server/NeonSprawl.Server/Game/Interaction/InteractionResponse.cs |
Versioned response DTO; XML: reasonCode required when allowed is false; omit on success. |
server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs |
Static id → position + radius. |
server/NeonSprawl.Server/Game/World/HorizontalReach.cs (or under Interaction/) |
Shared horizontal distance + within-radius helpers. |
server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs |
MapInteractionApi route registration. |
server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs |
Boundary unit tests. |
server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs |
Integration tests via WebApplicationFactory. |
client/scripts/interaction_request_client.gd (or name aligned with repo) |
HTTP POST interact + debug print. |
client/scripts/prototype_interaction_constants.gd |
const anchor X/Z/Y + interaction_radius + id string — must match PrototypeInteractableRegistry.cs. |
client/scripts/interaction_radius_indicators.gd (or merged name) |
Drive emission/modulate on marker meshes from player snap position vs prototype_interaction_constants. |
Files to modify
| Path | Rationale |
|---|---|
server/NeonSprawl.Server/Program.cs |
Register MapInteractionApi(). |
client/scenes/main.tscn |
Add prototype interactable + 2+ glow marker meshes (no collision); materials suitable for emission toggle. |
client/scripts/main.gd |
Compose interaction client (signals/exports). |
client/README.md |
NEO-9 manual check: markers glow in-range / dim out-of-range (preview = same XZ+radius as server); walk + E; compare Output to glow. |
server/README.md |
Interaction API + reason codes + distance rule. |
docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md |
Implementation snapshot for NEO-9. |
docs/decomposition/modules/documentation_and_implementation_alignment.md |
Optional tracking row update for E1.M1. |
Tests
- Unit:
HorizontalReach/ distance helper—exactly at radius → in range (<=); just outside (slightly> radius) → out of range; document float tolerance if any. - Integration: seeded player in range → 200,
allowed: true, noreasonCode(or null per serializer policy—document); out of range / unknown interactable → 200,allowed: false, non-emptyreasonCode;interactableIdmissing or whitespace-only → 400; unknown player → 404; POST move then POST interact updates outcome. - Manual: Godot F5 + server; walk until markers glow (in range) vs dim (out); press interact on E; confirm Output
allowed/reasonCodematches (glow ⇒ expect allow at same constants; if mismatch, fix shared numbers or server bug).
Deferred (not this story)
- Multi-floor / vertical placement: Revisit distance math, interactable placement, and any Y-aware rules when stacked levels matter; until then floor-plane reach only.
Pull request description (Linear AC) — draft for merge
InteractionRequest v1
- Endpoint:
POST /game/players/{id}/interact - HTTP:
404= unknown player only.400= malformed / invalid v1 request. Any handled attempt for a known player returns200withInteractionResponse(includingallowed: falseandreasonCode). - Body (example):
{
"schemaVersion": 1,
"interactableId": "prototype_terminal"
}
| JSON property | Type (v1) | Meaning |
|---|---|---|
schemaVersion |
integer | Must be 1 for v1. |
interactableId |
string | Prototype interactable id (server static table). |
interactableId rules (v1): Trim whitespace; case-insensitive lookup vs lowercase registry keys. Missing or empty after trim → 400. Unknown id after normalization → 200 + unknown_interactable.
InteractionResponse v1 (example allowed)
{
"schemaVersion": 1,
"allowed": true,
"interactableId": "prototype_terminal",
"payload": { "kind": "placeholder" }
}
InteractionResponse v1 (example denied)
{
"schemaVersion": 1,
"allowed": false,
"reasonCode": "out_of_range"
}
reasonCode (v1) |
When | HTTP |
|---|---|---|
out_of_range |
Horizontal distance > interactionRadius (on the radius is allowed — inclusive <=). |
200 |
unknown_interactable |
Id not in prototype table. | 200 |
Not in body: Unknown player → 404 (no InteractionResponse). Malformed request → 400.
allowed: false: body must include reasonCode. allowed: true: omit reasonCode; payload optional.
Distance rule (v1): Horizontal distance on X/Z only; in range iff sqrt(dx² + dz²) <= interactionRadius (inclusive boundary). Y is not used for reach (floor-level actions; multi-floor is a future change).
Note for reviewers — dotnet test and Postgres
The test project uses committed postgres.runsettings (RunSettingsFilePath in NeonSprawl.Server.Tests.csproj), so ConnectionStrings__NeonSprawl is always set during dotnet test / Rider. The three Postgres integration tests execute (they do not skip). Without a reachable database—and without local Docker for the harness to run docker compose up—those tests fail. That is intentional; CI supplies Postgres via the workflow service container. See server/README.md (Postgres integration tests + Contributors / PRs).
Paste or adapt when opening the PR; align field names with final DTOs if they differ slightly.