15 KiB
NEO-25 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NEO-25 |
| Title | E1.M3: InteractableDescriptor list / projection (multi-entity) |
| Linear | NEO-25 |
| Slug | E1M3-03 |
| Git branch | NEO-25-e1m3-interactable-descriptor-list |
| Parent context | Epic 1 — Core Player Runtime · E1M3 prototype backlog |
| Decomposition | E1.M3 — InteractionAndTargetingLayer |
Goal, scope, and out-of-scope
Goal: The server exposes a versioned JSON snapshot of InteractableDescriptor rows for ≥2 prototype interactables so the client can discover anchors, radii, and kind without treating GDScript as the long-term registry. The existing NEO-9 POST /game/players/{id}/interact path remains authoritative per id (horizontal X/Z reach, inclusive radius).
In scope
- Registry: Extend
PrototypeInteractableRegistrywith a second row: stable idprototype_resource_node_alpha, world anchor(12, 0.5, −6)(open floor; avoidsObstacleinmain.tscnat ~(6, 1, 5)),interactionRadius(same 3.0 m as the terminal unless testing needs otherwise — document in registry comments),kindstringresource_node. Keepprototype_terminalat(0, 0.5, 0),kindterminal, radius 3.0 (unchanged NEO-9 defaults). - HTTP:
GET /game/world/interactablesreturnsInteractablesListResponsev1: top-levelschemaVersion,interactablesarray of descriptors. Each element includesinteractableId(lowercase canonical),kind,anchor(x,y,z),interactionRadius. Stable JSON order: ascendinginteractableIdso tests and docs stay deterministic (prototype_resource_node_alphabeforeprototype_terminal). - Client (Decision — option A): On boot (after or in parallel with position authority wiring — see Technical approach),
GETthe list and treat it as the only source for anchor/radius/kind used by preview glow and world meshes. Removeprototype_interaction_constants.gd; no duplicated anchor/radius constants for gameplay preview. - Client visuals: For each descriptor, spawn one visible prop mesh at the anchor (distinct materials by
kindfor readability) plus two small glow marker meshes per interactable (same emission pattern as today’sinteraction_radius_indicators.gd, but N interactables × 2 markers). Preview still compares playerglobal_positionto each descriptor’s anchor on X/Z only with inclusive<=vs that row’sinteractionRadius— serverPOST …/interactremains authoritative. - Client interaction QA: Two input actions so each id is explicitly reachable without cycling ambiguity:
interact(default E) →prototype_terminal;interact_secondary(default R) →prototype_resource_node_alpha. Document inclient/project.godotandclient/README.md. - Tests: C# integration tests for the new GET (schema, count ≥ 2, field presence, ordering). Extend or add interaction tests proving
POST …/interactallows each id when the seeded player is moved in range and denies when out of range. - Bruno: New request(s) under
bruno/neon-sprawl-server/for the GET (per testing-expectations). - Manual QA: During implementation, add
docs/manual-qa/NEO-25.mdper planning-implementation-docs.
Out of scope (per Linear / backlog)
- Full data-driven content pipeline (contracts.md content kind).
- Gather loop / rewards (E3.M1).
- Per-player or zone-filtered lists (registry is global; route is world-scoped intentionally — future visibility can add query params or a different resource without breaking this v1 list).
positionHinton interact (NEO-24-style) — not required for NEO-25; interact still readsIPositionStateStoreonly.
Acceptance criteria checklist
- ≥2 interactables in
PrototypeInteractableRegistrywith distinct ids, anchors, radii, and kinds (terminalvsresource_node). GET /game/world/interactablesreturns versioned JSON whoseinteractablesentries each includeinteractableId,anchor(world),interactionRadius,kind.POST /game/players/{id}/interactwithInteractionRequestv1 succeeds for each id when the player is in horizontal range; denies with stablereasonCodewhen out of range (NEO-9 semantics preserved).- Godot: preview glow + scene props are driven from the fetched descriptor list (no
prototype_interaction_constants.gd).interact/interact_secondaryeach hit the correct id for manual QA. - Bruno + server README document the new GET.
Technical approach
-
C# registry model: Extend
PrototypeInteractableEntry(or equivalent) withKind(string, stable machine valuesterminal,resource_nodefor v1). AddIReadOnlyList<InteractableDescriptorDto>or a staticGetAllDescriptors()that projects registry rows in ascending id order for the HTTP handler. -
DTOs: New types under
NeonSprawl.Server/Game/Interaction/(e.g.InteractableDescriptorResponse,InteractablesListResponse) with XML/summary comments matching the README field table. ReusePositionVectorfrom targeting if it already serializes{x,y,z}cleanly; otherwise a smallAnchorVectorDTO co-located with interactables. -
Route registration:
app.MapGet("/game/world/interactables", …)— no player id; no store dependency (static registry only). If we later need player-scoped visibility, add a new route rather than overloading this one. -
Godot catalog client: New script (e.g.
interactables_catalog_client.gd) with the same HTTP patterns astarget_selection_client.gd/interaction_request_client.gd:HTTPRequest,_busy,base_url,request_catalog()on_readyor whenmain.gdtriggers after first frame. Signalcatalog_ready(descriptors: Array)where each element is aDictionarymirroring JSON (or a small typed helper). On failure:push_error+ leaveInteractionMarkersempty (document: run server first for full scene). -
World build-out: Replace the static
World/NavigationRegion3D/PrototypeTerminal+ staticWorld/InteractionMarkers/MarkerA|Bpattern with either:- Preferred: an empty
World/InteractablesRootNode3D+ child scriptinteractable_scene_builder.gd(or merged into catalog client) that oncatalog_readyspawnsStaticBody3D(walkable group) + mesh per descriptor atanchor, and spawns twoMeshInstance3Dmarkers per descriptor underWorld/InteractionMarkers, then calls into the existing per-frame glow logic (refactorinteraction_radius_indicators.gdinto aNode3Dthat owns arrays of marker nodes + descriptor metrics, or one material per interactable row). - Scene cleanup: Remove hard-coded terminal + two markers from
main.tscnonce runtime spawn is wired; keepmain.gdthin — connect catalog → builder →interaction_radius_indicatorsequivalent.
- Preferred: an empty
-
main.gdsequencing: Callrequest_catalog()early enough that glow has data before the player walks (e.g. same_readyblock after nav bake, before or aftersync_from_server— if after, document one-frame dim state). EnsureInteractionRequestClientdoes not POST until catalog is ready or hard-bind the two actions to the stable ids above (catalog still drives visuals; keys use fixed id strings matching server contract). -
Docs:
server/README.md— subsection Interactable descriptors (NEO-25) with curl example and field table.client/README.md— replace NEO-9 “constants file” language with “fetched fromGET /game/world/interactables”. Optionally one line in E1_M3_InteractionAndTargetingLayer.md implementation snapshot when the story merges.
Decisions (kickoff — locked)
| Topic | Choice |
|---|---|
| List route | GET /game/world/interactables (global registry projection). |
| Second row | prototype_resource_node_alpha @ (12, 0.5, −6), kind: resource_node, radius 3.0 (moved from +XZ prototype to stay clear of scene Obstacle). |
| Client registry source | Option A: fetch list on boot; no prototype_interaction_constants.gd. |
| Client visuals | Full parity: one prop + two glow markers per descriptor. |
| Interaction keys | interact → terminal; interact_secondary → resource node id. |
Files to add
| Path | Purpose |
|---|---|
server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs |
Versioned InteractablesListResponse + nested InteractableDescriptor JSON shape for GET. |
server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs |
MapInteractablesWorldApi — registers GET /game/world/interactables. |
client/scripts/interactables_catalog_client.gd |
HTTP GET catalog; cache; catalog_ready signal. |
client/scripts/interactable_world_builder.gd |
On catalog: spawn walkable meshes + marker children from descriptor data (keeps main.gd thin per godot-client-script-organization). |
client/test/interactables_catalog_client_test.gd |
GdUnit: mock transport returns JSON; assert parse + ordering + field extraction. |
client/test/interactables_catalog_test_double.gd |
Optional: mock HTTP helper mirroring other test doubles. |
server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs |
Integration: GET returns 200, schema 1, ≥2 items, ascending ids, required fields. |
bruno/neon-sprawl-server/interaction/Get interactables list.bru |
(Or under bruno/neon-sprawl-server/world/ if we prefer grouping) — documents GET contract. |
docs/manual-qa/NEO-25.md |
Story manual QA checklist (generated during implementation per project rule). |
Files to modify
| Path | Rationale |
|---|---|
server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs |
Add second entry + Kind on entries; expose enumeration / projection for GET; update XML to drop “sync GDScript constants” wording in favor of NEO-25 GET. |
server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs |
No path change to POST; only if shared helpers are needed for descriptor projection (otherwise leave untouched). |
server/NeonSprawl.Server/Program.cs |
Register MapInteractablesWorldApi() (or equivalent one-liner). |
server/README.md |
Document NEO-25 GET + example JSON + field table next to Interaction (NEO-9). |
server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs |
Add/adjust cases: interact allowed for each id in range; unknown id unchanged; optional ordering smoke for registry if useful. |
client/scripts/interaction_radius_indicators.gd |
Refactor to multi-descriptor input (arrays / setup from builder) or merge into builder with the same emission rules as today. |
client/scripts/interaction_request_client.gd |
Remove preload of deleted constants; wire interact / interact_secondary to the two stable ids; optionally block POST until catalog loaded (minimal: document that keys use contract ids). |
client/scripts/main.gd |
Instantiate/connect InteractablesCatalogClient, call request_catalog, connect catalog_ready → world builder + indicator setup; remove references to deleted constants script. |
client/scenes/main.tscn |
Remove static PrototypeTerminal + static MarkerA/MarkerB once runtime spawn replaces them; add InteractablesCatalogClient node + empty roots as needed. |
client/project.godot |
Register interact_secondary (default R). |
client/README.md |
NEO-9/NEO-25 manual flow: fetch-driven preview, dual keys, server-first tuning. |
docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md |
Optional one-line implementation snapshot update when NEO-25 merges (descriptor list live). |
Files to remove
| Path | Rationale |
|---|---|
client/scripts/prototype_interaction_constants.gd |
Superseded by server-backed catalog (Option A); delete .gd and .uid companion if present. |
Tests
| Test file | What to cover |
|---|---|
server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs |
GET returns 200; schemaVersion 1; interactables length ≥ 2; sorted by interactableId; each entry has kind, anchor, interactionRadius; spot-check values for prototype_terminal and prototype_resource_node_alpha. |
server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs |
For each registry id: seed player position in range → allowed: true; move out of range → allowed: false, reasonCode: out_of_range; preserve 400/unknown interactable behaviors from NEO-9. |
client/test/interactables_catalog_client_test.gd |
Parse happy-path JSON; assert signal payload shape and stable ordering. |
client/test/interaction_request_client_test.gd |
Add if missing: when adding/changed interaction_request_client.gd, per testing-expectations — mock HTTP; assert correct interactableId in POST bodies for E vs R actions. (If file does not exist yet, create minimal suite alongside the client change.) |
Manual: docs/manual-qa/NEO-25.md — server + client; confirm GET in browser/curl; walk to each anchor; glow per row; E / R posts match allowed / reasonCode in Output.
Open questions / risks
- Boot without server: Descriptor GET fails → empty interactables / error log. Acceptable for prototype; document in README (run server before F5 for full layout).
- Collision / nav: New
StaticBody3Dprops must remainwalkable(or not block nav) — place on floor; use same collision pattern as the oldPrototypeTerminal. - Id strings in
interaction_request_client.gd: Stableinteract/interact_secondarybindings reference canonical ids as literals — not geometry duplication, but if registry ids rename, update client + tests together.