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
parent
63fecb1099
commit
a7453eb82f
|
|
@ -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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.
|
||||
|
||||
### Manual check (NEO-24)
|
||||
|
||||
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap.
|
||||
2. Press **Tab**: HUD → `Target: prototype_target_alpha`, `Validity: ok` (spawn is inside alpha's 8 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.
|
||||
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.
|
||||
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).
|
||||
6. Press **Esc**: HUD → `Target: —`, `Validity: none`, sequence advances.
|
||||
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 ~2.83 m from alpha's anchor `(-3, -3)`, well inside the shared 6 m radius).
|
||||
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 ~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 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. 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,27 @@ extends Object
|
|||
|
||||
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
|
||||
## (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 = {
|
||||
"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":
|
||||
{"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]
|
||||
## (client-side horizontal distance ≤ anchor radius), starting after [param current] and
|
||||
## wrapping last → first. Returns [code]""[/code] when no anchor is in range so callers can
|
||||
## fall back to [method next_id_after] and let the server pick the denial reason.
|
||||
## (client-side horizontal distance ≤ anchor radius) **and not equal to [param current]**,
|
||||
## starting after [param current] and wrapping last → first. Returns [code]""[/code] when
|
||||
## 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
|
||||
## 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:
|
||||
if ORDERED_IDS.is_empty():
|
||||
return ""
|
||||
var current_str: String = ""
|
||||
var start_idx: int = 0
|
||||
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():
|
||||
var found: int = ORDERED_IDS.find(current_str)
|
||||
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()):
|
||||
var probe_idx: int = (start_idx + i) % ORDERED_IDS.size()
|
||||
var probe_id: String = ORDERED_IDS[probe_idx]
|
||||
if probe_id == current_str:
|
||||
continue
|
||||
var radius: float = anchor_radius(probe_id)
|
||||
if radius <= 0.0:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
# 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.
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
|
|
@ -275,8 +275,8 @@ func test_tab_from_no_lock_skips_out_of_range_and_picks_beta() -> void:
|
|||
var authority := FreshnessAuthorityStub.new()
|
||||
auto_free(authority)
|
||||
add_child(authority)
|
||||
# Near beta anchor (8, 8), ~2.4 m horizontal — inside 4 m. Far from alpha at (-5, -5).
|
||||
var player := _make_player(Vector3(5.9, 0.5, 9.2))
|
||||
# Near beta anchor (3, 3), ~1.4 m horizontal — inside 6 m. Far from alpha at (-3, -3).
|
||||
var player := _make_player(Vector3(4.0, 0.5, 4.0))
|
||||
c.set_freshness_kick(authority, player)
|
||||
c.request_tab_next()
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ Pair the client (Godot **F5**) with the server (`cd server/NeonSprawl.Server &&
|
|||
- `Target: —`
|
||||
- `Validity: none`
|
||||
- `Seq: 0`
|
||||
- `prototype_target_alpha: 0.00 m / 8.0 (in)` (spawn `(-5,-5)` is exactly on alpha's XZ anchor)
|
||||
- `prototype_target_beta: 18.38 m / 4.0 (out)`
|
||||
- `prototype_target_alpha: 2.83 m / 6.0 (in)` (spawn `(-5,-5)` is ~2.83 m from alpha anchor `(-3,-3)`)
|
||||
- `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.
|
||||
- [ ] **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.
|
||||
|
||||
## 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
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
## 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)
|
||||
- `Validity: out_of_range`
|
||||
- `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.
|
||||
- [ ] 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`
|
||||
- `Validity: ok`
|
||||
- `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.
|
||||
- [ ] **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).
|
||||
- [ ] 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 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.
|
||||
|
||||
## 6. Clear
|
||||
## 7. Clear
|
||||
|
||||
- [ ] Press **Esc** at any point with a lock held. HUD flips to:
|
||||
- `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).
|
||||
- [ ] 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.
|
||||
- [ ] 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).
|
||||
- [ ] **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).
|
||||
- [ ] **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).
|
||||
- [ ] 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.
|
||||
|
||||
## 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).
|
||||
- [ ] 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).
|
||||
- [ ] 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 1–10 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 1–11 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).
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ Scope: `NEO-24-e1m3-client-tab-target-lock-ui-synced-to-server` (`origin/main...
|
|||
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.
|
||||
|
||||
**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 #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:
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ public class PlayerTargetStateReaderTests
|
|||
[Fact]
|
||||
public void ComputeValidity_AtExactLockRadius_ReturnsOk()
|
||||
{
|
||||
// Alpha anchor XZ (-5, -5), lockRadius 8 → horizontal distance 8 from (3, -5) is on boundary (inclusive).
|
||||
var snap = new PositionSnapshot(3, 0, -5, 0);
|
||||
// Alpha anchor XZ (-3, -3), lockRadius 6 → horizontal distance 6 from (3, -3) is on boundary (inclusive).
|
||||
var snap = new PositionSnapshot(3, 0, -3, 0);
|
||||
Assert.Equal(
|
||||
TargetValidity.Ok,
|
||||
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
|
||||
|
|
@ -34,9 +34,23 @@ public class PlayerTargetStateReaderTests
|
|||
public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange()
|
||||
{
|
||||
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(
|
||||
TargetValidity.OutOfRange,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
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";
|
||||
|
||||
/// <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";
|
||||
|
||||
/// <summary>Alpha anchor aligned with spawn horizontal cell; <see cref="PrototypeTargetEntry.LockRadius"/> on XZ only.</summary>
|
||||
public static readonly PrototypeTargetEntry PrototypeTargetAlpha = new(-5, 0.5, -5, 8.0);
|
||||
/// <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(-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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue