NEO-26: emit selection_event from TargetSelectionClient

Add selection_event signal when lockedTargetId changes; attribute tab, clear,
and server_correction from POST/GET paths. Optional log_selection_events print.
GdUnit coverage; README, E1.M3 snapshot, manual QA, and plan checklist updated.
pull/51/head
VinPropane 2026-04-25 19:33:53 -04:00
parent acb8ccf3c1
commit 5127fe59a7
7 changed files with 265 additions and 11 deletions

View File

@ -107,6 +107,15 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
- **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`.
### SelectionEvent (NEO-26)
- **`TargetSelectionClient`** emits **`selection_event(event: Dictionary)`** only when the server-acknowledged **`lockedTargetId`** changes (not on **`validity`-only** updates; those stay on **`target_state_changed`**).
- Payload keys: **`previous`** ([String] or `null`), **`next`** (same), **`cause`** (`tab` \| `clear` \| `server_correction`), **`sequence`** (int from the new state). **`POST …/target/select`** from **`request_select_target_id`** (including the path used by Tab) uses cause **`tab`**; **`request_clear_target`** uses **`clear`**; every **`GET …/target`** completion uses **`server_correction`** (including boot sync).
- **Dev logging:** enable **`log_selection_events`** on the `TargetSelectionClient` node to mirror each event to the Godot **Output** (`print`).
- **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scenes path to that node) without reshaping the payload.
Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md).
### Manual check (NEO-24) ### Manual check (NEO-24)
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`. 1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.

View File

