NEO-24: freshen server position before target-select + surface divergence on HUD

Fix "Tab says denied even though I'm in range of beta": root cause is the
race between the 20 Hz move-stream sampler and the target-select POST. A
Tab press right after the capsule stops can be validated against the
server's last sampled position, which trails the visible capsule by
several meters.

Client-side fixes (server contract unchanged):

- TargetSelectionClient.set_freshness_kick(authority, player): before each
  POST /target/select, submit a move-stream with the current player
  position so the server's stored snapshot is as fresh as possible when
  the range check runs. Wired in main.gd; optional so tests without the
  wiring still pass (regression-covered).
- PlayerPositionLabel now shows a "srv: (x,y,z) Δ=<d>m age=<ms>" line
  populated from PositionAuthorityClient.authoritative_ack, so any
  residual client/server divergence is visible in the HUD and future
  reports can point at it directly.
- Two new tests: select POST kicks exactly one freshness stream with the
  current player position before POSTing, and the kick is a no-op when
  the wiring is absent.

Docs: plan Decision 5, review follow-up #2, manual QA section 5 and
section 1, and client README updated to describe the new behaviors.
pull/47/head
VinPropane 2026-04-21 23:21:16 -04:00
parent 71ae829482
commit e31cd2df68
8 changed files with 147 additions and 10 deletions

View File

