Followup #5: after locking alpha, walking into beta's ring, stopping and
pressing Tab could still return `out_of_range` even when client and server
agreed on position (Δ=0). The freshness-kick move-stream POST and the
target/select POST race on separate HTTP connections so the select can be
validated against a stale stored snap.
Extend TargetSelectRequest v1 with an optional positionHint {x,y,z}.
Server uses the hint for the radius check and for the echoed
targetState.validity; the hint is advisory and does not write to the
position store (move-stream stays the sole write path). Client attaches
the hint automatically whenever set_freshness_kick(...) is wired, so
headless unit tests that skip the wiring fall through to the no-hint
behavior. Freshness kick is kept in place to refresh the stored snap for
later validity GETs but is no longer load-bearing for denial correctness.
Server: 3 new tests (hint accepts when stored is stale, hint-far denies,
hint never writes to position store). Client: 1 new test asserting the
hint is in the POST body when wired. All 62 server + 96 client tests
pass.
Plan Decision 8, review follow-up #5, manual QA section 6, and NEO-23
contract notes updated. New bruno request exercises the hint path.
All prototype targets now share `PrototypeTargetRegistry.SharedLockRadius`
(= 6 m). Anchors moved to alpha `(-3, 0.5, -3)` and beta `(3, 0, 3)` so
the two 6 m rings overlap ~3.5 m wide centered at origin, letting Tab
flip between targets without locomotion. Spawn `(-5, -5)` keeps alpha
in range (~2.83 m) and beta out (~11.31 m) so the existing soft-lock
denial QA path at spawn still holds.
`prototype_target_constants.gd` mirrors the shared radius + new anchors.
The range-aware Tab picker now **skips the currently-locked id** so Tab
always expresses a swap intent — if the only in-range target is already
locked, the fallback picks the next cycle id and the server denies
(preserves NEO-23's "Tab = visible denial when no swap possible" rule).
Tests:
- Server: new `PlayerTargetStateReaderTests.ComputeValidity_AtOrigin_…`
asserts both targets report `Ok` at origin. Existing boundary test
rewritten for the new alpha anchor.
- Client: new `test_tab_from_locked_alpha_at_origin_swaps_to_beta_when_both_in_range`
and `test_tab_when_only_current_lock_is_in_range_falls_back_to_cycle_for_denial`.
Existing in-range/out-of-range positions updated for the new layout.
Server 59/59 and client 95/95 suites pass.
Plan Decision 7, review follow-up #4, manual QA section 5 (new "Tab-flip
at origin" overlap sanity), and README updated. Per-target radii will
return with real combat design (E5.M1) — this is a prototype stub.
Previously Tab from no-lock always picked `ORDERED_IDS[0]` (alpha), so
standing next to beta and pressing Tab was denied with `out_of_range`
even though the client HUD clearly showed beta in range.
Added `PrototypeTargetConstants.next_in_range_id_after(current, world)`
which walks the cycle and returns the first anchor whose client-side
horizontal distance is within the anchor radius.
`TargetSelectionClient.request_tab_next()` prefers that pick when the
player reference is wired (same path `set_freshness_kick` uses) and
falls back to the plain cycle order when nothing is in range so the
server still owns the denial reason.
Server contract (NEO-23) untouched. Tests cover the beta pick, the
nothing-in-range fallback, and the no-player-ref regression. Plan
Decision 6 + review follow-up #3 + README + manual QA updated.
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.
Players reported targeting "feels inconsistent" because the server
PrototypeTargetRegistry anchors (alpha (-5,-5) r=8, beta (8,8) r=4) have
no visible meshes — the only console visible in the scene is the NEO-9
PrototypeTerminal at origin, which is unrelated to NEO-23/24 targeting.
Adds a display-only mirror of the server registry:
- client/scripts/prototype_target_markers.gd spawns a colored mast +
translucent flat radius ring per anchor so the lock radius is
visible in-world.
- client/scripts/prototype_target_constants.gd gains ANCHORS with
position/radius/color, matching PrototypeTargetRegistry.cs. Marked
display-only; server remains authoritative for validity.
- TargetLockLabel now renders a per-anchor distance line each physics
tick ("<id>: <d> m / <radius> (in|out)") so players can see the
relationship between the visible capsule and the lock radius and
immediately spot client/server position drift.
Also updates README, plan (Decision 4), manual QA (Section 1 boot +
Section 4 locomotion), and the review file's follow-up section to
describe the UX fix.
Review 2026-04-21 caught that the hybrid soft-lock refresh path is
unreachable during normal WASD locomotion. `main.gd` was wiring
`TargetSelectionClient` to `PositionAuthorityClient.authoritative_position_received`,
which only fires on boot sync and move-rejection resync — successful
`move-stream` 200s are intentionally silent to avoid RTT rubber-banding.
So walking out of an alpha/beta radius never triggered the throttled
`GET /target` the HUD depends on, and validity stayed stuck at `ok`.
Add a separate `authoritative_ack(world)` signal on `PositionAuthorityClient`
emitted from both `BOOT_GET` 200 and successful `move-stream` 200, and
switch `TargetSelectionClient` (+ `main.gd`) to it. The snap signal keeps
its current semantics so rubber-band suppression is untouched; the ack
signal is a pure heartbeat that cooldown-throttled consumers can hook.
Also adds an integration test that wires both real clients together (per
the review's suggestion 1) so renames or mis-wires between the two
scripts fail loudly rather than silently skipping the end-to-end path.
- `client/scripts/position_authority_client.gd`: new `authoritative_ack`
signal; extracted `_parse_world_from_response` helper to share JSON
parse across `BOOT_GET` and `STREAM_POST`; emit ack from both.
- `client/scripts/target_selection_client.gd`: rename
`on_authoritative_position_snap` to `on_authoritative_ack`; drop
`apply_as_snap` gate (no longer meaningful).
- `client/scripts/main.gd`: connect the new signal.
- `client/test/position_authority_client_test.gd`: assert ack on boot
200 and stream 200.
- `client/test/target_selection_client_test.gd`: rename tests for new
handler.
- `client/test/target_refresh_on_locomotion_test.gd`: new integration
suite wiring real authority + target clients.
- Plan Decision 3 + Tests row + Files-to-modify row updated.
- `docs/reviews/2026-04-21-NEO-24.md`: committed with blocking issue +
suggestions struck through with `Done.` notes per
planning-implementation-docs rule.
- `docs/decomposition/modules/documentation_and_implementation_alignment.md`:
E1.M3 row now reflects NEO-24 landed.
- `client/README.md` + `docs/manual-qa/NEO-24.md`: refreshed signal names
so the soft-lock refresh description matches the wiring.
Tests: 88/88 GdUnit headless passed locally.
Adds `TargetSelectionClient` (Godot, HTTP to NEO-23 targeting endpoints):
- Tab cycles ids in ascending order matching `PrototypeTargetRegistry`
(`prototype_target_alpha`, `prototype_target_beta`) and wraps.
- Esc clears by POSTing with `targetId` omitted.
- UI always paints from the server's authoritative `targetState`
(success and denials alike); `UICanvas/TargetLockLabel` shows
`lockedTargetId`, `validity`, `sequence`, and (on deny) `reasonCode`.
- Movement-triggered refresh: while a lock is held, subsequent
`authoritative_position_received` snaps fire a throttled GET
(~250 ms cooldown) so `validity` flips to `out_of_range` after
locomotion without duplicating server radius math on the client.
Registers `target_tab` (Tab) / `target_clear` (Esc) in `project.godot`;
documents bindings + manual QA in `client/README.md`.
Tests: `target_selection_client_test.gd` covers GET parse, tab ordering
from empty/alpha/wrap, POST body shape, denial preserves server state,
clear omits targetId, movement refresh fires only while locked, cooldown
collapses bursts to one GET, and `apply_as_snap=false` is ignored.
All 85 client GdUnit cases pass; gdlint and gdformat clean.
See `docs/plans/NEO-24-implementation-plan.md`.
run_wasd: one _has_scrape_vertical_contact() before move_and_slide and one
after; fold repeated predicates into wish_active_scrape_vertical_pre and
post_slide_wish_floor_scrape.
Review doc: mark suggestions #2/#4 done; suggestion #1, nits, verification
ledge bullet, and partial doc rows reviewed/deferred with strikethrough notes.
Extract static helpers (floor-ray continuation, median feet Y, micro-slip
XZ projection, wish-aligned horizontal velocity) from the tick path so
GdUnit can cover seam math without physics doubles. Add
player_locomotion_wasd_test.gd, document in README, and update NEO-22
plan/review.
Move the WASD physics tick into player_locomotion_wasd.gd so player.gd
stays under the 1600-line CI cap. Fix gdlint class order (enum before
consts), load-constant naming, and wrap long doc lines. Update NEO-22
review and plan; refresh gdlintrc comment.
Delete ground_pick and GroundPick wiring; PositionAuthorityClient now only
boot GET and move-stream POST. main.gd snaps on authoritative position only.
Update client/server READMEs, cursor rule example, NEO-22 plan note, and
GdUnit authority tests (stream 400 + boot resync).
Stable idle latches _idle_anchor_active and _hold_idle_anchor clamps XZ each
tick. Locomotion bypasses that branch, so the anchor stayed at the pre-walk
position while the capsule moved; releasing keys ran idle again and
_hold_idle_anchor teleported to the stale anchor.
Successful move-stream responses echoed server position roughly one RTT behind
integrated move_and_slide, so apply_as_snap still fired whenever dh exceeded
the locomotion skip threshold. Treat stream 200 as ack-only: do not emit
authoritative_position_received. Boot GET and click-move verify unchanged.
Update README + GdUnit expectation.
Stream HTTP responses can lag integrated move_and_slide by roughly one RTT,
so apply_as_snap was pulling the capsule backward each tick while moving.
Skip small XZ corrections when wish or horizontal speed indicates locomotion,
with a cap so large desync still snaps. move_rejected sets a one-shot flag so
resync after move-stream 400 still applies the authoritative pose.
Introduce POST /game/players/{id}/move-stream with ordered targets, full-chain
validation before apply, and PositionStateResponse. Godot client uses camera-
relative WASD, queues samples, and snaps to stream responses; debug builds keep
click-to-move via ground_pick. Update READMEs, E1.M1 snapshot, and tests.
Add prototype_smooth_hill_piece for QA geometry alongside main scene wiring.
Refresh player locomotion and main scene integration from stashed work.
Track docs/plans/NEO-22-implementation-plan.md (WASD replaces click-to-move).
Migrate Jira NEON-* references to Linear NEO-* and linear.app links in
docs, Cursor rules, READMEs, and traceability comments. Renamed
implementation plans and dated reviews to NEO filenames.
Branch is based on origin/main with this commit only (no NEON-29
gameplay chain). client/README follows main’s district/map copy;
player.gd matches main aside from NEO-14 debug trace prefixes.
Traces showed has_goal=true, vlat&&ncol, MOVE_SPEED XZ flipping while
stable=false: vert_err ~0.31 stayed above VERT_ARRIVE_EPS so arrival never
cleared the goal — not idle jitter.
Run vertical-route latch before arrival; when on floor with vlat&&ncol,
allow vert_err up to WALK_COLUMN_NEAR_ARRIVE_VERT (0.36 m) if horiz_dist
<= ARRIVE_EPS. Backup: clear_nav_goal after WALK_COLUMN_STUCK_FRAMES with
net drift < WALK_COLUMN_STUCK_MAX_DRIFT in the same regime.
Idle jitter was not walk/peel: without has_goal, stable idle required
floor_normal·up >= 0.998. Tread+riser contacts often report ~0.996–0.997,
so stable idle never latched and rim settle nudge + idle move_and_slide
ran every tick. Use STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT (0.992) for the
floor-flat check when slide contacts are ridged and we are still using the
strict entry threshold.
When vlat&&ncol, XZ within ~0.92*ARRIVE_EPS, and vert_sep in a narrow band
above vertical-arrival noise, zero XZ velocity so nav slide cannot drive ~6 cm
Y oscillation on violet stairs. Descending fallback when column latch is off;
skip when goal is above feet (climb still needs XZ).
Remove WALK_TREAD_STABILITY horiz cap (it ran for whole vlat&&ncol band and
made the last steps sluggish without fixing jitter).
Raise WALK_CONTINUATION_MAX_BELOW_FEET 0.078→0.108 m so ~0.104 m tread drops
count as walk continuation; probe/debounced peel+snap stop flipping each tick.
Kept below ~0.25 m so ~0.3 m gold→floor void still rejects the slab.
Revert NAV_PATH_STEER_MIN_LOOKAHEAD to 0.22 m.
Steering reverse clamp was reverted (blocked arrival). Instead, when on floor
and (vlat && ncol && vert_sep band) or tight XZ + small vert_sep, clamp
horizontal speed to 1.35 m/s so move_and_slide does not drive riser ping-pong
at full MOVE_SPEED. Bump path steer min lookahead 0.22→0.28 m.
_walk_clamp_steering_reverse kept prior horizontal velocity whenever the new
steer-to-goal direction opposed current motion (dot < -0.35). That prevented
ever closing ARRIVE_EPS in many geometries, so walk never cleared, XZ stayed at
MOVE_SPEED, and air gravity could run indefinitely (y << 0).
Restore direct/path steering without the clamp; NAV_COLUMN_STEER_EXIT_DIST
back to 0.97.
Path lookahead vs direct goal could reverse horizontal velocity every physics
tick on treads (NEON-16), driving slide/snap Y jitter. Clamp new steer to
prior bearing when dot < -0.35 and speed is above a small threshold.
Widen NAV_COLUMN_STEER_EXIT_DIST (0.97→1.12) to reduce path/direct mode
chatter when horiz_dist wobbles near the old band.
DESCEND_LIP_SNAP_CAP (0.1) ran whenever goal was below feet by geometry,
independent of probe debounce. Feet_y noise on treads toggled 0.32 vs 0.1
snap and caused ~2–3 cm Y jitter. Apply lip cap only when debounced peel is
armed and not in peel suspend; otherwise keep FLOOR_SNAP_MOVING.
Continuation rays reject stair treads (~0.104 m) under WALK_CONTINUATION_MAX_BELOW_FEET
(0.078 m), so is_on_floor + probe flickered every tick and toggled peel vs full snap
(Y jitter ~1.0 m). Arm peel/snap aggression only after WALK_PEEL_PROBE_FALSE_FRAMES
consecutive probe failures; deep descent still arms immediately.
Widen horiz peel suspend (0.34→0.46 m vert, 2→5 m XZ) for goal/tread separation gaps.
Widen vertical-route latch off-sep (0.038→0.085) so descent toward floor
does not drop vlat while feet still wobble on stairs.
Add horiz+vert fallback: suspend peel and restore snap when vert_sep ≤0.34 m
and horiz_dist ≤2 m, covering ncol=false paths where goal Y matches slab
height but tread peel still fought snap (violet stairs ~y=0.92).
When vertical route is latched and nav column steering is active, peel
plus zero floor snap caused a 3-frame limit cycle (NEON-16 traces).
Skip peel and restore normal snap while feet are within 0.48 m of the
goal surface vertically.
Lower WALK_CONTINUATION_MAX_BELOW_FEET to 0.078 m (under ~0.104 m treads) so
the next tread is not treated as forward walk support; ledge peel runs on
descents and pulls down onto each step instead of gliding with weak air gravity.
Raise PLAYER_VERTICAL_ACCEL_MULTIPLIER to 9× for peel, walk-air, and idle-air.
Apply PLAYER_VERTICAL_ACCEL_MULTIPLIER (~2.45×) to walk-airborne gravity,
ledge peel integration, and idle-airborne gravity for snappier falls.
Peel used to clear when goal.y >= feet_y - DESCEND_GOAL_Y_MARGIN, which
stopped peeling ~6 cm above the click surface while XZ was already at the
destination — use vertical_arrival_error <= VERT_ARRIVE_EPS instead, with
a small guard when the goal is clearly above the feet (climb).
Direct global_position.y edits may not stick vs Jolt after move_and_slide.
Use move_and_collide for the peel step so motion goes through physics.
If get_gravity() is near zero, use -9.81 for peel, walk air, and idle air.
CharacterBody3D MOTION_MODE_GROUNDED move_and_slide clears downward
velocity while is_on_floor(), so gravity on velocity never produced
vertical motion off terrace lips (all prior probe/snap tweaks were no-ops).
After each walk move_and_slide, when the goal surface is below the feet
and probes/deep-descent say drop, accumulate peel_vy += g·dt and apply
global_position.y += peel_vy·dt. Airborne walk still uses velocity gravity.
Set physics/3d/default_gravity=9.81 explicitly in project.godot.
Rays and is_on_floor() can still agree the capsule is supported while the
authoritative click is on the main floor far below (TerracePlatformB lip).
When feet are >0.42 m above the goal surface Y, always add walk gravity and
zero moving floor snap. Shorter drops (e.g. gold step to floor) still use
the probe heuristic only.
Degenerate XZ (velocity and want_goal_h ~0) made _walk_has_close_floor_probe_below
return after center-only, suppressing ledge gravity for the whole move.
Add _resolve_walk_probe_dir_xz: when goal surface is below feet, prefer
horizontal bearing to the goal, else nav path lookahead, else fallbacks.
Non-descending motion keeps velocity-first probes.
Remove the nav-column-only want_goal_h override (superseded by descending
branch). Reverts the regression where the capsule stayed on violet until XZ.
Column steering follows path tangents on the violet deck while the
authoritative goal sits on the lower floor; velocity-aligned support rays
kept hitting deck in the tangent direction and suppressed ledge gravity.
Use want_goal_h for probe offsets when vertical routing + column steer
+ on floor + goal surface below feet.
Down-rays use 0.32 m depth; a gold step is ~0.3 m above the slab, so a
forward sample over empty space still intersected the gray floor and
passed the up-normal test. That looked like continued support, so ledge
gravity and snap-off never fired.
Only treat a hit as walk support if its Y is within 0.14 m below the
current feet (one tread scale); deeper hits are a lower deck / pit.
Walk support used any ray hit under the foot disk, so the center sample
could still see the platform while the leading edge was over a void.
is_on_floor() stayed true from the lip, snap stayed strong, and vy was
zeroed each tick — horizontal skate until XZ arrival.
Now require a center hit plus at least one forward-biased sample in the
movement direction when horizontal intent is non-zero; use velocity XZ
for probe direction so nav/path steering matches the rays.
Single center ray + flat floor-normal gate missed tilted lip contacts
and still hit deck under capsule center while leading edge hung over void.
Moving FLOOR_SNAP_MOVING then cancelled gravity each tick.
Sample down rays at center and offsets along walk direction; depth 0.32;
set floor_snap_length 0 when is_on_floor but no probe hit.
Jolt + floor snap could keep is_on_floor true over open space toward a
lower click target, so vy stayed 0 until arrival. Add a short downward
ray under the capsule feet (WALK_SUPPORT_PROBE_DEPTH) and apply gravity
when no upward-facing hit is close, only on fairly level floor normals.
Vertical+column branch scanned path for first point with XZ offset >5 cm;
Jolt moved the body across that edge each tick and flipped the chosen
waypoint → 180° velocity oscillation (vlat && ncol).
Use NavigationAgent3D.get_next_path_position() when navigation not
finished; fall back to auth goal when finished or degenerate.