@ -1,6 +1,8 @@
extends Node extends Node
## NEO-24 (E1M3-02): client tab-target + lock UI synced to server (NEO-23 `TargetState` v1). ## NEO-24 (E1M3-02): client tab-target + lock UI synced to server (NEO-23 `TargetState` v1).
## NEO-26 (E1M3-04): emits [signal selection_event] when server-acknowledged [code]lockedTargetId[/code]
## changes (see [member log_selection_events]).
## ##
## Flow: ## Flow:
## - `request_sync_from_server()` → `GET …/target` returns `PlayerTargetStateResponse` v1. ## - `request_sync_from_server()` → `GET …/target` returns `PlayerTargetStateResponse` v1.
@ -19,6 +21,12 @@ extends Node
signal target_state_changed(state: Dictionary) signal target_state_changed(state: Dictionary)
## NEO-26: fired only when normalized [code]lockedTargetId[/code] changes after a GET/POST.
## Payload: [code]previous[/code] ([String] or [code]null[/code]), [code]next[/code] (same),
## [code]cause[/code] ([code]"tab"[/code] | [code]"clear"[/code] | [code]"server_correction"[/code]),
## [code]sequence[/code] ([int], from new state). E1.M4+ may [code]connect[/code] here without reshaping.
signal selection_event(event: Dictionary)
enum Phase { IDLE, GET, POST_SELECT } enum Phase { IDLE, GET, POST_SELECT }
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
@ -30,6 +38,9 @@ const _REFRESH_COOLDOWN_MSEC: int = 250
@export var base_url: String = "http://127.0.0.1:5253" @export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1" @export var dev_player_id: String = "dev-local-1"
## When true, each [signal selection_event] is also printed to the Godot output (dev aid only).
@export var log_selection_events: bool = false
var _http: Node var _http: Node
var _busy: bool = false var _busy: bool = false
var _phase: Phase = Phase.IDLE var _phase: Phase = Phase.IDLE
@ -54,6 +65,10 @@ var _state: Dictionary = {}
## `-_REFRESH_COOLDOWN_MSEC` so the first snap is never blocked by the window on boot. ## `-_REFRESH_COOLDOWN_MSEC` so the first snap is never blocked by the window on boot.
var _last_refresh_msec: int = -_REFRESH_COOLDOWN_MSEC var _last_refresh_msec: int = -_REFRESH_COOLDOWN_MSEC
## Cause for the in-flight HTTP request (GET → [code]server_correction[/code]; POST → [code]tab[/code] / [code]clear[/code]).
## Set immediately before a request starts; cleared when the response is applied or dropped.
var _http_selection_cause: String = ""
func _create_http_request() -> Node: func _create_http_request() -> Node:
return HTTPRequest.new() return HTTPRequest.new()
@ -81,6 +96,7 @@ func _input(event: InputEvent) -> void:
func request_sync_from_server() -> void: func request_sync_from_server() -> void:
if _busy: if _busy:
return return
_http_selection_cause = "server_correction"
_busy = true _busy = true
_phase = Phase.GET _phase = Phase.GET
_last_refresh_msec = Time.get_ticks_msec() _last_refresh_msec = Time.get_ticks_msec()
@ -90,6 +106,7 @@ func request_sync_from_server() -> void:
push_warning("TargetSelectionClient: GET failed to start (%s)" % err) push_warning("TargetSelectionClient: GET failed to start (%s)" % err)
_busy = false _busy = false
_phase = Phase.IDLE _phase = Phase.IDLE
_http_selection_cause = ""
func request_tab_next() -> void: func request_tab_next() -> void:
@ -117,12 +134,12 @@ func request_select_target_id(target_id: String) -> void:
return return
var payload: Dictionary = {"schemaVersion": 1, "targetId": trimmed} var payload: Dictionary = {"schemaVersion": 1, "targetId": trimmed}
_maybe_attach_position_hint(payload) _maybe_attach_position_hint(payload)
_post_select(payload) _post_select(payload, "tab")
func request_clear_target() -> void: func request_clear_target() -> void:
# NEO-23: omit `targetId` to clear. JSON `null` also works; omission keeps the body minimal. # NEO-23: omit `targetId` to clear. JSON `null` also works; omission keeps the body minimal.
_post_select({"schemaVersion": 1}) _post_select({"schemaVersion": 1}, "clear")
## NEO-24 follow-up #5: attach the live capsule position as an advisory `positionHint` so the ## NEO-24 follow-up #5: attach the live capsule position as an advisory `positionHint` so the
@ -172,9 +189,10 @@ func _has_lock() -> bool:
return v is String and not (v as String).is_empty() return v is String and not (v as String).is_empty()
func _post_select(payload: Dictionary) -> void: func _post_select(payload: Dictionary, selection_cause: String) -> void:
if _busy: if _busy:
return return
_http_selection_cause = selection_cause
_kick_freshness_stream() _kick_freshness_stream()
_busy = true _busy = true
_phase = Phase.POST_SELECT _phase = Phase.POST_SELECT
@ -186,6 +204,7 @@ func _post_select(payload: Dictionary) -> void:
push_warning("TargetSelectionClient: POST failed to start (%s)" % err) push_warning("TargetSelectionClient: POST failed to start (%s)" % err)
_busy = false _busy = false
_phase = Phase.IDLE _phase = Phase.IDLE
_http_selection_cause = ""
## Nudges the authority with the current capsule position so the server's stored snapshot ## Nudges the authority with the current capsule position so the server's stored snapshot
@ -220,6 +239,7 @@ func _on_request_completed(
if _result != HTTPRequest.RESULT_SUCCESS: if _result != HTTPRequest.RESULT_SUCCESS:
push_warning("TargetSelectionClient: HTTP failed (result=%s)" % _result) push_warning("TargetSelectionClient: HTTP failed (result=%s)" % _result)
_http_selection_cause = ""
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
@ -227,6 +247,7 @@ func _on_request_completed(
if not parsed is Dictionary: if not parsed is Dictionary:
if response_code != 200: if response_code != 200:
push_warning("TargetSelectionClient: HTTP %s non-JSON body" % response_code) push_warning("TargetSelectionClient: HTTP %s non-JSON body" % response_code)
_http_selection_cause = ""
return return
var data: Dictionary = parsed var data: Dictionary = parsed
@ -234,37 +255,51 @@ func _on_request_completed(
Phase.GET: Phase.GET:
if response_code == 200: if response_code == 200:
_update_state_from_get(data) _update_state_from_get(data)
else:
_http_selection_cause = ""
Phase.POST_SELECT: Phase.POST_SELECT:
# Server includes authoritative `targetState` on 200 (apply and denial). 400 / 404 # Server includes authoritative `targetState` on 200 (apply and denial). 400 / 404
# bodies may lack it — swallow quietly; `_state` keeps last-good. # bodies may lack it — swallow quietly; `_state` keeps last-good.
if response_code == 200: if response_code == 200:
_update_state_from_post(data) _update_state_from_post(data)
else:
_http_selection_cause = ""
_: _:
pass _http_selection_cause = ""
func _update_state_from_get(data: Dictionary) -> void: func _update_state_from_get(data: Dictionary) -> void:
var state := _extract_target_state_fields(data) var state := _extract_target_state_fields(data)
var cause := _http_selection_cause
_http_selection_cause = ""
if state.is_empty(): if state.is_empty():
return return
var prev: Dictionary = _state.duplicate()
_state = state _state = state
target_state_changed.emit(_state.duplicate()) target_state_changed.emit(_state.duplicate())
_maybe_emit_selection_event(prev, state, cause)
func _update_state_from_post(data: Dictionary) -> void: func _update_state_from_post(data: Dictionary) -> void:
var target_state_variant: Variant = data.get("targetState", null) var target_state_variant: Variant = data.get("targetState", null)
if not target_state_variant is Dictionary: if not target_state_variant is Dictionary:
_http_selection_cause = ""
push_warning("TargetSelectionClient: POST 200 missing targetState") push_warning("TargetSelectionClient: POST 200 missing targetState")
return return
var state := _extract_target_state_fields(target_state_variant as Dictionary) var state := _extract_target_state_fields(target_state_variant as Dictionary)
if state.is_empty(): if state.is_empty():
_http_selection_cause = ""
return return
var cause := _http_selection_cause
_http_selection_cause = ""
state["selectionApplied"] = bool(data.get("selectionApplied", false)) state["selectionApplied"] = bool(data.get("selectionApplied", false))
var reason_variant: Variant = data.get("reasonCode", null) var reason_variant: Variant = data.get("reasonCode", null)
if reason_variant is String and not (reason_variant as String).is_empty(): if reason_variant is String and not (reason_variant as String).is_empty():
state["reasonCode"] = reason_variant as String state["reasonCode"] = reason_variant as String
var prev: Dictionary = _state.duplicate()
_state = state _state = state
target_state_changed.emit(_state.duplicate()) target_state_changed.emit(_state.duplicate())
_maybe_emit_selection_event(prev, state, cause)
## Shape-only parse of the v1 target state payload (works for GET body and POST echo). ## Shape-only parse of the v1 target state payload (works for GET body and POST echo).
@ -285,3 +320,30 @@ func _extract_target_state_fields(data: Dictionary) -> Dictionary:
out["validity"] = (validity_variant as String) if validity_variant is String else "none" out["validity"] = (validity_variant as String) if validity_variant is String else "none"
out["sequence"] = int(data.get("sequence", 0)) out["sequence"] = int(data.get("sequence", 0))
return out return out
func _normalized_lock_id(state: Dictionary) -> Variant:
if state.is_empty():
return null
var v: Variant = state.get("lockedTargetId", null)
if v is String and not (v as String).is_empty():
return v as String
return null
func _maybe_emit_selection_event(prev_state: Dictionary, new_state: Dictionary, cause: String) -> void:
var prev_id: Variant = _normalized_lock_id(prev_state)
var next_id: Variant = _normalized_lock_id(new_state)
if prev_id == next_id:
return
if cause.is_empty():
return
var ev: Dictionary = {
"previous": prev_id,
"next": next_id,
"cause": cause,
"sequence": int(new_state.get("sequence", 0)),
}
selection_event.emit(ev)
if log_selection_events:
print("[TargetSelectionClient] SelectionEvent ", ev)

View File

@ -0,0 +1,156 @@
extends GdUnitTestSuite
## NEO-26: `TargetSelectionClient.selection_event` — cause strings and lock-id-only rule.
const TargetSelectionTestDouble := preload("res://test/target_selection_test_double.gd")
const ALPHA_ID := "prototype_target_alpha"
const BETA_ID := "prototype_target_beta"
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var _queue: Array[Dictionary] = []
func enqueue(result: int, code: int, body: String) -> void:
_queue.append({"result": result, "code": code, "body": body})
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
if _queue.is_empty():
return ERR_UNAVAILABLE
var r: Dictionary = _queue.pop_front()
var body_bytes: PackedByteArray = str(r["body"]).to_utf8_buffer()
request_completed.emit(r["result"], r["code"], PackedStringArray(), body_bytes)
return OK
func _make_client(http_transport: Node) -> Node:
var c: Node = TargetSelectionTestDouble.new()
c.set("injected_http", http_transport)
auto_free(http_transport)
auto_free(c)
add_child(c)
return c
func _target_state_json(locked_id_or_null: Variant, validity: String, sequence: int = 0) -> String:
var locked := "null"
if locked_id_or_null is String and not (locked_id_or_null as String).is_empty():
locked = '"%s"' % (locked_id_or_null as String)
return (
'{"schemaVersion":1,"playerId":"dev-local-1","lockedTargetId":%s,' % locked
+ '"validity":"%s","sequence":%d}' % [validity, sequence]
)
func _select_response_json(
applied: bool,
locked_id_or_null: Variant,
validity: String,
sequence: int = 0,
reason: String = ""
) -> String:
var target_state := _target_state_json(locked_id_or_null, validity, sequence)
if applied:
return '{"schemaVersion":1,"selectionApplied":true,"targetState":%s}' % target_state
var reason_segment := ""
if not reason.is_empty():
reason_segment = ',"reasonCode":"%s"' % reason
return (
'{"schemaVersion":1,"selectionApplied":false%s,"targetState":%s}'
% [reason_segment, target_state]
)
func test_tab_lock_change_emits_tab() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
)
var c := _make_client(transport)
var events: Array = []
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
c.request_tab_next()
assert_that(events.size()).is_equal(1)
var last: Dictionary = events[0] as Dictionary
assert_that(last.get("cause", "")).is_equal("tab")
assert_that(last.get("previous")).is_null()
assert_that(last.get("next")).is_equal(ALPHA_ID)
func test_clear_lock_change_emits_clear() -> void:
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, null, "none", 2)
)
var c := _make_client(transport)
var events: Array = []
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
c.request_tab_next()
c.request_clear_target()
assert_that(events.size()).is_equal(2)
assert_that((events[1] as Dictionary).get("cause", "")).is_equal("clear")
assert_that((events[1] as Dictionary).get("previous")).is_equal(ALPHA_ID)
assert_that((events[1] as Dictionary).get("next")).is_null()
func test_get_lock_id_change_emits_server_correction() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
)
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(BETA_ID, "ok", 2)
)
var c := _make_client(transport)
var events: Array = []
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
c.request_tab_next()
c.request_sync_from_server()
assert_that(events.size()).is_equal(2)
var last: Dictionary = events[1] as Dictionary
assert_that(last.get("cause", "")).is_equal("server_correction")
assert_that(last.get("previous")).is_equal(ALPHA_ID)
assert_that(last.get("next")).is_equal(BETA_ID)
func test_get_validity_only_change_emits_nothing() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
)
transport.enqueue(
HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1)
)
var c := _make_client(transport)
var events: Array = []
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
c.request_tab_next()
c.request_sync_from_server()
assert_that(events.size()).is_equal(1)
assert_that((events[0] as Dictionary).get("cause", "")).is_equal("tab")
func test_denial_without_lock_id_change_emits_nothing() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS,
200,
_select_response_json(false, null, "none", 0, "out_of_range")
)
var c := _make_client(transport)
var events: Array = []
c.selection_event.connect(func(ev: Dictionary) -> void: events.append(ev.duplicate()))
c.request_select_target_id(ALPHA_ID)
assert_that(events.size()).is_equal(0)

