NEO-24: unify prototype target radius at 6 m, overlap rings at origin

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.
pull/47/head
VinPropane 2026-04-21 23:37:42 -04:00
parent 63fecb1099
commit a7453eb82f
8 changed files with 149 additions and 45 deletions

View File

@ -96,20 +96,21 @@ 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. When the player reference is wired (via `set_freshness_kick`, which `main.gd` does), Tab **skips client-computed out-of-range anchors** and selects the first in-range id in cycle order so pressing Tab while visibly standing next to an anchor actually locks it instead of trying the next-in-order id that happens to be across the map. Falls back to the plain cycle when nothing is in range so the server still picks the denial reason. **`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. When the player reference is wired (via `set_freshness_kick`, which `main.gd` does), Tab **skips client-computed out-of-range anchors** and selects the first in-range id in cycle order so pressing Tab while visibly standing next to an anchor actually locks it instead of trying the next-in-order id that happens to be across the map. Falls back to the plain cycle when nothing is in range so the server still picks the denial reason. **`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. `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. - **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: 7.1 m / 6.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. - **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. All prototype targets share a single radius (`PrototypeTargetConstants.SHARED_LOCK_RADIUS` = `PrototypeTargetRegistry.SharedLockRadius` = **6 m**); anchors are placed at `(-3, 0.5, -3)` and `(3, 0, 3)` so the two rings overlap at origin (~3.5 m wide), letting you flip between targets with Tab without locomotion. The script and `prototype_target_constants.gd` mirror the server's `PrototypeTargetRegistry.cs` change them together in the same commit so the markers never drift from server authority. Per-target radii will return with real combat design (E5.M1).
- **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`.
### Manual check (NEO-24) ### Manual check (NEO-24)
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap. 1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.
2. Press **Tab**: HUD → `Target: prototype_target_alpha`, `Validity: ok` (spawn is inside alpha's 8 m radius). 2. Press **Tab**: HUD → `Target: prototype_target_alpha`, `Validity: ok` (spawn is ~2.83 m from alpha's anchor `(-3, -3)`, well inside the shared 6 m radius).
3. Press **Tab** again: server denies (beta anchor is **(8, 8)** with a **4 m** radius, so from spawn it is out of range). Per NEO-23 soft-lock policy the persisted lock does not change, so HUD stays at `Target: prototype_target_alpha, Validity: ok` with `Denied: out_of_range` appended. 3. Press **Tab** again: server denies (beta anchor `(3, 3)` is ~11.3 m from spawn, outside 6 m). The range-aware picker skips the currently-locked alpha and falls back to the cycle so the server gets a clean "swap to beta" request and can deny it; per NEO-23 soft-lock policy the persisted lock does not change, so HUD stays at `Target: prototype_target_alpha, Validity: ok` with `Denied: out_of_range` appended.
4. **WASD** away from spawn past ~8 m horizontally from alpha; HUD flips to `Validity: out_of_range` on the next movement-triggered refresh (≤250 ms cadence) while `lockedTargetId` stays `alpha` — this is the **soft lock** round-tripped from the server. 4. **WASD** away from spawn past ~6 m horizontally from alpha; HUD flips to `Validity: out_of_range` on the next movement-triggered refresh (≤250 ms cadence) while `lockedTargetId` stays `alpha` — this is the **soft lock** round-tripped from the server.
5. Walk toward beta (inside **(8, 8)** within 4 m) and press **Tab**: swap succeeds, HUD shows `Target: prototype_target_beta, Validity: ok`. A further **Tab** from here wraps to `alpha` (and will itself succeed or deny based on distance). 5. Walk to the **origin** (both rings visibly overlap): HUD shows `alpha` and `beta` distance lines both `(in)` (~4.24 m each). Press **Tab** to flip alpha → beta → alpha → … without moving. This is the main overlap sanity.
6. Press **Esc**: HUD → `Target: —`, `Validity: none`, sequence advances. 6. Walk toward beta (inside `(3, 3)` within 6 m, outside alpha's ring) and press **Tab** from a cleared lock: the range-aware picker skips alpha and locks beta directly. A further **Tab** from beta-only-in-range wraps to `alpha` and the server denies (same soft-lock behavior as step 3).
7. Press **Esc**: HUD → `Target: —`, `Validity: none`, sequence advances.
## Movement prototype (NEO-5 → NEO-11) ## Movement prototype (NEO-5 → NEO-11)

View File

@ -12,13 +12,27 @@ extends Object
const ORDERED_IDS: Array[String] = ["prototype_target_alpha", "prototype_target_beta"] const ORDERED_IDS: Array[String] = ["prototype_target_alpha", "prototype_target_beta"]
## All prototype targets share a single radius (`PrototypeTargetRegistry.SharedLockRadius`
## on the server). Per-target radii will come back with real combat design (E5.M1).
const SHARED_LOCK_RADIUS: float = 6.0
## Keys match [constant ORDERED_IDS]; values mirror `PrototypeTargetRegistry.cs` anchors ## Keys match [constant ORDERED_IDS]; values mirror `PrototypeTargetRegistry.cs` anchors
## (world space) and horizontal lock radii (XZ meters). ## (world space) and horizontal lock radii (XZ meters). Anchors are placed so the two rings
## overlap at origin (~3.5 m wide), letting Tab flip between targets without locomotion.
## Keep in lock-step with the C# registry.
const ANCHORS: Dictionary = { const ANCHORS: Dictionary = {
"prototype_target_alpha": "prototype_target_alpha":
{"position": Vector3(-5.0, 0.5, -5.0), "radius": 8.0, "color": Color(1.0, 0.55, 0.2, 1.0)}, {
"position": Vector3(-3.0, 0.5, -3.0),
"radius": SHARED_LOCK_RADIUS,
"color": Color(1.0, 0.55, 0.2, 1.0),
},
"prototype_target_beta": "prototype_target_beta":
{"position": Vector3(8.0, 0.0, 8.0), "radius": 4.0, "color": Color(0.7, 0.45, 1.0, 1.0)}, {
"position": Vector3(3.0, 0.0, 3.0),
"radius": SHARED_LOCK_RADIUS,
"color": Color(0.7, 0.45, 1.0, 1.0),
},
} }
@ -66,9 +80,11 @@ static func ordered_ids() -> Array[String]:
## Returns the next id in [constant ORDERED_IDS] that is **in range** of [param world] ## Returns the next id in [constant ORDERED_IDS] that is **in range** of [param world]
## (client-side horizontal distance ≤ anchor radius), starting after [param current] and ## (client-side horizontal distance ≤ anchor radius) **and not equal to [param current]**,
## wrapping last → first. Returns [code]""[/code] when no anchor is in range so callers can ## starting after [param current] and wrapping last → first. Returns [code]""[/code] when
## fall back to [method next_id_after] and let the server pick the denial reason. ## no such anchor is in range so callers can fall back to [method next_id_after] and let
## the server pick the denial reason (this preserves the "Tab on an out-of-reach swap =
## visible denial" UX from NEO-23's soft-lock rule).
## ##
## Client math here is a UX convenience (same purpose as the HUD distance lines): the ## Client math here is a UX convenience (same purpose as the HUD distance lines): the
## server remains authoritative for the actual lock decision in ## server remains authoritative for the actual lock decision in
@ -78,9 +94,10 @@ static func ordered_ids() -> Array[String]:
static func next_in_range_id_after(current: Variant, world: Vector3) -> String: static func next_in_range_id_after(current: Variant, world: Vector3) -> String:
if ORDERED_IDS.is_empty(): if ORDERED_IDS.is_empty():
return "" return ""
var current_str: String = ""
var start_idx: int = 0 var start_idx: int = 0
if current is String: if current is String:
var current_str: String = (current as String).strip_edges() current_str = (current as String).strip_edges()
if not current_str.is_empty(): if not current_str.is_empty():
var found: int = ORDERED_IDS.find(current_str) var found: int = ORDERED_IDS.find(current_str)
if found >= 0: if found >= 0:
@ -88,6 +105,8 @@ static func next_in_range_id_after(current: Variant, world: Vector3) -> String:
for i in range(ORDERED_IDS.size()): for i in range(ORDERED_IDS.size()):
var probe_idx: int = (start_idx + i) % ORDERED_IDS.size() var probe_idx: int = (start_idx + i) % ORDERED_IDS.size()
var probe_id: String = ORDERED_IDS[probe_idx] var probe_id: String = ORDERED_IDS[probe_idx]
if probe_id == current_str:
continue
var radius: float = anchor_radius(probe_id) var radius: float = anchor_radius(probe_id)
if radius <= 0.0: if radius <= 0.0:
continue continue

View File

@ -265,7 +265,7 @@ func test_select_post_without_freshness_wiring_is_a_noop() -> void:
func test_tab_from_no_lock_skips_out_of_range_and_picks_beta() -> void: func test_tab_from_no_lock_skips_out_of_range_and_picks_beta() -> void:
# Player stands next to beta (well outside alpha's 8 m ring). Without the range-aware # Player stands next to beta (well outside alpha's ring). Without the range-aware
# tab cycle the server denies with `out_of_range` on alpha — see NEO-24 follow-up #3. # tab cycle the server denies with `out_of_range` on alpha — see NEO-24 follow-up #3.
var transport := MockHttpTransport.new() var transport := MockHttpTransport.new()
transport.enqueue( transport.enqueue(
@ -275,8 +275,8 @@ func test_tab_from_no_lock_skips_out_of_range_and_picks_beta() -> void:
var authority := FreshnessAuthorityStub.new() var authority := FreshnessAuthorityStub.new()
auto_free(authority) auto_free(authority)
add_child(authority) add_child(authority)
# Near beta anchor (8, 8), ~2.4 m horizontal — inside 4 m. Far from alpha at (-5, -5). # Near beta anchor (3, 3), ~1.4 m horizontal — inside 6 m. Far from alpha at (-3, -3).
var player := _make_player(Vector3(5.9, 0.5, 9.2)) var player := _make_player(Vector3(4.0, 0.5, 4.0))
c.set_freshness_kick(authority, player) c.set_freshness_kick(authority, player)
c.request_tab_next() c.request_tab_next()
assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID) assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
@ -302,6 +302,57 @@ func test_tab_from_no_lock_with_nothing_in_range_falls_back_to_first_id() -> voi
assert_that(c.cached_state().get("reasonCode", "")).is_equal("out_of_range") assert_that(c.cached_state().get("reasonCode", "")).is_equal("out_of_range")
func test_tab_from_locked_alpha_at_origin_swaps_to_beta_when_both_in_range() -> void:
# With alpha (-3,-3) and beta (3,3) sharing r=6, origin is in both rings. Locked to
# alpha, Tab should request beta (normal cycle behavior; range-aware skip-current
# does not interfere when the next id is also in range).
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
)
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2)
)
var c := _make_client(transport)
var authority := FreshnessAuthorityStub.new()
auto_free(authority)
add_child(authority)
var player := _make_player(Vector3(0.0, 0.5, 0.0))
c.set_freshness_kick(authority, player)
c.request_tab_next()
c.request_tab_next()
assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID)
func test_tab_when_only_current_lock_is_in_range_falls_back_to_cycle_for_denial() -> void:
# Locked to alpha, only alpha is in range — Tab should express a *swap* intent and
# fall through to the plain cycle so the server denies with `out_of_range` (NEO-23
# soft-lock rule). If the picker returned alpha instead, the user would see no
# visible reaction to Tab.
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
)
transport.enqueue(
HTTPRequest.RESULT_SUCCESS,
200,
_select_response_json(false, ALPHA_ID, "ok", 1, "out_of_range")
)
var c := _make_client(transport)
var authority := FreshnessAuthorityStub.new()
auto_free(authority)
add_child(authority)
# Near alpha (-3, -3), ~1.4 m in — beta (3, 3) is ~8.5 m out.
var player := _make_player(Vector3(-2.0, 0.5, -2.0))
c.set_freshness_kick(authority, player)
c.request_select_target_id(ALPHA_ID)
c.request_tab_next()
assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
assert_that(c.cached_state().get("reasonCode", "")).is_equal("out_of_range")
assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)
func test_tab_without_player_ref_still_uses_plain_cycle_order() -> void: func test_tab_without_player_ref_still_uses_plain_cycle_order() -> void:
# Regression: headless unit tests and any client that skips `set_freshness_kick(...)` # Regression: headless unit tests and any client that skips `set_freshness_kick(...)`
# must keep the pre-existing cycle-order semantics. Alpha is requested from no-lock. # must keep the pre-existing cycle-order semantics. Alpha is requested from no-lock.

