11 KiB
Review — NEO-24
Date: 2026-04-21
Scope: NEO-24-e1m3-client-tab-target-lock-ui-synced-to-server (origin/main...HEAD)
Base: origin/main (merge-base 37f44a494400f4fdd369077c52dfc012df73a5d5)
Follow-up: blocking issue + suggestions below are done (strikethrough + Done.). See the NEO-24 plan Decision 3 for the signal change.
Post-merge UX follow-up #3 (2026-04-21): user reported "tab here does not acquire target" while standing 2.41 m from beta (well inside the 4 m ring) with Δ=0.00m age=2357ms — i.e. client and server agreed on position. Server denied with out_of_range. Root cause was the Tab cycle: with no lock held, request_tab_next() returned ORDERED_IDS[0] = prototype_target_alpha, which was 17.90 m away. The user was visibly next to beta but Tab was always trying alpha first. Fix: PrototypeTargetConstants.next_in_range_id_after(current, world) 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 and falls back to next_id_after(current) when nothing is in range (so the server still owns the denial reason). Server contract untouched. Tests: test_tab_from_no_lock_skips_out_of_range_and_picks_beta, test_tab_from_no_lock_with_nothing_in_range_falls_back_to_first_id, test_tab_without_player_ref_still_uses_plain_cycle_order.
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:
PlayerPositionLabelnow shows the server-acknowledged position + horizontal Δ + ack age (cached fromPositionAuthorityClient.authoritative_ackvia a new_on_authoritative_ack_for_hudhandler). Makes the divergence directly visible in future repro.TargetSelectionClientexposesset_freshness_kick(authority, player);main.gdwires it so everyPOST /target/selectis immediately preceded by asubmit_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 viaprototype_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 — ifValidity: okshows while the distance line reads(out), the server's position snapshot is trailing the visible capsule and the nextauthoritative_ackrefresh reconciles it. - Updated
client/README.mdanddocs/manual-qa/NEO-24.md(Section 1 boot state and Section 4 locomotion check) to describe the markers and distance lines.
Verdict
Request changes — Addressed. Blocking issue fixed; integration test added; doc-alignment row updated.
Summary
This branch adds the client-side target selection node, HUD label, input bindings, tests, and story docs for NEO-24. The overall direction matches the E1.M3 plan well: selection intent is POSTed to the server, the HUD paints only server-acknowledged target state, and denial handling is intentionally authoritative.
Follow-up verification confirms the earlier review findings were addressed. The movement-triggered soft-lock refresh is now reachable during normal locomotion via PositionAuthorityClient.authoritative_ack, the new integration test covers the real authority-to-target wiring, and the E1.M3 implementation-alignment row was updated to reflect NEO-24 landing.
Documentation checked
docs/plans/NEO-24-implementation-plan.md— matches. Decision 2 now usesPositionAuthorityClient.authoritative_ack, and Decision 3 records why the new signal was added.docs/manual-qa/NEO-24.md— matches. Section 4’s locomotion-driven refresh path is now implemented by the new authority ack signal and target refresh wiring.docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md— matches for server-authoritative target state and denial reconciliation.docs/decomposition/modules/client_server_authority.md— matches for client-as-intent / server-as-truth behavior.docs/decomposition/modules/module_dependency_register.md— matches.E1.M3remainsIn Progress.docs/decomposition/modules/documentation_and_implementation_alignment.md— matches. TheE1.M3tracking row now reflects NEO-24 landed (HUD +authoritative_ackrefresh) and points back to the plan/manual QA docs.
Blocking issues
-
The movement-driven refresh path described by the plan, README, and manual QA is not reachable during normal locomotion.Done. Added a separatemain.gdconnectsTargetSelectionClient.on_authoritative_position_snap()toPositionAuthorityClient.authoritative_position_received, butposition_authority_client.gdonly emits that signal from_emit_position_from_response()duringsync_from_server()and the move-rejection recovery path. Successfulmove-streamPOSTs explicitly do not emit the signal, so walking around with a held lock never triggers the throttledGET /targetrefresh that NEO-24 depends on. As a result, the HUD can stay stuck atValidity: okafter the player walks out of range, which breaks the plan's Decision 2, the acceptance criterion for soft-lock refresh, and manual QA section 4.authoritative_ack(world)signal onPositionAuthorityClientthat fires on bothBOOT_GET200 and successfulmove-stream200 (extracted a shared_parse_world_from_responsehelper).authoritative_position_receivedkeeps its current snap-only semantics so rubber-band suppression is untouched.TargetSelectionClient.on_authoritative_ack(world)replaceson_authoritative_position_snap; cooldown + lock gate unchanged.main.gdconnects to the new signal. Seeclient/scripts/position_authority_client.gd,target_selection_client.gd,main.gd, and plan Decision 3.Relevant behavior in
client/scripts/position_authority_client.gd:match _phase: Phase.BOOT_GET: _busy = false if response_code == 200: _emit_position_from_response(text, true) Phase.STREAM_POST: ... if response_code == 200: # Do not emit `authoritative_position_received` here. passMeanwhile
client/scripts/target_selection_client.gdonly refreshes from that signal:func on_authoritative_position_snap(_world: Vector3, apply_as_snap: bool) -> void: if not apply_as_snap: return if not _has_lock(): return ... request_sync_from_server()The fix needs to either emit a suitable movement/reconciliation signal during normal locomotion or hook the target refresh to another authoritative movement event that actually occurs while moving.
Suggestions
Add an integration-level test for the real movement/targeting wiring. The currentDone. Addedclient/test/target_selection_client_test.gdsuite callson_authoritative_position_snap()directly, which is why it misses the broken connection toPositionAuthorityClient. A focused test aroundmain.gdwiring orPositionAuthorityClientsignal behavior would prevent this regression.client/test/target_refresh_on_locomotion_test.gd: wires a realPositionAuthorityClientandTargetSelectionClientwith mock transports and the sameauthoritative_ack→on_authoritative_ackconnectionmain.gduses. Verifies (a) amove-stream200 with a held lock produces exactly one throttledGET /targetand the cached state flips toout_of_range, and (b) the same path is a no-op when no lock is held. Also addedtest_sync_boot_get_200_emits_authoritative_ackandtest_move_stream_post_200_emits_authoritative_acktoposition_authority_client_test.gdso renames on either side will break the per-script suites too.UpdateDone. E1.M3 row now reflects NEO-24 landed (HUD +docs/decomposition/modules/documentation_and_implementation_alignment.mdif this story lands. ItsE1.M3snapshot still says client tab-target is open, which will be stale after merge.authoritative_ackrefresh) with links to the plan and manual QA file.
Nits
None.
Verification
- Ran
godot --headless --import --path . --quit-after 10fromclient/. - Ran
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/position_authority_client_test.gdfromclient/— suite passed, including the newauthoritative_ackassertions. - Ran
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/target_selection_client_test.gdfromclient/— suite passed, including the renamed ack-based refresh tests. - Ran
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/target_refresh_on_locomotion_test.gdfromclient/— integration suite passed, confirmingmove-stream200 triggers exactly one throttled target refresh while locked and none while unlocked. - Read diagnostics for the touched client/scripts, client/test, plan, and review files — no linter errors found.
- I did not run the full manual client/server checklist;
docs/manual-qa/NEO-24.mdsection 4 remains the highest-value end-to-end sanity check if you want one more runtime pass before merge.