View File

@ -0,0 +1 @@
uid://ksj15nsjl6ph

View File

@ -100,7 +100,7 @@ flowchart LR
## Implementation snapshot ## Implementation snapshot
- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). Godot tab-target: [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24). **`InteractableDescriptor` list:** **`GET /game/world/interactables`** (NEO-25). **Still open on later issues:** **`SelectionEvent`**. **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md). - **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). Godot tab-target: [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24). **`InteractableDescriptor` list:** **`GET /game/world/interactables`** (NEO-25). **`SelectionEvent` (prototype):** Godot **`TargetSelectionClient.selection_event`** when **`lockedTargetId`** changes — [NEO-26](../../plans/NEO-26-implementation-plan.md). **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
- **Precursor under E1.M1 (NEO-9 + NEO-25):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`GET /game/world/interactables`** (versioned descriptor list); **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry under `server/NeonSprawl.Server/Game/Interaction/`; client **`interactables_catalog_client.gd`**, **`interactable_world_builder.gd`**, **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-25](../../plans/NEO-25-implementation-plan.md), and [server README — Interaction](../../../server/README.md#interaction-neo-9). - **Precursor under E1.M1 (NEO-9 + NEO-25):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`GET /game/world/interactables`** (versioned descriptor list); **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry under `server/NeonSprawl.Server/Game/Interaction/`; client **`interactables_catalog_client.gd`**, **`interactable_world_builder.gd`**, **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-25](../../plans/NEO-25-implementation-plan.md), and [server README — Interaction](../../../server/README.md#interaction-neo-9).
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).