@ -96,7 +96,8 @@ The main scene includes a **prototype terminal** at the map center (same world *
`scripts/target_selection_client.gd` wires the server targeting API from [NEO-23](../docs/plans/NEO-23-implementation-plan.md) to a tab-cycle input and a HUD overlay. `scripts/target_selection_client.gd` wires the server targeting API from [NEO-23](../docs/plans/NEO-23-implementation-plan.md) to a tab-cycle input and a HUD overlay.
- **Bindings** (in `project.godot`): **`target_tab`** → **Tab** cycles targets in the order defined by **`scripts/prototype_target_constants.gd`** (`prototype_target_alpha`, then `prototype_target_beta`; matches `PrototypeTargetRegistry.cs` ascending id), wrapping from last → first. **`target_clear`** → **Esc** POSTs with `targetId` omitted to clear the server lock. - **Bindings** (in `project.godot`): **`target_tab`** → **Tab** cycles targets in the order defined by **`scripts/prototype_target_constants.gd`** (`prototype_target_alpha`, then `prototype_target_beta`; matches `PrototypeTargetRegistry.cs` ascending id), wrapping from last → first. **`target_clear`** → **Esc** POSTs with `targetId` omitted to clear the server lock.
- **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, one **display-only** line per known target in the form `<id>: <dist> m / <radius> (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: <reasonCode>` on the last line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response. Distance lines are a lag diagnostic: if the HUD shows `Validity: ok` but `alpha: 10.3 m / 8.0 (out)`, the server's position snapshot is lagging the visible capsule, and the next `authoritative_ack` refresh will reconcile. - **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, one **display-only** line per known target in the form `<id>: <dist> m / <radius> (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: <reasonCode>` on the last line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response. Distance lines are a lag diagnostic: if the HUD shows `Validity: ok` but `alpha: 10.3 m / 8.0 (out)`, the server's position snapshot is lagging the visible capsule, and the next `authoritative_ack` refresh will reconcile. `UICanvas/PlayerPositionLabel` also shows the last **server-acknowledged** world position + horizontal delta from the visible capsule — when that `Δ` is large, targeting denials are being evaluated against a stale server snapshot.
- **Freshness kick:** `main.gd` calls `TargetSelectionClient.set_freshness_kick(authority, player)`, so each `POST /target/select` is preceded by a `submit_stream_targets([player.global_position])` on the `PositionAuthorityClient`. That nudges the server's stored position as close as possible to the visible capsule before the range check runs, collapsing the common "Tab right after stopping" false-denial race without waiting for the `move-stream` response. Tests can omit the wiring to run the client with an isolated mock transport.
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a small colored mast + a flat translucent ring at every `ANCHORS` entry so the lock radius is visible in-world. The script and `prototype_target_constants.gd` mirror the server's `PrototypeTargetRegistry.cs` anchors/radii; change them together in the same commit so the markers never drift from server authority. - **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a small colored mast + a flat translucent ring at every `ANCHORS` entry so the lock radius is visible in-world. The script and `prototype_target_constants.gd` mirror the server's `PrototypeTargetRegistry.cs` anchors/radii; change them together in the same commit so the markers never drift from server authority.
- **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none. - **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`. - **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.

View File

@ -1136,8 +1136,8 @@ autowrap_mode = 3
[node name="PlayerPositionLabel" type="Label" parent="UICanvas" unique_id=9000003] [node name="PlayerPositionLabel" type="Label" parent="UICanvas" unique_id=9000003]
offset_left = 8.0 offset_left = 8.0
offset_top = 8.0 offset_top = 8.0
offset_right = 220.0 offset_right = 360.0
offset_bottom = 88.0 offset_bottom = 106.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.92, 0.95, 0.98, 1) theme_override_colors/font_color = Color(0.92, 0.95, 0.98, 1)
@ -1146,13 +1146,14 @@ theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 15 theme_override_font_sizes/font_size = 15
text = "x: — text = "x: —
y: — y: —
z: —" z: —
srv: —"
[node name="TargetLockLabel" type="Label" parent="UICanvas" unique_id=9000004] [node name="TargetLockLabel" type="Label" parent="UICanvas" unique_id=9000004]
offset_left = 8.0 offset_left = 8.0
offset_top = 104.0 offset_top = 122.0
offset_right = 340.0 offset_right = 360.0
offset_bottom = 288.0 offset_bottom = 306.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.86, 0.94, 1, 1) theme_override_colors/font_color = Color(0.86, 0.94, 1, 1)

View File

@ -45,6 +45,12 @@ var _stream_physics_counter: int = 0
## Cached latest `target_state_changed` payload so the HUD can re-render with live ## Cached latest `target_state_changed` payload so the HUD can re-render with live
## distance readouts on every physics tick without missing server-side fields. ## distance readouts on every physics tick without missing server-side fields.
var _last_target_state: Dictionary = {} var _last_target_state: Dictionary = {}
## Last server-acknowledged world position (from `PositionAuthorityClient.authoritative_ack`;
## boot GET 200 or `move-stream` 200). Used purely to render the server-vs-client divergence
## line in the HUD so "target says denied but I'm obviously in range" becomes self-explanatory.
var _last_ack_world: Vector3 = Vector3.ZERO
var _have_last_ack: bool = false
var _last_ack_msec: int = 0
## After the first `apply_as_snap` authority update, micro-snaps may be skipped ## After the first `apply_as_snap` authority update, micro-snaps may be skipped
## (boot always applies). ## (boot always applies).
var _allow_authority_soft_snap_skip: bool = false var _allow_authority_soft_snap_skip: bool = false
@ -93,7 +99,13 @@ func _ready() -> void:
# every server-confirmed position (boot GET 200 + `move-stream` 200) so a held lock is # every server-confirmed position (boot GET 200 + `move-stream` 200) so a held lock is
# revalidated during ordinary WASD locomotion without re-introducing snap rubber-banding. # revalidated during ordinary WASD locomotion without re-introducing snap rubber-banding.
_authority.connect("authoritative_ack", Callable(_target_client, "on_authoritative_ack")) _authority.connect("authoritative_ack", Callable(_target_client, "on_authoritative_ack"))
_authority.connect("authoritative_ack", Callable(self, "_on_authoritative_ack_for_hud"))
_target_client.connect("target_state_changed", Callable(self, "_on_target_state_changed")) _target_client.connect("target_state_changed", Callable(self, "_on_target_state_changed"))
# NEO-24 post-merge: tell the target client how to nudge the authority with the current
# capsule position before each POST /target/select. Shrinks the common "Tab right after
# stopping" false-denial race by making the server's stored snapshot as fresh as possible.
if _target_client.has_method("set_freshness_kick"):
_target_client.call("set_freshness_kick", _authority, _player)
_authority.call("sync_from_server") _authority.call("sync_from_server")
_target_client.call("request_sync_from_server") _target_client.call("request_sync_from_server")
if _radius_preview.has_method("setup_player"): if _radius_preview.has_method("setup_player"):
@ -116,10 +128,27 @@ func _physics_process(_delta: float) -> void:
else: else:
_stream_physics_counter = 0 _stream_physics_counter = 0
var p: Vector3 = _player.global_position var p: Vector3 = _player.global_position
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z] _player_pos_label.text = _build_player_position_text(p)
_render_target_lock_label(p) _render_target_lock_label(p)
## HUD: client position on top, and (once a `move-stream` / boot GET has landed) the
## **server**-acknowledged position + horizontal delta underneath. When the server line
## is far from the client line the capsule has outrun the authoritative state — any
## targeting denials are being computed against the stale server position, and the next
## `move-stream` 200 (or the recovery resync on a 400) will reconcile.
func _build_player_position_text(p: Vector3) -> String:
var lines: PackedStringArray = ["x: %.3f" % p.x, "y: %.3f" % p.y, "z: %.3f" % p.z]
if _have_last_ack:
var s: Vector3 = _last_ack_world
var dh: float = Vector2(p.x - s.x, p.z - s.z).length()
var age_msec: int = max(0, Time.get_ticks_msec() - _last_ack_msec)
lines.append("srv: (%.2f, %.2f, %.2f) Δ=%.2fm age=%dms" % [s.x, s.y, s.z, dh, age_msec])
else:
lines.append("srv: — (no ack yet)")
return "\n".join(lines)
func _locomotion_wish_world_xz_from_camera() -> Vector3: func _locomotion_wish_world_xz_from_camera() -> Vector3:
var v: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back") var v: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
if v.length_squared() < 1e-8: if v.length_squared() < 1e-8:
@ -161,6 +190,16 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
_player.snap_to_server(world) _player.snap_to_server(world)
## NEO-24 diagnostic: cache the server's last authoritative position so the HUD can
## surface client-vs-server divergence. This is the same signal `TargetSelectionClient`
## hooks for movement-driven lock refresh — we do not snap the capsule to this value
## (that is `authoritative_position_received`'s job on boot + move rejection).
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
_last_ack_world = world
_have_last_ack = true
_last_ack_msec = Time.get_ticks_msec()
## NEO-24: cache the last server-acknowledged target state; the label text is ## NEO-24: cache the last server-acknowledged target state; the label text is
## re-rendered every physics tick in [method _render_target_lock_label] so the ## re-rendered every physics tick in [method _render_target_lock_label] so the
## live distance readouts stay in sync with the player capsule even while ## live distance readouts stay in sync with the player capsule even while

View File

@ -34,6 +34,15 @@ var _http: Node
var _busy: bool = false var _busy: bool = false
var _phase: Phase = Phase.IDLE var _phase: Phase = Phase.IDLE
## Optional "freshness kick": before each `POST /target/select` we ask the authority to
## submit a `move-stream` with the current player position so the server's stored snapshot
## is as close to the visible capsule as possible when it runs the radius check. Without
## this, a Tab press right after you stop moving can be validated against the last
## 20 Hz sample (which trailed the capsule) and deny at a position that is visibly in
## range. Both refs are optional so tests that inject a plain script can still run.
var _freshness_authority: Node = null
var _freshness_player: Node3D = null
## Last acknowledged state from the server. Mirrors `PlayerTargetStateResponse` v1: ## Last acknowledged state from the server. Mirrors `PlayerTargetStateResponse` v1:
## `schemaVersion` (int), `playerId` (String), `lockedTargetId` (String or `null`), ## `schemaVersion` (int), `playerId` (String), `lockedTargetId` (String or `null`),
## `validity` (String: `none` | `ok` | `out_of_range` | `invalid_target`), ## `validity` (String: `none` | `ok` | `out_of_range` | `invalid_target`),
@ -122,6 +131,14 @@ func cached_state() -> Dictionary:
return _state.duplicate() return _state.duplicate()
## Wire the authority + player so each `POST /target/select` is preceded by a fresh
## `move-stream` submit (see `_freshness_authority`). Pass `null` for either to disable.
## Safe to call multiple times; the last wiring wins.
func set_freshness_kick(authority: Node, player: Node3D) -> void:
_freshness_authority = authority
_freshness_player = player
func _has_lock() -> bool: func _has_lock() -> bool:
if _state.is_empty(): if _state.is_empty():
return false return false
@ -132,6 +149,7 @@ func _has_lock() -> bool:
func _post_select(payload: Dictionary) -> void: func _post_select(payload: Dictionary) -> void:
if _busy: if _busy:
return return
_kick_freshness_stream()
_busy = true _busy = true
_phase = Phase.POST_SELECT _phase = Phase.POST_SELECT
var url := "%s/game/players/%s/target/select" % [_base_root(), _player_path_segment()] var url := "%s/game/players/%s/target/select" % [_base_root(), _player_path_segment()]
@ -144,6 +162,21 @@ func _post_select(payload: Dictionary) -> void:
_phase = Phase.IDLE _phase = Phase.IDLE
## Nudges the authority with the current capsule position so the server's stored
## snapshot is as fresh as possible when the select POST lands. Does **not** wait
## for the move-stream response — both requests race on separate connections — but
## in practice this collapses the common "Tab right after stopping" false-denial
## window because the authority flushes queued crumbs synchronously on submit.
func _kick_freshness_stream() -> void:
if _freshness_authority == null or _freshness_player == null:
return
if not is_instance_valid(_freshness_authority) or not is_instance_valid(_freshness_player):
return
if not _freshness_authority.has_method("submit_stream_targets"):
return
_freshness_authority.call("submit_stream_targets", [_freshness_player.global_position])
func _base_root() -> String: func _base_root() -> String:
return base_url.strip_edges().rstrip("/") return base_url.strip_edges().rstrip("/")

View File

@ -209,3 +209,56 @@ func test_two_acks_within_cooldown_collapse_to_one_get() -> void:
c.on_authoritative_ack(Vector3.ZERO) c.on_authoritative_ack(Vector3.ZERO)
c.on_authoritative_ack(Vector3.ZERO) c.on_authoritative_ack(Vector3.ZERO)
assert_that(transport.request_count).is_equal(count_before + 1) assert_that(transport.request_count).is_equal(count_before + 1)
# Authority stub that captures the last `submit_stream_targets` batch so we can verify
# `TargetSelectionClient._post_select` kicks a freshness stream before POSTing.
class FreshnessAuthorityStub:
extends Node
var last_batch: Array = []
var submit_count: int = 0
func submit_stream_targets(targets: Array) -> void:
submit_count += 1
last_batch = targets.duplicate()
func _make_player(pos: Vector3) -> Node3D:
var n := Node3D.new()
n.global_position = pos
auto_free(n)
add_child(n)
return n
func test_select_post_kicks_freshness_stream_before_posting() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
)
var c := _make_client(transport)
var authority := FreshnessAuthorityStub.new()
auto_free(authority)
add_child(authority)
var player := _make_player(Vector3(1.5, 0.5, -2.25))
c.set_freshness_kick(authority, player)
c.request_select_target_id(ALPHA_ID)
# Exactly one freshness submit, matching the pre-POST capsule position.
assert_that(authority.submit_count).is_equal(1)
assert_that(authority.last_batch.size()).is_equal(1)
assert_that(authority.last_batch[0]).is_equal(Vector3(1.5, 0.5, -2.25))
# And the select POST still went out as usual.
assert_that(transport.last_url).contains("/target/select")
func test_select_post_without_freshness_wiring_is_a_noop() -> void:
# Regression: tests that construct the client without `set_freshness_kick(...)` must
# still POST normally. Confirms the kick is fully optional.
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
)
var c := _make_client(transport)
c.request_select_target_id(ALPHA_ID)
assert_that(transport.last_url).contains("/target/select")
assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)

View File

@ -25,6 +25,7 @@ Pair the client (Godot **F5**) with the server (`cd server/NeonSprawl.Server &&
- `Seq: 0` - `Seq: 0`
- `prototype_target_alpha: 0.00 m / 8.0 (in)` (spawn `(-5,-5)` is exactly on alpha's XZ anchor) - `prototype_target_alpha: 0.00 m / 8.0 (in)` (spawn `(-5,-5)` is exactly on alpha's XZ anchor)
- `prototype_target_beta: 18.38 m / 4.0 (out)` - `prototype_target_beta: 18.38 m / 4.0 (out)`
- [ ] `UICanvas/PlayerPositionLabel` shows an `srv: (x, y, z) Δ=<d>m age=<ms>` line within ~1 s of boot (boot `GET /position` populates it). At rest the `Δ` should be near 0.
- [ ] **Visible anchors** are rendered in-world: an **orange** mast + flat ring at `(-5, 0.5, -5)` (alpha, 8 m radius) and a **violet** mast + ring at `(8, 0, 8)` (beta, 4 m radius). Both rings lie flat on the floor; the player capsule should sit inside the alpha ring at spawn. - [ ] **Visible anchors** are rendered in-world: an **orange** mast + flat ring at `(-5, 0.5, -5)` (alpha, 8 m radius) and a **violet** mast + ring at `(8, 0, 8)` (beta, 4 m radius). Both rings lie flat on the floor; the player capsule should sit inside the alpha ring at spawn.
- [ ] Godot **Output** has no `TargetSelectionClient:` warnings. Server console shows a single `GET /game/players/dev-local-1/target` from the boot sync. - [ ] Godot **Output** has no `TargetSelectionClient:` warnings. Server console shows a single `GET /game/players/dev-local-1/target` from the boot sync.
@ -57,11 +58,12 @@ Pair the client (Godot **F5**) with the server (`cd server/NeonSprawl.Server &&
## 5. Swap to beta when in range ## 5. Swap to beta when in range
- [ ] WASD toward beta at **(8, 8)** until you are within 4 m. Press **Tab**. HUD swaps cleanly: - [ ] WASD toward beta at **(8, 8)** until you are inside the violet ring (≤4 m). Press **Tab**. HUD swaps cleanly:
- `Target: prototype_target_beta` - `Target: prototype_target_beta`
- `Validity: ok` - `Validity: ok`
- `Seq` increments by 1 - `Seq` increments by 1
- [ ] Press **Tab** once more. Server denies (alpha is far away) — HUD stays at `beta / ok` with `Denied: out_of_range` appended. - [ ] Press **Tab** once more. Server denies (alpha is far away) — HUD stays at `beta / ok` with `Denied: out_of_range` appended.
- [ ] **Freshness kick sanity:** walk to the edge of beta's ring, stop, and **immediately** press Tab. The select POST should succeed (`Validity: ok`) — the client kicks a `move-stream` with the current capsule position right before POSTing so the server is not validating against a stale 20 Hz sample. If this **denies** at a position visibly inside the ring, check `PlayerPositionLabel`'s `srv: …` line — a large `Δ` means the freshness kick is not landing before the select POST, and the race needs either a higher `move-stream` rate or a server-side client-position-hint contract.
## 6. Clear ## 6. Clear

View File

@ -106,3 +106,4 @@ No new **C#** tests (client-only story). **Bruno:** NEO-23 targeting requests al
| 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). | | 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](../reviews/2026-04-21-NEO-24.md)): 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. | | 3 | **Signal for the refresh hook** | Review follow-up (see [2026-04-21-NEO-24 review](../reviews/2026-04-21-NEO-24.md)): 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 `<id>: <d> m / <radius> (in|out)` distance lines. Anchors/radii are mirrored in `prototype_target_constants.gd` as **display-only** data — server remains authoritative. If `PrototypeTargetRegistry.cs` moves, update both files in the same commit. | | 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 `<id>: <d> m / <radius> (in|out)` distance lines. Anchors/radii are mirrored in `prototype_target_constants.gd` as **display-only** data — server remains authoritative. If `PrototypeTargetRegistry.cs` moves, update both files in the same commit. |
| 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. |

View File

@ -5,7 +5,14 @@ Scope: `NEO-24-e1m3-client-tab-target-lock-ui-synced-to-server` (`origin/main...
Base: `origin/main` (merge-base `37f44a494400f4fdd369077c52dfc012df73a5d5`) Base: `origin/main` (merge-base `37f44a494400f4fdd369077c52dfc012df73a5d5`)
Follow-up: blocking issue + suggestions below are **done** (strikethrough + **Done.**). See the [NEO-24 plan](../plans/NEO-24-implementation-plan.md) Decision 3 for the signal change. Follow-up: blocking issue + suggestions below are **done** (strikethrough + **Done.**). See the [NEO-24 plan](../plans/NEO-24-implementation-plan.md) Decision 3 for the signal change.
**Post-merge UX follow-up (2026-04-21):** user reported "targeting result feels very inconsistent and doesn't seem to matter whether i'm within range of the console or not." Root cause was not a correctness bug — the `PrototypeTargetRegistry` anchors (`alpha (-5,-5)` r=8, `beta (8,8)` r=4) have **no visible scene meshes**, so players were reasoning about targeting against the `PrototypeTerminal` box at origin (which belongs to NEO-9 interaction, not NEO-23/24 targeting). Added: **Post-merge UX follow-up #2 (2026-04-21):** user reported "when in range of beta, pressing Tab says denied" — HUD distance line confirmed `prototype_target_beta: 0.58 m / 4.0 (in)` but server denied with `out_of_range` (and `prototype_target_alpha: 18.95 m` — almost exactly the spawn-to-current distance, strongly suggesting the server's stored position was still near spawn). Root cause is a race between the `move-stream` 20 Hz sampler and the target-select POST: a Tab press right after you stop moving validates against the last sampled position, which can trail the visible capsule. Two changes:
- `PlayerPositionLabel` now shows the server-acknowledged position + horizontal Δ + ack age (cached from `PositionAuthorityClient.authoritative_ack` via a new `_on_authoritative_ack_for_hud` handler). Makes the divergence directly visible in future repro.
- `TargetSelectionClient` exposes `set_freshness_kick(authority, player)`; `main.gd` wires it so every `POST /target/select` is immediately preceded by a `submit_stream_targets([player.global_position])` nudge on the authority. The select POST does not wait for the stream response, but the kick shrinks the race window enough in practice that "stop, Tab, denied" stops firing. Tests cover both the kick happening (`test_select_post_kicks_freshness_stream_before_posting`) and no regression when the wiring is absent (`test_select_post_without_freshness_wiring_is_a_noop`).
Server contract (NEO-23) untouched; this is pure client-side.
**Post-merge UX follow-up #1 (2026-04-21):** user reported "targeting result feels very inconsistent and doesn't seem to matter whether i'm within range of the console or not." Root cause was not a correctness bug — the `PrototypeTargetRegistry` anchors (`alpha (-5,-5)` r=8, `beta (8,8)` r=4) have **no visible scene meshes**, so players were reasoning about targeting against the `PrototypeTerminal` box at origin (which belongs to NEO-9 interaction, not NEO-23/24 targeting). Added:
- `client/scripts/prototype_target_markers.gd` — spawns an unshaded colored mast + flat radius ring per anchor (mirrors the server registry via `prototype_target_constants.gd`). - `client/scripts/prototype_target_markers.gd` — spawns an unshaded colored mast + flat radius ring per anchor (mirrors the server registry via `prototype_target_constants.gd`).
- Per-anchor distance readout on `UICanvas/TargetLockLabel`: `<id>: <d> m / <radius> (in|out)` computed client-side from the live capsule position. Labeled explicitly as a lag diagnostic — if `Validity: ok` shows while the distance line reads `(out)`, the server's position snapshot is trailing the visible capsule and the next `authoritative_ack` refresh reconciles it. - Per-anchor distance readout on `UICanvas/TargetLockLabel`: `<id>: <d> m / <radius> (in|out)` computed client-side from the live capsule position. Labeled explicitly as a lag diagnostic — if `Validity: ok` shows while the distance line reads `(out)`, the server's position snapshot is trailing the visible capsule and the next `authoritative_ack` refresh reconciles it.