19 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,PositionAuthorityClientemits a newauthoritative_ack(world)signal — fired on every server-confirmed position (bootGET200 and successfulmove-stream200). 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). Whyauthoritative_ackinstead ofauthoritative_position_received: the latter only fires on boot / move-rejection resync — normalmove-stream200s are intentionally silent to avoid RTT rubber-banding — so hooking the target refresh to it leaves the soft-lock path unreachable during WASD locomotion (blocking issue raised in 2026-04-21-NEO-24 review). The new signal carries only the world position (noapply_as_snapflag) and is emitted after every authoritative position regardless of whether the client snaps.
-
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_ack→target_selection_client.on_authoritative_ack(...)(the handler is a no-op when no lock is held, so boot is naturally skipped; 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. Post-merge follow-up (Decision 4) also adds World/PrototypeTargetMarkers (colored mast + translucent radius ring per anchor). |
client/scripts/main.gd |
Wire @onready target client + label; connect target_state_changed; trigger initial request_sync_from_server() after authority boot path; connect authoritative_ack → on_authoritative_ack (see Decision 2). Re-render TargetLockLabel each physics tick with live per-anchor distance lines from PrototypeTargetConstants (Decision 4). |
client/scripts/prototype_target_constants.gd |
Display-only mirror of PrototypeTargetRegistry.cs anchors + radii used by the markers and HUD distance readouts (Decision 4). |
client/scripts/prototype_target_markers.gd |
Spawns a visible mast + flat ring at every PrototypeTargetConstants.ANCHORS entry so players can reason about range visually (Decision 4). |
client/scripts/position_authority_client.gd |
Add authoritative_ack(world) signal and emit it from both BOOT_GET 200 and STREAM_POST 200 so held locks re-validate during normal WASD locomotion without re-introducing snap rubber-banding. Extracted _parse_world_from_response(...) helper to avoid duplicating JSON parse across the two paths. |
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_ack(...) while a lock is held fires a GET, while no lock is held fires nothing, and two back-to-back acks within cooldown produce one GET. Use MockHttpTransport pattern from position_authority_client_test.gd. |
client/test/position_authority_client_test.gd |
Extend the existing suite to assert that authoritative_ack is emitted on both BOOT_GET 200 and successful move-stream 200 (existing no-snap-on-stream-200 assertion stays). |
client/test/target_refresh_on_locomotion_test.gd |
Integration (added during review follow-up): wires a real PositionAuthorityClient + TargetSelectionClient with mock transports and the same signal wiring main.gd uses; asserts that with a lock held, a move-stream 200 produces exactly one throttled refresh GET on the target client, and that the same path is a no-op with no lock. Catches renames / mis-wires between the two scripts that the unit suites miss. |
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_ack (new signal — fires on every server-confirmed position, including move-stream 200) 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). |
| 3 | Signal for the refresh hook | Review follow-up (see 2026-04-21-NEO-24 review): the initial wiring hooked authoritative_position_received, which only fires on boot / rejection resync — normal move-stream 200s are intentionally silent to avoid RTT rubber-banding. A new authoritative_ack(world) signal was added to PositionAuthorityClient and emitted from both 200 paths so the movement-driven refresh is actually reachable during WASD. The snap signal kept its current semantics; the ack signal is pure heartbeat. |
| 4 | Post-merge UX: anchor visibility | Users reported "targeting feels inconsistent" because the PrototypeTargetRegistry anchors (alpha (-5,-5) r=8, beta (8,8) r=4) have no visible meshes — the only visible console in the scene is the NEO-9 PrototypeTerminal at origin, which is unrelated to targeting. Added client/scripts/prototype_target_markers.gd + PrototypeTargetMarkers node in main.tscn (colored mast + translucent flat ring per anchor) and extended TargetLockLabel with per-anchor `: m / (in |
| 5 | Post-merge UX: position-drift race | Follow-up report: "when in range of beta, pressing Tab says denied." Root cause is a race between move-stream 20 Hz sampling and the target-select POST — a Tab press right after you stop moving is validated against the last sampled position, which can trail the visible capsule by several meters. Two changes: (a) PlayerPositionLabel now renders a srv: (x,y,z) Δ=<d>m age=<ms> line from PositionAuthorityClient.authoritative_ack, making the server/client divergence directly visible; (b) TargetSelectionClient.set_freshness_kick(authority, player) (wired in main.gd) has the target client call authority.submit_stream_targets([player.global_position]) immediately before every POST /target/select, so the server's stored snapshot is as fresh as possible when the range check runs. The freshness kick does not wait on the stream 200 — both requests race on separate HTTP connections — but it shrinks the window enough in practice that "stop, Tab, denied" stops firing. Both pieces are pure client changes; the server contract (NEO-23) is untouched. |
| 6 | Post-merge UX: range-aware Tab cycle | Follow-up report: "tab here does not acquire target" while standing 2.41 m from beta (inside 4 m) with Δ=0 — server and client agreed on position but Tab still denied. Root cause: with no lock held, request_tab_next() returned ORDERED_IDS[0] = alpha (17.90 m away), not the anchor the user was visibly standing next to. Added PrototypeTargetConstants.next_in_range_id_after(current, world) which walks the cycle and returns the first anchor whose client-side horizontal distance ≤ radius; TargetSelectionClient.request_tab_next() prefers that pick when the player ref is wired (via the existing set_freshness_kick path) and falls back to next_id_after(current) when nothing is in range so the server still owns the denial reason. Pure client-side UX improvement; server contract untouched. The picker also skips the currently-locked id so Tab always expresses a swap intent — if the only in-range target is what you already have, the fallback picks the next cycle id and lets the server deny (preserves the NEO-23 soft-lock "Tab = visible denial when no swap possible" rule). |
| 7 | Prototype target geometry: shared radius + overlap | Follow-up request: make all targetable objects share a single radius and make the rings overlap in the test scene so Tab-flipping is observable without locomotion. Introduced PrototypeTargetRegistry.SharedLockRadius = 6.0 and moved anchors to alpha (-3, 0.5, -3) and beta (3, 0, 3) (distance ~8.49 m ⇒ ~3.5 m-wide overlap centered at origin). Spawn (-5, -5) keeps alpha in range (~2.83 m) and beta out (~11.31 m) so the existing manual QA "Tab-to-out-of-range = soft-lock denial" path still holds; walking to the origin puts both rings in range and Tab cleanly flips alpha ↔ beta. client/scripts/prototype_target_constants.gd mirrors the new values as display-only data (+ a SHARED_LOCK_RADIUS constant to match the server). Keep the two in lock-step; NEO-23 reader tests updated to the new boundary ((3, -3) for alpha radius 6). Per-target radii will return with real combat design (E5.M1); this is a prototype stub only. |
| 8 | Post-merge UX: positionHint on target/select (NEO-23 contract extension) |
Follow-up report: "targeted A, moved out of A range and into B range — Tab gets denied incorrectly" at Δ=0, age≈8 s. Root cause: the freshness kick from Decision 5 kicks a move-stream POST and the select POST on separate HTTP connections — nothing guarantees the server processes the stream first, so the select's range check can still run against the stale stored snap. Fix: extend TargetSelectRequest v1 with an optional positionHint: {x,y,z}; when present, the server uses it for the radius check and for the validity field in the response (both the soft-lock and applied-lock paths). The hint is purely advisory — it does not write to IPositionStateStore (move-stream stays the only write path) and remains trust-on-client for the prototype; E5.M1 combat will bound the hint against the stored snap / movement budget before shipping to players. Client wires the hint automatically when set_freshness_kick(...) is called (same player ref), so existing headless unit tests that skip that wiring continue to omit the hint. The freshness kick is kept in place to refresh the stored snap for subsequent validity GETs, but is no longer load-bearing for denial correctness. |