View File

@ -0,0 +1,25 @@
# NEO-26 — Manual QA checklist
| Field | Value |
|--------|--------|
| **Key** | NEO-26 |
| **Title** | E1.M3: SelectionEvent surface + debug or HUD consumer |
| **Linear** | [NEO-26](https://linear.app/neon-sprawl/issue/NEO-26/e1m3-selectionevent-surface-debug-or-hud-consumer) |
| **Plan** | [`docs/plans/NEO-26-implementation-plan.md`](../plans/NEO-26-implementation-plan.md) |
Pair client (**F5**) with the server. Open the Godot **Output** dock.
## 1. `log_selection_events`
- [ ] Select **`TargetSelectionClient`** in the scene tree; set **`log_selection_events`** to **On** in the Inspector.
- [ ] Run with server. Press **Tab** to lock a target: Output shows a line **`[TargetSelectionClient] SelectionEvent`** with `cause=tab`, `previous` null or prior id, `next` = locked id.
- [ ] Press **Esc** to clear: next line shows **`cause=clear`**, `next` null when lock cleared.
- [ ] Turn **`log_selection_events`** **Off**; Tab / Esc no longer print selection lines (HUD from NEO-24 unchanged).
## 2. Movement GET (validity-only)
- [ ] With logging **off**, lock **alpha**, **WASD** until HUD shows **`Validity: out_of_range`** (NEO-24 soft lock). No requirement for extra **Output** lines from NEO-26 here — **id-only** events suppress **`validity`-only** GET updates.
## 3. Subscription sanity (optional)
- [ ] In a throwaway local script or remote inspector, **`connect`** to **`selection_event`** and confirm the same payloads as printed when logging is on.

View File

@ -32,10 +32,10 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] **Tab** path: after a successful selection cycle that **changes** `lockedTargetId`, a `SelectionEvent` is emitted with `cause` **`tab`** (and correct previous/next ids). - [x] **Tab** path: after a successful selection cycle that **changes** `lockedTargetId`, a `SelectionEvent` is emitted with `cause` **`tab`** (and correct previous/next ids).
- [ ] **Clear** path: clearing the lock emits **`clear`** when `lockedTargetId` transitions from non-null to `null`. - [x] **Clear** path: clearing the lock emits **`clear`** when `lockedTargetId` transitions from non-null to `null`.
- [ ] **Server correction:** a **GET**-driven authoritative update (e.g. movement-triggered refresh from `on_authoritative_ack``request_sync_from_server`) that **changes** `lockedTargetId` emits **`server_correction`**. If the prototype server never changes id on GET (only `validity`), document that **id-unchanged** refreshes do **not** emit `SelectionEvent` for v1, and add a test or comment proving GET is still classified when id *does* change (or a focused mock scenario). - [x] **Server correction:** a **GET**-driven authoritative update that **changes** `lockedTargetId` emits **`server_correction`**. **Id-unchanged** GETs (e.g. **`validity`** only) do **not** emit `SelectionEvent` for v1 — `test_get_validity_only_change_emits_nothing`; id change via mock in `test_get_lock_id_change_emits_server_correction`.
- [ ] **E1.M4 hook:** `docs/plans/NEO-26-implementation-plan.md` (and optionally `client/README.md` under targeting) states **which node** exposes **`selection_event` (or agreed name)** and that E1.M4 may `connect` without reshaping the payload. - [x] **E1.M4 hook:** this plan + **`client/README.md`** (“SelectionEvent (NEO-26)”) document **`TargetSelectionClient.selection_event`** and stable payload keys.
## Technical approach ## Technical approach
@ -52,7 +52,7 @@
4. **Signal:** Add e.g. **`selection_event(event: Dictionary)`** with keys `previous`, `next`, `cause` (and optional `sequence` int passthrough for debugging). Keep **`target_state_changed`** unchanged for existing HUD. 4. **Signal:** Add e.g. **`selection_event(event: Dictionary)`** with keys `previous`, `next`, `cause` (and optional `sequence` int passthrough for debugging). Keep **`target_state_changed`** unchanged for existing HUD.
5. **Consumer:** No new scene nodes — **`TargetSelectionClient`** (or dedicated debug child) implements optional **`print`** when export flag enabled; ring buffer optional in-memory for test assertions only. 5. **Consumer:** No new scene nodes — **`TargetSelectionClient.log_selection_events`** mirrors each **`selection_event`** to **Output** via **`print`**.
6. **Docs:** Update **`client/README.md`** “Target lock + tab cycle” area with a short **“SelectionEvent (NEO-26)”** subsection: signal name, payload keys, print flag, E1.M4 subscription note. 6. **Docs:** Update **`client/README.md`** “Target lock + tab cycle” area with a short **“SelectionEvent (NEO-26)”** subsection: signal name, payload keys, print flag, E1.M4 subscription note.
@ -61,6 +61,7 @@
| Path | Purpose | | Path | Purpose |
|------|---------| |------|---------|
| `client/test/selection_event_client_test.gd` | GdUnit4: assert causes for tab POST, clear POST, and GET-with-mock-body changing lock id (`TargetSelectionTestDouble` pattern). | | `client/test/selection_event_client_test.gd` | GdUnit4: assert causes for tab POST, clear POST, and GET-with-mock-body changing lock id (`TargetSelectionTestDouble` pattern). |
| `docs/manual-qa/NEO-26.md` | Manual QA for `log_selection_events` + optional subscription check. |
## Files to modify ## Files to modify
@ -77,7 +78,7 @@
| `client/test/selection_event_client_test.gd` | **Add:** Tab mock POST changes lock → event `cause == "tab"`. Clear mock → `"clear"`. Simulated GET completion after flagging movement refresh → `"server_correction"` when body changes `lockedTargetId`. Assert **no** event when id unchanged. | | `client/test/selection_event_client_test.gd` | **Add:** Tab mock POST changes lock → event `cause == "tab"`. Clear mock → `"clear"`. Simulated GET completion after flagging movement refresh → `"server_correction"` when body changes `lockedTargetId`. Assert **no** event when id unchanged. |
| `client/test/target_selection_client_test.gd` | **Change (optional):** If new behavior duplicates setup, keep NEO-24 tests focused on HTTP/state; only touch this file when sharing helpers avoids copy-paste. | | `client/test/target_selection_client_test.gd` | **Change (optional):** If new behavior duplicates setup, keep NEO-24 tests focused on HTTP/state; only touch this file when sharing helpers avoids copy-paste. |
**Manual verification:** Run server + client; tab and clear; walk until soft-lock story changes validity — confirm debug line classification matches plan; screenshot or note in story if `server_correction` only appears when GET changes id. **Manual verification:** [`docs/manual-qa/NEO-26.md`](../../manual-qa/NEO-26.md).
## Open questions / risks ## Open questions / risks