Add selection_event signal when lockedTargetId changes; attribute tab, clear,
and server_correction from POST/GET paths. Optional log_selection_events print.
GdUnit coverage; README, E1.M3 snapshot, manual QA, and plan checklist updated.
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.
PlayerPositionLabel's 3 lines at font_size=15 + outline=6 overran its
64 px rect, so TargetLockLabel (offset_top=82) rendered the `Target:`
row on top of the `z:` row. Bump position-label bottom to 88 and push
target-label top to 104 for clean separation; widen target-label rect to
320x120 so the optional 4th `Denied: <reasonCode>` line fits without
clipping.
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`.
Type preload as GDScript so static helpers resolve on the script
resource; pass Array[float] literals into median_feet_y_from_samples to
match typed parameters. Document FLOOR_RAY_FEET_INVALID sync in tests.
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.
Rename mock variables to avoid shadowing GdUnitTestSuite.mock, drop
redundant await on synchronous assert_signal chains, and ignore
unused request_completed on HangingHttpTransport (API parity only).
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.
Raise stable-idle floor up-dot entry to 0.998 so Jolt normals on a flat
box can latch the idle anchor; 0.999 rarely tripped and corrective
move_and_slide + rim/bump nudges kept moving XZ.
Add STABLE_IDLE_ENTER_STREAK_FRAMES (2) before first latch; arrival
path still latches immediately. Unlatch budget 8→10 ticks.
(0.02, 0.995).normalized() dots ~0.9998 with UP, so default
STABLE_IDLE_FLOOR_MIN_UP_DOT (0.999) still treats it as flat.
Use the same tilted normal as min_flat_up_dot_parameter test.
feet_y wobble from move_and_slide on approach treads crossed
DESCEND_GOAL_Y_MARGIN each frame, toggling path vs direct steer.
Schmitt latch (WALK_VERT_ROUTE_LATCH_ON_SEP/OFF_SEP) with reset on
nav lifecycle. Extend debug_idle_trace with feet_y, vert_sep, vlat,
ncol. Add player_test for latch reset.
While _idle_stable_latched, use STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT (0.992) for
idle_support floor flatness so tread-edge normal flicker does not unlatch as
often. idle_support_is_stable gains optional min_flat_up_dot; unlatch streak 8.
Prototype position label updates in main _physics_process. Add GdUnit for param.
loose_ticks+ridged no longer forces ~0.8s corrective idle when floor normal
is still level (tread corner with vertical riser). Gate that branch on
shallow floor (dot < IDLE_RIM_MIN_FLOOR_UP_DOT). Bump unlatch streak to 5.
Update GdUnit + NEON-29 plan.
Hold latched zero-vel idle through STABLE_IDLE_UNLATCH_TICKS consecutive
unstable readings so thin tread lips do not drop into corrective move_and_slide
every other frame. Idle anchor now freezes Y as well as XZ. Reset latch on
goal/snap/arrival; seed latch on arrival when support is stable after idle tick.
Update hold_idle_anchor GdUnit expectation.
Single-radius switch at DIRECT_APPROACH_RADIUS let horiz_dist cross every
frame between direct-to-goal and nav-path steering, flipping velocity ~180°.
Schmitt band NAV_COLUMN_STEER_ENTER/EXIT with latched _walk_nav_column_steering;
reset on new goal, snap, arrival, clear.
Stop forcing moving floor_max_angle for the full post-stop window on open
flat support; tighten ridged wallish threshold; relax stable floor dot;
on arrival, take stable-idle path when support is already stable after
one idle tick. Add GdUnit case for shallow seam normals.
idle_support_is_stable no longer treats every post-stop loose-tick frame as
unstable; only slide normals showing floor+wall (lip) keep corrective idle.
Deduplicate ridged-contact check via idle_slide_contacts_are_ridged.
Relax STABLE_IDLE_FLOOR_MIN_UP_DOT slightly for Jolt normal flicker.
Update GdUnit cases and NEON-29 plan note.
TerracePlatformC_Approach (PCS_*): four cardinals, 7.2 m arms, ~0.104 m rise.
TerraceStepB_Approach (TSB_*): S/E/W, 6.2×1.0 m NS treads.
TerracePlatformB_Approach (TPB_*): N/E/W with six risers for 0.6 m to floor;
south uses existing gold step. New ramp-tint materials. README + plan.
TerracePlatformB top is ~0.6 m above the floor; agent_max_climb only
limited nav links, while chained step assist could still climb in one
click. Refuse assist when goal surface is more than 0.35 m above feet
so the gold TerraceStepB path (or a second click) is required.
Default get_next_path_position steering caused crawl/stuck behavior with
Jolt at 120 Hz. Steer XZ toward the authoritative target always except
when the goal surface is meaningfully above/below the feet and the
player is within DIRECT_APPROACH_RADIUS — then use the baked path for
ramps/treads. Update README and NEON-29 plan.
Revert path_desired_distance and target_desired_distance to 0.35; 0.22
was below practical per-tick progress toward waypoints and led to
oscillation or stuck path state. When NavigationAgent reports finished
but horizontal distance still exceeds ARRIVE_EPS, steer directly
toward the authoritative goal in XZ.
Tread run was 0.45 m vs 0.8 m capsule diameter, causing straddle stuck
on stairs and blocked motion toward edges when exiting. Use 1.0 m
depth (NS/WE boxes), recenter PAS_* transforms flush to the lip.
Tighten NavigationAgent path/target desired distance 0.35 to 0.22.
Remove walk-stall nav replan and seam-based step-assist clearing that
churned targets and toggled floor_block_on_wall on small rises. Restore
0.11 m assist with 8-tick cooldown; cap wallish snap only when the goal
is meaningfully above the feet. Drop descending_stall floor_block branch.
Document rollback in the implementation plan.
Step assist stayed active when is_on_wall from mesh seams after landing on
the goal height — keep reduced snap and odd slides. Clear assist when
vert_err and feet vs goal show we are on the target slab.
If horizontal speed stays low with a far goal, refresh NavigationAgent3D
target after 8 ticks to break stale paths.
Twelve walkable StaticBody treads (three per side) bridge floor to teal
lip; darker teal material. Fixes Jolt/box-lip stuck when script-only
mitigations had no user-visible effect. README + plan note.
Cap walk floor snap to WALK_STEP_ASSIST_SNAP when climbing into a blocking
wall (stops 0.32m snap fighting step assist). Restore descend lip snap cap;
relax floor_block when descend stalls (low vh, still far in xz). Step assist
lift 0.16 m, cooldown 1 tick.
Step assist: use is_on_wall + get_wall_normal opposing want; keep slide loop
with dot < -0.02.
Descend: relax floor_block on shallow floor normal at internal edges; widen
vertical-ish slide threshold slightly.
Raise TerracePlatformA Y by 0.012 m vs main floor for coplanarity.
Remove the forward-velocity dot gate that blocked assist when pushing
head-on into a step (dot ~1 vs max 0.52).
Relax floor_block_on_wall only for true descends (goal below feet) when
is_on_wall or slide hits a vertical-ish normal, so platform deck motion
stays stable.
Applying gravity whenever the nav goal was below feet stayed true for the
entire walk across TerracePlatformB (feet ~0.6 m vs floor goal Y=0),
corrupting horizontal velocity and slide behavior.
Drop the descend bee-line branch; follow the nav mesh for vertical routing.
Restore full horizontal velocity set with vy=0 and air-only gravity.
Update README and NEON-29 plan to match.
Steer only XZ in _set_horizontal_velocity_toward so vy can accumulate.
Apply gravity when airborne or goal-below-feet; stomp vy on grounded
non-descend moves. Drop descend floor_block relax and on-floor gravity
mult; cap snap for full descend without a feet_y band that flipped
snap length at the lip.
Reverts the 15mm TerraceStepB/TerracePlatformB root offset that regressed
floor-to-step movement.
When the walk goal is below the feet, apply gravity even while is_on_floor
so lip contacts are not stuck with vy cleared every tick; cap descend
floor_snap_length so the upper surface does not win over internal edges.
Raise TerraceStepB and TerracePlatformB 15mm so boxes are not coplanar
with the main floor top, reducing Jolt internal-edge hangs.
Remove descend lip stall, peel multipliers, and snap cap; keep a single
floor_block_on_wall=false policy for the whole descend branch to avoid
oscillation. Document in NEON-29 plan.
Relax floor_block whenever feet are >7 cm above a lower goal — stall/wall was too
late for step1→floor. Re-tighten only in the last centimeters (wallish/stall).
Apply DESCEND_WEAK_PEEL_MULT on_floor while descending every frame; ramp to
DESCEND_LIP_GRAVITY_MULT when stalled/wallish/carry. Skip peel when airborne
(full gravity from _apply_walk_air_gravity).
Always-on gravity while on_floor and descending made platform→floor feel glued
(slow vertical creep, no horizontal until landing). Remove that; use 7x gravity
only when lip_stall>0, wallish slide, or prior move left low horizontal speed
while still far from goal (carry_lip_from_prior_move).
Track _descend_lip_stall_ticks after descend/nomap moves; reset on new goal,
arrival, snap, clear_nav.
Relax floor_block_on_wall when goal is above feet and wallish (floor→step) or
goal below and (wallish or stall) so slides can clear terrace lips.
Reset floor_block_on_wall when idle or arrival.
Wall/slide heuristics missed many step→floor stalls (e.g. far floor clicks).
While descending with is_on_floor, always add gravity to vy before move_and_slide.
Cap floor_snap_length when feet stay meaningfully above the goal so strong snap
cannot yank the capsule back onto the step internal edge; cap lifts near landing
(feet within 8 cm of goal Y). Apply snap helper on nomap + descend branches.
First terrace step to main floor could stall: is_on_floor stayed true on the
step top while a vertical face zeroed horizontal motion; vy was reset to 0 so
no gravity ran. Taller drops broke contact sooner so second→floor worked.
While descending (goal below feet) and on floor, if is_on_wall or any slide
normal is wall-ish (ny < 0.55), integrate gravity into vy before move_and_slide.
_physics_idle_tick zeroed velocity with no gravity, so after walking off a
terrace or arriving slightly above the floor FLOOR_SNAP_IDLE could not reach
the ground and the body hovered until a new walk goal. Apply get_gravity()*delta
and FLOOR_SNAP_MOVING while !is_on_floor(). Budgeted idle branch now runs the
same physics when airborne instead of returning without move_and_slide.
- _set_horizontal_velocity_toward: optional fallback xz when delta.xz ~ 0 (avoids +X hack).
- Direct-approach branch: if horiz_dist to goal is ~0 but not arrived vertically, steer
using the first navigation path point with horizontal separation.
- Apply get_gravity()*delta while walking and not on_floor so capsules land and Jolt
registers floor contacts.
- Set Player collision_mask=3 explicitly; README collision bullet updated.
Player uses collision_layer 2 so camera pick rays (mask 1) skip the capsule.
Static bodies defaulted to collision_mask 1, so they never scanned layer 2 and
Godot produced no floor/wall contacts—walk appeared stuck with is_on_floor
false. Set collision_mask=3 on all NavigationRegion3D StaticBody3D walkables
and occluders; bumps inherit from Floor at spawn. Document the pairing in README.
capsule_feet_y was called with CAPSULE_HALF_HEIGHT (0.5, cylinder half only),
giving a "code-feet" that is 0.4 m above the actual capsule bottom.
From the first step (body Y=1.2): code-feet = 0.7, which is above the
platform goal (Y=0.6). 0.6 < 0.64 triggered the descend bypass even though
the player needed to ascend. Each bypass tick used FLOOR_SNAP_MOVING=0.32 m,
which snapped the capsule back to the step surface (0.11 m below the
assist-lifted bottom), undoing every step assist lift in an oscillation loop.
The player appeared to float at first-step height and could not reach the
second platform.
Fix: use CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS (=0.9, the actual
capsule bottom) for the feet_y in the descend bypass condition. The bypass
now fires only when the goal surface is genuinely below the player's physical
feet, not when the player needs to ascend through stepped geometry.
Also use WALK_STEP_ASSIST_SNAP instead of FLOOR_SNAP_MOVING in the descend
bypass when _step_assist_active is true, so any remaining overlap cannot undo
a step assist lift via the large snap.
The is_on_wall() short-circuits in _step_assist_has_support() and
_step_assist_wallish_blocks() caused the assist to fire whenever the capsule
touched any wall contact — including the sides of random floor bumps while
crossing flat ground toward a distant elevated goal. After a 0.11 m lift,
floor_snap_length = 0.09 m could not reach the original floor (0.11 m away)
and _step_assist_active never cleared, so the capsule floated permanently.
Fixes:
- Remove is_on_wall() blanket short-circuit from both functions.
- _step_assist_has_support() now takes want_dir_xz and accepts wall-ish
contacts only when they oppose the direction of travel (dot < -0.2),
i.e. genuine step faces, not sideways bump contacts.
- _step_assist_wallish_blocks() relies solely on the directional collision
loop (dot < -0.04); no is_on_wall() bypass.
- _try_walk_step_assist() reordered to compute want before calling both
functions so the same direction vector is used throughout.
- Added "floating free" fallback: if _step_assist_active and no collisions
and not on floor, clear the flag so FLOOR_SNAP_MOVING restores on the
next tick. Applied to all three physics paths.
Two independent root causes kept the player from climbing terrace steps:
1. snap_to_server used the raw surface Y from the server (e.g. 0.6 m for a
platform) as global_position.y directly. With capsule total half-height
= 0.9 m this placed the bottom at -0.3 m underground. The stale
is_on_floor()==true + _floor_angle_loose_ticks==0 path skipped every
move_and_slide(), so Jolt never resolved the penetration and the capsule
froze at an intermediate Y.
Fix: clamp global_position.y >= CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
(0.9 m) in snap_to_server; force FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
corrective idle ticks afterwards.
2. Each _try_walk_step_assist() lift (0.11 m) was immediately undone by
floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m snapping the capsule back
to the lower floor, which was only 0.11 m below the lifted bottom.
Fix: add WALK_STEP_ASSIST_SNAP = 0.09 m constant and _step_assist_active
bool. While climbing, floor_snap_length uses the smaller constant in
both _after_walk_move_and_slide() and the main physics loop. The flag
is cleared when the capsule lands cleanly (is_on_floor && !is_on_wall),
or when the nav goal is cleared/snapped to server.
Also: is_on_wall() now counts as support in _step_assist_has_support() so
the assist fires while the capsule is floating but pressed against a step
face; _step_assist_active cleared on arrival, snap, and goal-clear; all
temporary DBG print() statements removed.
The vertical arrival error was computed with CAPSULE_HALF_HEIGHT (0.5),
placing code-feet at body_y - 0.5 = 0.4 on a floor-level player.
Step surfaces at Y=0.3 are only 0.1 m away from that reference, and
Jolt can nudge the body down to ~0.8 when the bottom hemisphere contacts
the step edge, making code-feet = 0.3 and vert_err = 0 — triggering
immediate arrival while the player is still at floor level.
Fix: pass CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS (= 0.9) to
vertical_arrival_error so actual feet = body_y - 0.9 = 0.0 on the
floor. Step goals (Y=0.3) now give vert_err=0.3 >> VERT_ARRIVE_EPS,
preventing premature arrival. Arrival fires correctly once the step
assist lifts the player onto the surface (body_y=1.2, err=0).
Add two tests pinning the new pre/post-climb behaviour.
_try_walk_step_assist compared goal surface Y to the capsule centre
(~0.9 m above floor) instead of the actual capsule bottom, disabling
the assist for any surface below ~0.925 m — every terrace we added.
The lift formula used offsets 0.45/0.55 instead of the full capsule
total half-height (CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS = 0.9),
producing zero/negative lift.
Fix: guard uses actual_feet_y = pos.y - CAPSULE_HALF_HEIGHT - PLAYER_CAPSULE_RADIUS;
lift targets goal.y + total_half_height via clampf. Flat floor clicks
and cylinder bumps are unaffected (guard still disabled when goal.y is
at or below true feet level).
Horizontal distance was never a valid rejection reason; remove references
to FarPad as a reject target and clarify that MaxVerticalStep (pillar top)
is the only remaining server rejection criterion.
Expanded 45×45 district made the 18 m MaxHorizontalStep too restrictive
(floor diagonal ~63 m). Add HorizontalStepEnabled to MovementValidationOptions
(default false, matching DistrictBoundsEnabled pattern) so the check is
skipped unless explicitly opted in. Add test for disabled case (9 pass).
Update README and plan with the decision.
Replace 20×20 floor with 45×45 (~5× area). Add three occluder
obstacles (ObstacleB/C/D) distributed across the expanded space for
camera/occlusion stress QA. Add three terrace structures (TerracePlatformA,
TerraceStepB+TerracePlatformB, TerracePlatformC) for vertical variation;
each height transition respects agent_max_climb=0.35 m so the nav mesh
routes onto all surfaces. Update README with district layout table and
designer/QA limits.
- main.gd: class member order (prvvars before @onready); wrap doc lines to 100 cols.
- isometric_follow_camera.gd: wrap doc lines to 100 cols.
- position_authority_client.gd: gdformat.
- pre-push: resolve .venv-gd/Scripts/gdlint.exe and gdformat.exe (Windows venv).
- Add scripts/install-git-hooks.ps1; README notes hook install and why CI can still fail.
- Input action dev_toggle_occluder_obstacle (Ctrl+Shift+K): toggle obstacle off nav source via reparent to World, rebake NavigationRegion3D, sync NavigationAgent3D; physics/process/collision hardening unchanged intent.
- Occluder dictionary keys by instance id; related client fixes and README/project input note.
- Plan and review: PARSED_GEOMETRY_BOTH / runtime geometry removal lesson for future mechanics.
Apply the repo-local gdformat output to the touched GDScript files so the pre-push formatting hook passes. This is a formatting-only follow-up to the lint-hook cleanup.
Refactor the bump escape helper and shared test script loading so the branch passes the repo's pre-push GDScript lint checks. This keeps the runtime behavior intact while unblocking push and PR creation.
Harden idle settling so flat ground, obstacle contacts, and rare bump-edge cases stop drifting instead of looping corrective nudges. Also resolves the saved review follow-up items and syncs the runtime docs to the final feet-height and idle-anchor behavior.
ground_pick.gd now checks _collider_is_occluder before the walkable
break: when the ray hits a body in the "occluder" group it advances
OCCLUDER_PICK_THROUGH past the hit point and continues, unconditionally
and regardless of OcclusionPolicy fade state. Non-occluder click
targeting is unchanged.
Three unit tests added to ground_pick_test.gd covering occluder ancestry
detection. Plan open questions and module doc updated.
Review verdict updated to Approved; all blocking issues and suggestions
resolved and documented. Add auto-generated Godot .uid sidecar files for
occlusion_policy.gd and occlusion_policy_test.gd.
Fixes review blocking issue: when the follow target is null (freed,
renamed, or path unresolved) or the Camera3D child is missing,
_restore_all_occluders() is now called before returning so geometry
cannot be left stuck semi-transparent.
Adds OcclusionPolicy resource and wires it into IsometricFollowCamera.
Each _process frame, iterative rays from the camera eye to the player
focus detect bodies tagged "occluder"; their MeshInstance3D surfaces are
overridden with a transparent StandardMaterial3D (fade_alpha=0.25, instant).
Materials restore when the body clears the ray. Null-material surfaces
(e.g. the prototype Obstacle with no material assigned) receive a plain
transparent StandardMaterial3D so they fade correctly.
- client/scripts/occlusion_policy.gd — new Resource (enabled, fade_alpha,
occluder_group, occluder_collision_mask, max_occluder_cast_depth,
occluder_count_log_threshold, is_valid)
- client/resources/isometric_occlusion_policy.tres — default instance
- client/scripts/isometric_follow_camera.gd — occlusion_policy export,
_update_occlusion pipeline, _apply_occluder_fade/_restore_occluder helpers,
static occlusion_policy_is_valid guard, _exit_tree cleanup
- client/scenes/main.tscn — Obstacle tagged "occluder", policy assigned
- client/test/occlusion_policy_test.gd — new GdUnit4 unit tests
- client/test/isometric_follow_camera_test.gd — policy guard static tests
- docs/decomposition/modules/E1_M2_IsometricCameraController.md — NEON-27
snapshot + readability demo gate note
- docs/plans/NEON-27-implementation-plan.md — null-material decision recorded
Align plan, client/server README, E1.M1 snapshot, and code review note with
the chosen design: descend bypass favors smooth stepped surfaces; multi-click
around obstacles is OK. NS-24 remains for idle jitter.
Zero-velocity move_and_slide each physics tick still reapplies floor snap and
collision, which often reads as vibration. If there is no walk goal (or we
just arrived), velocity is zero, and is_on_floor(), return early.
User-requested rollback to the pre-72eef09 behavior: skip nav waypoints when
auth goal.y is below body (floor pick vs mid-capsule), steering xz straight at
the goal — good for bump departure, bad for gray-box detours. Revert
floor_snap_length to 0.28 to match that snapshot.
Restore a straight Godot nav pattern: steer toward get_next_path_position until
finish or final approach, then toward goal; horizontal-only arrival by xz.
Removes footprint rays, bump-step group, and step-off vy layering that fought
the engine. Slightly raise Player floor_snap_length for small ledges.
Per-frame wall-normal + goal boosts fought a flipping wall normal and capped
re-vectoring, causing back-and-forth jitter. Drop _apply_bump_rim_wall_unstick
and related helpers/constants; keep direct step-down + footprint logic only.
STEP_OFF_DESCEND_SPEED 2.2 → 1.75.
Remove ticket-style names from collider/footprint APIs (_collider_in_bump_step_group,
_departing_bump_step_to_lower_floor, touches_bump_step). Editor group string stays
ns19_bump in one const until main.tscn renames the group.
Convex frustum sides turn goal steering into tangential rim slide with little
net progress. When footprint still hits ns19_bump and dest floor is lower by
at least STEP_OFF_MIN_DROP, add horizontal wall-normal push (if it does not
strongly oppose goal) plus a small goal-direction boost, cap xz speed.
Slightly raise STEP_OFF_DESCEND_SPEED for the step-off branch.
Premature arrival used _floor_y_under_body() vs dest footing; at the rim that
can read floor while xz is already within ARRIVE_EPS, clearing the walk goal.
Match step-down logic: compare destination footing to footprint max_y and
cache footprint scan once per physics frame.
Nav paths often wrap along bump rims when the goal is far; the 8 m cap then
disabled step-off and left rim-following. Detect NS-19 bumps via
group on footprint rays; when any hit is a bump and vertical drop is in the
NS-19 band, steer straight to the goal up to 18 m (server max step). Non-bump
podiums keep the 8 m close cap. Single footprint pass returns max_y + bump.
At bump lips, center/lower rays often hit the floor first while the body is
still on the rim, so drop read ~0 and nav rim-follow resumed. Max floor Y
over center, lower probe, and 8 xz offsets (cardinal + diagonal) keeps
step-down bypass active until the whole footprint clears. Raise
STEP_OFF_MAX_HORIZ to 8 m (still capped by small drop band).
Project gravity while walking made motion worse (jitter, floor fights).
Restore horizontal-only seek everywhere except the short step-down bypass,
where a fixed downward speed helps clear NS-19 bump lips without affecting
normal nav or idle air state.
Horizontal seek only sets vx/vz; zero vy only on floor. Use project default
gravity vector when not on floor (with and without walk goal) so stepping
off NS-19 bumps can fall to the lower surface instead of hanging with vy=0.
- Ray down at goal xz for destination surface Y; step-off compares two footings.
- Arrive when horiz close and either capsule Y matches goal or underfoot matches
dest footing (server may send center 0.9 or pick surface 0).
- Sample floor Y from center and lower probe; use max for bump rim.
- Only bypass nav for step-sized vertical drop with wider near-goal horiz.
- Loosen next-waypoint stickiness so rim waypoints fall back to goal sooner.
Ray down for floor Y under the capsule; skip nav only when goal is a bit
lower than that surface, drop ≤0.55 m, and horiz ≤1.05 m. Matches
leaving a bump without comparing pick/body origin (which broke obstacle
pathing). Long crosses still use waypoints.
goal.y < body.y matched almost every move because ray picks use floor Y
while CharacterBody3D origin is mid-capsule, so we always bee-lined and
ignored waypoints. Rely on small-xz next-waypoint fallback for bump rims
instead.
Server already allows downward steps via abs(ΔY) vs MaxVerticalStep; document
that explicitly.
Client: when authoritative goal Y is below the body, skip nav waypoints and
steer horizontally toward the goal only — mesh waypoints under the rim
gave ~no horizontal speed (felt like wrong collision).
Replace stacked NS-19 workarounds (underfoot rays, plateau nav skip, 3D
climb/descent steering, rim escape) with one rule: set horizontal
velocity toward waypoint/goal, force velocity.y = 0, move_and_slide +
floor_* handle height along geometry. Server MoveCommand remains the
single place for allow/deny on illegal steps.
Frustum bases left ~0.33 m between closest corners while the player
capsule is ~0.8 m wide, so crossing could wedge between A and B. Shift
NS19BumpB −Z by 0.75 m (z −4.1 → −4.85) so AABB clearance ≈1 m.
- Add xz-offset down-rays (~0.42 m) so rim/lean on the 1 m Bump A still
counts as on-bump for nav skip (single center column could miss).
- Widen Bump A convex base 0.65→0.72 (top 0.5 unchanged): gentler ~34°
sides vs ~45° to reduce CharacterBody3D snags; mesh unchanged.
- If any of three down-rays (body + two lower origins) hits ns19_bump,
skip NavigationAgent waypoints unconditionally on that footprint.
- Steering: any goal lower than origin with same xz (dy < 0) uses rim
escape; dy in (-0.04, 0) previously zeroed velocity and could hang.
Plateau-only (strict flat normal + is_on_floor) missed rim poses and
convex top edges. Skip waypoints when a down ray from body or feet
offset hits ns19_bump and either the goal is lower than the body origin
(typical floor click off bump) or we are on a flatter floor normal
(~15°). Keeps waypoint routing on bump slopes when climbing past
obstacles unless the move is clearly downward.
Restore obstacle routing via waypoints elsewhere; on bump tops nav often
prefers a downward segment and breaks departure. Detect plateau with
is_on_floor + flat normal + ray down into group ns19_bump; then steer
only toward authoritative goal xz. Slopes keep waypoint pathing.
- Add BoxShape3D to PrototypeTerminal (layer 1, walkable) so the player
cannot pass through; matches terminal mesh size.
- Bake navigation from collision layer 1 only so the player (layer 2) is
not merged into the mesh; reparent Player under NavigationRegion3D so
NavigationAgent3D resolves a valid map.
- Restore nav waypoint following for long horizontal legs; keep horizontal
steering toward next/goal so bump departure behavior stays sane.
Ground pick: step the ray through steep walkable hits (0.09 < dot UP < 0.64)
so a view that grazes bump sides still resolves to flat floor beyond; walls
(dot ~0) stop the chain.
Player: drop NavigationAgent3D waypoint following; steer only toward the
server goal in xz so plateau paths are not dominated by vertical nav
segments. move_and_slide handles slopes and obstacles.
MIN_WALKABLE_NORMAL_DOT_UP 0.82 is stricter than NS-19 frustum slopes
(~45° / dot 0.71 on Bump A, ~39° / 0.78 on B). Clicks off the plateau
often hit the slope first; the pick was dropped with no MoveCommand.
Lower to 0.64 so ramps register; vertical walls remain ~0.
VERT_ARRIVE_EPS (0.16) was larger than the bump rise (~0.12–0.15), so a
floor click beside the plateau often satisfied arrival (horiz + vert) on
the first frame and cleared the walk goal before any motion.
Tighten vertical arrival to 0.055. Add a steer fallback when the target
shares xz but is lower (plateau vs floor underfoot) so we still pick a
horizontal escape toward the rim.
Path waypoints from the plateau often sit on the slope almost under the
player (same xz, lower y). 3D steer + y clamp gave almost no horizontal
velocity, so standing on top then clicking off looked stuck. Steer in xz
at MOVE_SPEED and let move_and_slide follow floor/slope; keep 3D only for
vertical climb when xz is aligned.
Replace top slab + four ramp wedges with one ConvexPolygonShape3D frustum
per bump so plateau and slopes are one manifold (no collider seams at
table edges when walking off).
Add CharacterBody3D safe_margin to reduce edge contact with physics.
Full bump box shared vertical faces with convex ramp wedges, doubling
collision and snagging CharacterBody3D on and off. Replace with thin
top BoxShape3D; ramps own all side contact.
Raise Player floor_max_angle to 50° and floor_snap_length slightly for
cleaner ramp floor snapping.
- Add four invisible ConvexPolygonShape3D ramp wedges per bump (walkable) so
move_and_slide can climb/descend without test_move ledge hacks.
- Remove _try_autostep / _maybe_autostep_after_slide and LEDGE_DROP_STEPS;
they mis-detected geometry and fought CharacterBody3D slide.
- Fix duplicate subresource unique_id on BumpA RampWest and BumpB RampSouth.
- Run .NET build/test only when the solution, server/, or the workflow changes.
- Add gdlint and gdformat --check for client/ on relevant path changes.
- Align client GDScript with gdtoolkit (formatting, enum order, line length).
Implement E1.M1 intent → authority: POST /game/players/{id}/move with v1 JSON
MoveCommandRequest (snap + sequence), extend in-memory store and
PositionStateApi. Godot client splits ground_pick and position_authority_client
with thin main.gd; snap_to_server on player.
Tests live under NeonSprawl.Server.Tests/Game/PositionState/ with AAA layout
and MethodName_ShouldExpectedOutcome_WhenScenario naming. Debug builds use
UseAppHost=false so Linux IDEs resolve .NET 10 from a user-local SDK.
Add NS-16 implementation plan, code review doc, godot-client-script-organization
rule, csharp test layout/naming rules, and refresh E1.M1 / alignment docs.
Server and client README updates include move API, prerequisites, and Rider
notes.
Made-with: Cursor
- State GDScript as the locked choice for Godot client code
- Summary tables and client README note standard (non-.NET) Godot build
- Server/client contract via Protobuf or JSON spike, not shared C# on client
Made-with: Cursor