View File

@ -23,10 +23,10 @@ Pair the client (Godot **F5**) with the server (`cd server/NeonSprawl.Server &&
- `Target: —` - `Target: —`
- `Validity: none` - `Validity: none`
- `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: 2.83 m / 6.0 (in)` (spawn `(-5,-5)` is ~2.83 m from alpha anchor `(-3,-3)`)
- `prototype_target_beta: 18.38 m / 4.0 (out)` - `prototype_target_beta: 11.31 m / 6.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. - [ ] `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 with a **shared 6 m radius**: an **orange** mast + flat ring at `(-3, 0.5, -3)` (alpha) and a **violet** mast + ring at `(3, 0, 3)` (beta). Both rings lie flat on the floor and **visibly overlap** in a band of ~3.5 m centered at origin. The player capsule should sit inside the alpha ring (only) 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.
## 2. Tab cycle — happy path ## 2. Tab cycle — happy path
@ -40,33 +40,41 @@ Pair the client (Godot **F5**) with the server (`cd server/NeonSprawl.Server &&
## 3. Tab to out-of-range target — soft lock preserved ## 3. Tab to out-of-range target — soft lock preserved
- [ ] With alpha locked, press **Tab** again. The server denies the swap to beta (spawn is ~18 m from `(8, 8)`; beta radius is 4 m). - [ ] With alpha locked at spawn, press **Tab** again. The server denies the swap to beta (spawn is ~11.3 m from `(3, 3)`; shared radius is 6 m). Because the range-aware picker **skips the currently-locked id**, Tab falls back to the plain cycle (→ beta), the server rejects, and the soft lock is preserved.
- [ ] HUD **stays** at `Target: prototype_target_alpha`, `Validity: ok`, `Seq: 1` with **`Denied: out_of_range`** appended. That is the NEO-23 soft-lock rule: a failed swap does not change the persisted lock or its sequence. - [ ] HUD **stays** at `Target: prototype_target_alpha`, `Validity: ok`, `Seq: 1` with **`Denied: out_of_range`** appended. That is the NEO-23 soft-lock rule: a failed swap does not change the persisted lock or its sequence.
- [ ] Server log shows the denial: `POST``200` with `selectionApplied: false`. - [ ] Server log shows the denial: `POST``200` with `selectionApplied: false`.
- [ ] Press **Tab** several more times from here. Each press cleanly denies; HUD never drifts or shows a `beta` / optimistic lock between responses. - [ ] Press **Tab** several more times from here. Each press cleanly denies; HUD never drifts or shows a `beta` / optimistic lock between responses.
## 4. Soft-lock out-of-range via locomotion (plan Decision 2) ## 4. Soft-lock out-of-range via locomotion (plan Decision 2)
- [ ] Still locked to alpha, **WASD** away from spawn until you are ~8 m horizontally from `(-5, -5)`. Within ~250 ms of the next `move-stream` 200 (each successful POST emits `authoritative_ack` → throttled target `GET`), HUD flips to: - [ ] Still locked to alpha, **WASD** away from spawn until you are ~6 m horizontally from `(-3, -3)` (easiest route: head toward the -x/-z side of the map away from both rings). Within ~250 ms of the next `move-stream` 200 (each successful POST emits `authoritative_ack` → throttled target `GET`), HUD flips to:
- `Target: prototype_target_alpha` (unchanged — soft lock) - `Target: prototype_target_alpha` (unchanged — soft lock)
- `Validity: out_of_range` - `Validity: out_of_range`
- `Seq: 1` (unchanged — no lock id change) - `Seq: 1` (unchanged — no lock id change)
- [ ] Walk back inside the radius. HUD returns to `Validity: ok` within ~250 ms of the next `move-stream` ack. - [ ] Walk back inside the alpha ring. HUD returns to `Validity: ok` within ~250 ms of the next `move-stream` ack.
- [ ] While walking, Godot Output is **not** spammed with targeting GETs. Server log shows no more than roughly one targeting `GET` per 250 ms during sustained motion, even though `move-stream` POSTs fire at ~20 Hz. - [ ] While walking, Godot Output is **not** spammed with targeting GETs. Server log shows no more than roughly one targeting `GET` per 250 ms during sustained motion, even though `move-stream` POSTs fire at ~20 Hz.
- [ ] Stand still with no lock changes. Server log shows **no** targeting GETs at idle (no `move-stream` is sent while idle, so there is no ack to trigger a refresh). - [ ] Stand still with no lock changes. Server log shows **no** targeting GETs at idle (no `move-stream` is sent while idle, so there is no ack to trigger a refresh).
- [ ] **Distance lines match the ring**: while moving, the `prototype_target_alpha: <d> m / 8.0 (in|out)` line flips `in`/`out` as you cross the visible orange ring edge. Any time the HUD shows `Validity: ok` but the distance line reads `(out)`, the server position snapshot is lagging the visible capsule — the next `authoritative_ack` refresh should reconcile within 250 ms. - [ ] **Distance lines match the ring**: while moving, the `prototype_target_alpha: <d> m / 6.0 (in|out)` line flips `in`/`out` as you cross the visible orange ring edge. Any time the HUD shows `Validity: ok` but the distance line reads `(out)`, the server position snapshot is lagging the visible capsule — the next `authoritative_ack` refresh should reconcile within 250 ms.
## 5. Swap to beta when in range ## 5. Tab-flip at origin (overlap zone) — shared-radius sanity
- [ ] WASD toward beta at **(8, 8)** until you are inside the violet ring (≤4 m). Press **Tab**. HUD swaps cleanly: - [ ] WASD toward the **origin** `(0, 0)`. Both rings overlap there; the HUD should show **both** distance lines with `(in)` (each ~4.24 m / 6.0).
- [ ] Press **Tab** from no-lock (if Esc'd first): HUD flips to `Target: prototype_target_alpha`, `Validity: ok`, `Seq` +1. Cycle-first.
- [ ] Press **Tab** again: HUD flips to `Target: prototype_target_beta`, `Validity: ok`, `Seq` +1. The range-aware picker returns beta (next in cycle, still in range).
- [ ] Press **Tab** a third time: HUD flips back to `Target: prototype_target_alpha`. This is the core "flip between targets without moving" sanity — if it does **not** flip cleanly, check the server log; you should see a successful `POST /target/select` per press.
- [ ] **Walk slightly off-center** so only one ring covers you (e.g. toward alpha's anchor): distance lines flip `(in)` / `(out)` accordingly, and Tab swaps behave like Section 3 (denial on the out-of-range direction, preserved soft lock).
## 6. Swap to beta when in range (via walking, not the overlap)
- [ ] WASD from spawn straight toward beta at **(3, 3)** until the capsule is inside the violet ring (≤6 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 from deep inside beta (alpha out of range). Server denies — HUD stays at `beta / ok` with `Denied: out_of_range` appended (same soft-lock UX as Section 3, other direction).
- [ ] **Range-aware cycle sanity:** press **Esc** to clear the lock (`Target: —`), then — while still inside beta's ring — press **Tab**. The client should skip alpha (out of range, ~18 m from here) and lock **beta** directly (`Target: prototype_target_beta`, `Validity: ok`). A plain cycle would pick alpha first and get denied with `out_of_range`; if that happens, the range-aware pick in `PrototypeTargetConstants.next_in_range_id_after` is not running (e.g. `set_freshness_kick` not wired). - [ ] **Range-aware cycle sanity:** press **Esc** to clear the lock (`Target: —`), then — while still inside beta's ring but outside alpha's — press **Tab**. The client skips alpha (out of range) and locks **beta** directly (`Target: prototype_target_beta`, `Validity: ok`). A plain cycle would pick alpha first and get denied with `out_of_range`; if that happens, the range-aware pick in `PrototypeTargetConstants.next_in_range_id_after` is not running (e.g. `set_freshness_kick` not wired).
- [ ] **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. - [ ] **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 ## 7. Clear
- [ ] Press **Esc** at any point with a lock held. HUD flips to: - [ ] Press **Esc** at any point with a lock held. HUD flips to:
- `Target: —` - `Target: —`
@ -76,30 +84,30 @@ Pair the client (Godot **F5**) with the server (`cd server/NeonSprawl.Server &&
- [ ] Server log shows `POST /target/select` with body `{"schemaVersion":1}` (no `targetId` key). - [ ] Server log shows `POST /target/select` with body `{"schemaVersion":1}` (no `targetId` key).
- [ ] Press **Esc** again with no lock. Server returns the same cleared state; HUD stays `—` / `none` (server may or may not bump sequence — whatever it returns is what the HUD shows). - [ ] Press **Esc** again with no lock. Server returns the same cleared state; HUD stays `—` / `none` (server may or may not bump sequence — whatever it returns is what the HUD shows).
## 7. Denial UI hygiene ## 8. Denial UI hygiene
- [ ] Press **Tab** from `—` while far from both targets. If the server denies (possible depending on exact spawn distance), HUD shows `Target: —`, `Validity: none`, `Denied: out_of_range`. The lock id does **not** get "stuck" at something the server refused. - [ ] Press **Tab** from `—` while far from both targets. If the server denies (possible depending on exact spawn distance), HUD shows `Target: —`, `Validity: none`, `Denied: out_of_range`. The lock id does **not** get "stuck" at something the server refused.
- [ ] After any successful Tab / Esc, the `Denied: …` line goes away on the next response. - [ ] After any successful Tab / Esc, the `Denied: …` line goes away on the next response.
## 8. Input behavior ## 9. Input behavior
- [ ] **Tab** does **not** make focus jump around UI during play (we intercept in `_input` before UI focus navigation). - [ ] **Tab** does **not** make focus jump around UI during play (we intercept in `_input` before UI focus navigation).
- [ ] **Esc** does not quit or minimize the game. - [ ] **Esc** does not quit or minimize the game.
- [ ] Holding **Tab** does not auto-repeat into a firehose of POSTs (pressed action only fires on key press, not echo). - [ ] Holding **Tab** does not auto-repeat into a firehose of POSTs (pressed action only fires on key press, not echo).
- [ ] **E** (NEO-9 interact) and **WASD** (NEO-22 move) still work independently of the target lock; having a lock does not block interact or movement. - [ ] **E** (NEO-9 interact) and **WASD** (NEO-22 move) still work independently of the target lock; having a lock does not block interact or movement.
## 9. Coexistence with movement rejection (NEO-19 / move-stream 400) ## 10. Coexistence with movement rejection (NEO-19 / move-stream 400)
- [ ] Walk at the orange `MoveRejectPedestal` (~(7.5, 6.5)) while holding a lock. When `move-stream` returns **400** (`vertical_step_exceeded`), `PositionAuthorityClient` runs a boot-style re-sync which emits both `authoritative_position_received` (for the snap) and `authoritative_ack` (for cooldown-throttled consumers). - [ ] Walk at the orange `MoveRejectPedestal` (~(7.5, 6.5)) while holding a lock. When `move-stream` returns **400** (`vertical_step_exceeded`), `PositionAuthorityClient` runs a boot-style re-sync which emits both `authoritative_position_received` (for the snap) and `authoritative_ack` (for cooldown-throttled consumers).
- [ ] HUD's `MoveRejectLabel` shows the rejection message as before (NEO-19). - [ ] HUD's `MoveRejectLabel` shows the rejection message as before (NEO-19).
- [ ] `TargetSelectionClient` treats that ack like any other: if a lock is held, it fires at most one refresh GET (subject to cooldown). No duplicate requests, no stuck busy state. - [ ] `TargetSelectionClient` treats that ack like any other: if a lock is held, it fires at most one refresh GET (subject to cooldown). No duplicate requests, no stuck busy state.
## 10. Server-down / transport failure ## 11. Server-down / transport failure
- [ ] Stop the server. Press **Tab** / **Esc**. Godot **Output** prints `TargetSelectionClient: HTTP failed …` warnings; the client is not wedged — you can press again (busy flag is released on failure). - [ ] Stop the server. Press **Tab** / **Esc**. Godot **Output** prints `TargetSelectionClient: HTTP failed …` warnings; the client is not wedged — you can press again (busy flag is released on failure).
- [ ] Restart the server and confirm the next **Tab** succeeds; HUD updates from the response. - [ ] Restart the server and confirm the next **Tab** succeeds; HUD updates from the response.
## 11. No regressions ## 12. No regressions
- [ ] Boot snap still works (NEO-7). - [ ] Boot snap still works (NEO-7).
- [ ] WASD locomotion + `move-stream` still works (NEO-22); `PlayerPositionLabel` updates every physics tick. - [ ] WASD locomotion + `move-stream` still works (NEO-22); `PlayerPositionLabel` updates every physics tick.
@ -109,4 +117,4 @@ Pair the client (Godot **F5**) with the server (`cd server/NeonSprawl.Server &&
--- ---
When sections 110 are ticked and 11 shows no regressions, the story is ready for **In Test** / PR. Record any deviations or follow-ups in the **Decisions** or **Open questions** sections of the [implementation plan](../plans/NEO-24-implementation-plan.md). When sections 111 are ticked and 12 shows no regressions, the story is ready for **In Test** / PR. Record any deviations or follow-ups in the **Decisions** or **Open questions** sections of the [implementation plan](../plans/NEO-24-implementation-plan.md).

View File

@ -107,4 +107,5 @@ No new **C#** tests (client-only story). **Bruno:** NEO-23 targeting requests al
| 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. | | 5 | **Post-merge UX: position-drift race** | Follow-up report: "when in range of beta, pressing Tab says denied." Root cause is a race between `move-stream` 20 Hz sampling and the target-select POST — a Tab press right after you stop moving is validated against the last sampled position, which can trail the visible capsule by several meters. Two changes: (a) `PlayerPositionLabel` now renders a `srv: (x,y,z) Δ=<d>m age=<ms>` line from `PositionAuthorityClient.authoritative_ack`, making the server/client divergence directly visible; (b) `TargetSelectionClient.set_freshness_kick(authority, player)` (wired in `main.gd`) has the target client call `authority.submit_stream_targets([player.global_position])` immediately before every `POST /target/select`, so the server's stored snapshot is as fresh as possible when the range check runs. The freshness kick does not *wait* on the stream 200 — both requests race on separate HTTP connections — but it shrinks the window enough in practice that "stop, Tab, denied" stops firing. Both pieces are pure client changes; the server contract (NEO-23) is untouched. |
| 6 | **Post-merge UX: range-aware Tab cycle** | Follow-up report: "tab here does not acquire target" while standing 2.41 m from beta (inside 4 m) with `Δ=0` — server and client agreed on position but Tab still denied. Root cause: with no lock held, `request_tab_next()` returned `ORDERED_IDS[0]` = alpha (17.90 m away), not the anchor the user was visibly standing next to. Added `PrototypeTargetConstants.next_in_range_id_after(current, world)` which walks the cycle and returns the first anchor whose client-side horizontal distance ≤ radius; `TargetSelectionClient.request_tab_next()` prefers that pick when the player ref is wired (via the existing `set_freshness_kick` path) and falls back to `next_id_after(current)` when nothing is in range so the server still owns the denial reason. Pure client-side UX improvement; server contract untouched. | | 6 | **Post-merge UX: range-aware Tab cycle** | Follow-up report: "tab here does not acquire target" while standing 2.41 m from beta (inside 4 m) with `Δ=0` — server and client agreed on position but Tab still denied. Root cause: with no lock held, `request_tab_next()` returned `ORDERED_IDS[0]` = alpha (17.90 m away), not the anchor the user was visibly standing next to. Added `PrototypeTargetConstants.next_in_range_id_after(current, world)` which walks the cycle and returns the first anchor whose client-side horizontal distance ≤ radius; `TargetSelectionClient.request_tab_next()` prefers that pick when the player ref is wired (via the existing `set_freshness_kick` path) and falls back to `next_id_after(current)` when nothing is in range so the server still owns the denial reason. Pure client-side UX improvement; server contract untouched. The picker also **skips the currently-locked id** so Tab always expresses a swap intent — if the only in-range target is what you already have, the fallback picks the next cycle id and lets the server deny (preserves the NEO-23 soft-lock "Tab = visible denial when no swap possible" rule). |
| 7 | **Prototype target geometry: shared radius + overlap** | Follow-up request: make all targetable objects share a single radius and make the rings overlap in the test scene so Tab-flipping is observable without locomotion. Introduced `PrototypeTargetRegistry.SharedLockRadius = 6.0` and moved anchors to **alpha `(-3, 0.5, -3)`** and **beta `(3, 0, 3)`** (distance ~8.49 m ⇒ ~3.5 m-wide overlap centered at origin). Spawn `(-5, -5)` keeps alpha in range (~2.83 m) and beta out (~11.31 m) so the existing manual QA "Tab-to-out-of-range = soft-lock denial" path still holds; walking to the origin puts both rings in range and Tab cleanly flips alpha ↔ beta. `client/scripts/prototype_target_constants.gd` mirrors the new values as display-only data (+ a `SHARED_LOCK_RADIUS` constant to match the server). Keep the two in lock-step; NEO-23 reader tests updated to the new boundary (`(3, -3)` for alpha radius 6). Per-target radii will return with real combat design (E5.M1); this is a prototype stub only. |

View File

@ -5,6 +5,8 @@ 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 #4 (2026-04-21):** user asked for a single shared radius across all prototype targets and overlapping rings in the test scene so Tab-flipping is observable without walking. `PrototypeTargetRegistry` now exposes a `SharedLockRadius = 6.0` constant used by both anchors; alpha moved to `(-3, 0.5, -3)` and beta to `(3, 0, 3)` so the rings overlap ~3.5 m wide centered at origin. Spawn at `(-5, -5)` keeps alpha in range / beta out (existing soft-lock denial QA path untouched); walking to origin flips both rings in-range and the range-aware Tab picker swaps alpha ↔ beta on every press. `PlayerTargetStateReaderTests` updated to the new `(3, -3)` alpha boundary plus a new origin-in-both test. `prototype_target_constants.gd` mirrors the server's `SHARED_LOCK_RADIUS`. Picker tightened to **skip the currently-locked id** — so with only one target in range and that target already locked, Tab falls back to the cycle and the server denies, keeping NEO-23's "Tab = visible denial when no swap is possible" rule. New tests: `test_tab_from_locked_alpha_at_origin_swaps_to_beta_when_both_in_range`, `test_tab_when_only_current_lock_is_in_range_falls_back_to_cycle_for_denial`. Plan Decision 7.
**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 #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: **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:

View File

@ -23,8 +23,8 @@ public class PlayerTargetStateReaderTests
[Fact] [Fact]
public void ComputeValidity_AtExactLockRadius_ReturnsOk() public void ComputeValidity_AtExactLockRadius_ReturnsOk()
{ {
// Alpha anchor XZ (-5, -5), lockRadius 8 → horizontal distance 8 from (3, -5) is on boundary (inclusive). // Alpha anchor XZ (-3, -3), lockRadius 6 → horizontal distance 6 from (3, -3) is on boundary (inclusive).
var snap = new PositionSnapshot(3, 0, -5, 0); var snap = new PositionSnapshot(3, 0, -3, 0);
Assert.Equal( Assert.Equal(
TargetValidity.Ok, TargetValidity.Ok,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap)); PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
@ -34,9 +34,23 @@ public class PlayerTargetStateReaderTests
public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange() public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange()
{ {
const double eps = 1e-6; const double eps = 1e-6;
var snap = new PositionSnapshot(3 + eps, 0, -5, 0); var snap = new PositionSnapshot(3 + eps, 0, -3, 0);
Assert.Equal( Assert.Equal(
TargetValidity.OutOfRange, TargetValidity.OutOfRange,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap)); PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
} }
[Fact]
public void ComputeValidity_AtOrigin_ShouldReturnOk_ForBothPrototypeTargets()
{
// NEO-24 post-merge: alpha (-3,-3) r=6 and beta (3,3) r=6 overlap at origin so Tab can
// flip without locomotion. Origin is ~4.24 m from each anchor (< 6 m).
var snap = new PositionSnapshot(0, 0, 0, 0);
Assert.Equal(
TargetValidity.Ok,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
Assert.Equal(
TargetValidity.Ok,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap));
}
} }

View File

@ -3,16 +3,24 @@ namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase.</summary> /// <summary>Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase.</summary>
public static class PrototypeTargetRegistry public static class PrototypeTargetRegistry
{ {
/// <summary>Near default dev spawn <c>(-5, -5)</c> on XZ.</summary> /// <summary>
/// All prototype combat targets share a single <c>LockRadius</c>. Scoped to prototype
/// stubs — per-target radii will come back with real combat design (E5.M1). Changing
/// this requires updating <c>client/scripts/prototype_target_constants.gd</c> in the
/// same commit so markers/HUD match server range checks.
/// </summary>
public const double SharedLockRadius = 6.0;
/// <summary>On the -x/-z side of spawn; overlaps <see cref="PrototypeTargetBeta"/>'s ring at origin so Tab can flip without moving.</summary>
public const string PrototypeTargetAlphaId = "prototype_target_alpha"; public const string PrototypeTargetAlphaId = "prototype_target_alpha";
/// <summary>Farther stub for out-of-range tests; ascending id order for NEO-24 tab cycle.</summary> /// <summary>On the +x/+z side; mirrors alpha across the origin for symmetric overlap (NEO-24 Tab cycle).</summary>
public const string PrototypeTargetBetaId = "prototype_target_beta"; public const string PrototypeTargetBetaId = "prototype_target_beta";
/// <summary>Alpha anchor aligned with spawn horizontal cell; <see cref="PrototypeTargetEntry.LockRadius"/> on XZ only.</summary> /// <summary>Alpha anchor; <see cref="PrototypeTargetEntry.LockRadius"/> is XZ-only. Distance to beta ~8.49 m ⇒ ~3.5 m overlap at origin.</summary>
public static readonly PrototypeTargetEntry PrototypeTargetAlpha = new(-5, 0.5, -5, 8.0); public static readonly PrototypeTargetEntry PrototypeTargetAlpha = new(-3, 0.5, -3, SharedLockRadius);
public static readonly PrototypeTargetEntry PrototypeTargetBeta = new(8, 0, 8, 4.0); public static readonly PrototypeTargetEntry PrototypeTargetBeta = new(3, 0, 3, SharedLockRadius);
private static readonly Dictionary<string, PrototypeTargetEntry> ById = new(StringComparer.Ordinal) private static readonly Dictionary<string, PrototypeTargetEntry> ById = new(StringComparer.Ordinal)
{ {