11 KiB
NEO-24 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NEO-24 |
| Title | E1.M3: Client tab-target + lock UI synced to server |
| Linear | NEO-24 |
| Slug | E1M3-02 |
| Git branch | NEO-24-e1m3-client-tab-target-lock-ui-synced-to-server |
| Parent context | Epic 1 — Core Player Runtime · E1M3 prototype backlog |
| Depends on | NEO-23 (targeting HTTP v1) |
| Decomposition | E1.M3 — InteractionAndTargetingLayer; authority notes: client_server_authority — E1.M3 |
Goal, scope, and out-of-scope
Goal: Tab (or agreed input action) cycles server-eligible stub targets in a documented deterministic order; HUD / debug overlay reflects the last server-acknowledged TargetState (lockedTargetId, validity, sequence). Client sends selection intent via existing POST …/target/select; optional optimistic presentation must reconcile to the authoritative targetState in each 200 response (including denials), per E1.M3 authority doc.
In scope
- Godot: input →
TargetSelectRequestv1 → parseTargetSelectResponse/PlayerTargetStateResponse; maintain last acknowledged target state for UI. - Tab cycle order: match server stub contract — lexicographic ascending id over the prototype registry ids (
prototype_target_alpha, thenprototype_target_beta), wrapping from last → first; same ordering as server README — Targeting /PrototypeTargetRegistrycomments. - Clear lock:
POSTwithtargetIdomitted or JSONnull(NEO-23 rule); binding documented inclient/project.godot(proposed: Esc viatarget_clear). - UI: readable label (or small overlay) showing server lock id (or none / empty),
validity, and optionallysequencefor debugging. - Soft lock / movement: when the player moves,
validitycan becomeout_of_rangewithout clearinglockedTargetId. Client must refresh authoritative state periodically or on hooks so the overlay stays truthful (see Technical approach). - Shared constants: duplicate stub target id list + order in a small GDScript constants module aligned with
PrototypeTargetRegistry(NEO-23 plan expectation for NEO-24 preview/cycle).
Out of scope (per Linear / backlog)
- Nearest-in-cone auto-target; sticky soft-target under latency.
AbilityCastRequest/ hotbar — E1.M4.- Server route or DTO changes (covered by NEO-23); no new ASP.NET endpoints unless a client bug reveals a contract gap (then narrow fix + plan update).
Acceptance criteria checklist
- With ≥2 stub targets, tab advances selection intent in ascending id order and wraps at the end of the ordered list. (
PrototypeTargetConstants.next_id_after+test_tab_from_no_lock_selects_first_id/test_tab_from_alpha_requests_beta/test_tab_wraps_from_beta_to_alpha.) - After each successful round-trip, UI shows
lockedTargetId(or—for none) andvalidityfrom the response body (targetStateon POST, body on GET). (TargetSelectionClient._extract_target_state_fields+main.gd:_on_target_state_changed;test_sync_get_200_emits_state.) - On
selectionApplied:false, UI shows authoritativetargetStatefrom the same response (no “stuck optimistic” lock id that the server denied). (_update_state_from_postreplaces cached state fromtargetStateon every 200, success or deny;test_denial_reflects_authoritative_target_state.) - Clear action clears lock server-side and UI updates from the response. (
request_clear_targetomitstargetId;test_clear_issues_post_without_target_id.) - Input bindings for tab cycle and clear are documented in
client/project.godotand inclient/README.md(new “Target lock + tab cycle (NEO-24)” section).
Server-up manual QA steps live in the README section above; run the server + client to exercise the soft-lock out-of-range flow end to end.
Technical approach
-
prototype_target_constants.gd(or rename if clearer): export orderedArray[String]of stub ids matchingPrototypeTargetRegistry(alpha,beta) for tab math only; single source for “eligible cycle list” on the client. -
target_selection_client.gd: Node analogous tointeraction_request_client.gd/position_authority_client.gd: owns injectable HTTP transport,base_url,dev_player_id,_busyguard. Responsibilities:request_sync_from_server()—GET …/target→ parsePlayerTargetStateResponsev1 → cache + emit signal.request_select_target_id(target_id: String)—POST …/target/selectwith body{"schemaVersion":1,"targetId":…}.request_clear_target()—POSTwith notargetId/nullper NEO-23.request_tab_next()— read cachedlockedTargetId+validity; compute next id in the ordered list (if no lock, choose first id in order); POST select; on deny, signal carries servertargetStateonly (no client-only lock).- Signal e.g.
target_state_changed(state: Dictionary)or typed fields for consumers (lockedTargetIdVariant string-or-null,validitystring,sequenceint,selection_appliedbool optional for last POST). - Refresh policy (hybrid, movement-triggered — Decision 2): no periodic polling. Issue
GET …/targetwhen (a) boot sync runs, (b) anyPOST …/target/selectcompletes (covered by response body already — no extra GET needed), and (c) while a lock is currently held, thePositionAuthorityClientemitsauthoritative_position_receivedafter boot (i.e. movement-driven snaps, not the initial boot snap). A small cooldown (e.g. 250 ms) coalesces bursts so many consecutivemove-streamacks cannot produce a GET per ack. No timer otherwise. Rationale: stays honest aboutout_of_rangewithout duplicating radius math on the client or holding an always-on polling timer. Trade-off: will not pick up purely server-driven state changes (none exist in NEO-23; revisit if combat/PvP adds them).
-
Optimistic highlight (optional slice): If implemented, keep pending selection separate from acknowledged state; on 200 denial, drop pending and paint from
targetState. If timeboxed, v1 may ship ack-only UI (still satisfies “optional optimistic” — none used). -
main.tscn/main.gd: Add child node withtarget_selection_client.gd; connect signal to a Label (newTargetLockLabelunderUICanvas). Callrequest_sync_from_server()once after boot position sync (or same frame as first authority ready) so HUD matches server on start. Also connectPositionAuthorityClient.authoritative_position_received→target_selection_client.on_authoritative_position_snap(...)(the client ignores the boot snap perapply_as_snapflag and only fires a GET when a lock is currently held; see step 2 refresh policy). -
project.godot:target_tab→ Tab;target_clear→ Escape (locked — Decision 1). -
Manual QA: Run server + client; tab between stubs; walk out of radius and confirm
validityflips toout_of_rangeon overlay while id remains; clear with Esc.
Files to add
| Path | Purpose |
|---|---|
client/scripts/prototype_target_constants.gd |
Ordered stub targetId list + helpers for “next in cycle” matching server registry. |
client/scripts/target_selection_client.gd |
HTTP GET/POST targeting APIs; tab/clear entrypoints; throttled refresh; signals for UI. |
client/test/target_selection_test_double.gd |
Test subclass injecting mock HTTP (pattern from position_authority_test_double.gd). |
client/test/target_selection_client_test.gd |
GdUnit4: tab order wrap, POST success path, denial keeps server lockedTargetId, clear POST, GET parse. |
Files to modify
| Path | Rationale |
|---|---|
client/scenes/main.tscn |
Add TargetSelectionClient node + TargetLockLabel (or reuse a debug panel) bound to scripts. |
client/scripts/main.gd |
Wire @onready target client + label; connect target_state_changed; trigger initial request_sync_from_server() after authority boot path. |
client/project.godot |
Register target_tab and target_clear input actions with defaults above. |
client/README.md |
One short subsection: tab-target prototype, bindings, and how to read the lock overlay (parity with interaction/move docs). |
Tests
| Test file | What to cover |
|---|---|
client/test/target_selection_client_test.gd |
Mock transport enqueues JSON: GET returns a v1 envelope; POST apply returns selectionApplied: true + targetState; POST deny returns selectionApplied: false + reasonCode + authoritative targetState; tab from no lock selects first id in order; tab from alpha requests beta; wrap from beta to alpha; clear issues POST without targetId; movement-triggered refresh: calling on_authoritative_position_snap(...) while a lock is held fires a GET, while no lock is held fires nothing, and two back-to-back snaps within cooldown produce one GET. Use MockHttpTransport pattern from position_authority_client_test.gd. |
No new C# tests (client-only story). Bruno: NEO-23 targeting requests already live under bruno/neon-sprawl-server/targeting/; no new .bru required unless implementation discovers a server contract change (then add per repo testing expectations for HTTP contract changes).
Open questions / risks
- Scene visuals: This story does not require 3D reticles on stub meshes; overlay text satisfies AC. World-space highlights can be a follow-up.
- Server-driven state changes: The hybrid refresh policy (Decision 2) will not pick up purely server-driven flips to
invalid_targetor future auto-clears — none exist in NEO-23, but if combat/PvP (E5.M1 / E6.M1) adds them later, revisit (either add a narrow server push or reintroduce a throttled poll).
Decisions
| # | Topic | Choice |
|---|---|---|
| 1 | Input bindings | target_tab → Tab; target_clear → Escape. Registered in client/project.godot. |
| 2 | Soft-lock refresh policy | Hybrid, movement-triggered. No periodic polling. Refresh sources: boot GET, each POST response echo, and GET fired from PositionAuthorityClient.authoritative_position_received (non-boot snaps) only while a lock is currently held, with a ~250 ms cooldown to coalesce bursts. Does not duplicate radius math on the client; does not cover future purely server-driven state changes (revisit when E5.M1 / E6.M1 introduce them). |