diff --git a/client/README.md b/client/README.md index 333b82e..3d06e75 100644 --- a/client/README.md +++ b/client/README.md @@ -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. - **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 scene’s 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) 1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`. diff --git a/client/scripts/target_selection_client.gd b/client/scripts/target_selection_client.gd index 56b96a3..46bdaf1 100644 --- a/client/scripts/target_selection_client.gd +++ b/client/scripts/target_selection_client.gd @@ -1,6 +1,8 @@ extends Node ## 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: ## - `request_sync_from_server()` → `GET …/target` returns `PlayerTargetStateResponse` v1. @@ -19,6 +21,12 @@ extends Node 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 } 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 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 _busy: bool = false 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. 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: return HTTPRequest.new() @@ -81,6 +96,7 @@ func _input(event: InputEvent) -> void: func request_sync_from_server() -> void: if _busy: return + _http_selection_cause = "server_correction" _busy = true _phase = Phase.GET _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) _busy = false _phase = Phase.IDLE + _http_selection_cause = "" func request_tab_next() -> void: @@ -117,12 +134,12 @@ func request_select_target_id(target_id: String) -> void: return var payload: Dictionary = {"schemaVersion": 1, "targetId": trimmed} _maybe_attach_position_hint(payload) - _post_select(payload) + _post_select(payload, "tab") func request_clear_target() -> void: # 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 @@ -172,9 +189,10 @@ func _has_lock() -> bool: 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: return + _http_selection_cause = selection_cause _kick_freshness_stream() _busy = true _phase = Phase.POST_SELECT @@ -186,6 +204,7 @@ func _post_select(payload: Dictionary) -> void: push_warning("TargetSelectionClient: POST failed to start (%s)" % err) _busy = false _phase = Phase.IDLE + _http_selection_cause = "" ## 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: push_warning("TargetSelectionClient: HTTP failed (result=%s)" % _result) + _http_selection_cause = "" return var text := body.get_string_from_utf8() @@ -227,6 +247,7 @@ func _on_request_completed( if not parsed is Dictionary: if response_code != 200: push_warning("TargetSelectionClient: HTTP %s non-JSON body" % response_code) + _http_selection_cause = "" return var data: Dictionary = parsed @@ -234,37 +255,51 @@ func _on_request_completed( Phase.GET: if response_code == 200: _update_state_from_get(data) + else: + _http_selection_cause = "" Phase.POST_SELECT: # Server includes authoritative `targetState` on 200 (apply and denial). 400 / 404 # bodies may lack it — swallow quietly; `_state` keeps last-good. if response_code == 200: _update_state_from_post(data) + else: + _http_selection_cause = "" _: - pass + _http_selection_cause = "" func _update_state_from_get(data: Dictionary) -> void: var state := _extract_target_state_fields(data) + var cause := _http_selection_cause + _http_selection_cause = "" if state.is_empty(): return + var prev: Dictionary = _state.duplicate() _state = state target_state_changed.emit(_state.duplicate()) + _maybe_emit_selection_event(prev, state, cause) func _update_state_from_post(data: Dictionary) -> void: var target_state_variant: Variant = data.get("targetState", null) if not target_state_variant is Dictionary: + _http_selection_cause = "" push_warning("TargetSelectionClient: POST 200 missing targetState") return var state := _extract_target_state_fields(target_state_variant as Dictionary) if state.is_empty(): + _http_selection_cause = "" return + var cause := _http_selection_cause + _http_selection_cause = "" state["selectionApplied"] = bool(data.get("selectionApplied", false)) var reason_variant: Variant = data.get("reasonCode", null) if reason_variant is String and not (reason_variant as String).is_empty(): state["reasonCode"] = reason_variant as String + var prev: Dictionary = _state.duplicate() _state = state 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). @@ -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["sequence"] = int(data.get("sequence", 0)) 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) diff --git a/client/test/selection_event_client_test.gd b/client/test/selection_event_client_test.gd new file mode 100644 index 0000000..2677e70 --- /dev/null +++ b/client/test/selection_event_client_test.gd @@ -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) diff --git a/client/test/selection_event_client_test.gd.uid b/client/test/selection_event_client_test.gd.uid new file mode 100644 index 0000000..c590918 --- /dev/null +++ b/client/test/selection_event_client_test.gd.uid @@ -0,0 +1 @@ +uid://ksj15nsjl6ph diff --git a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md index f9611a7..04e6f6b 100644 --- a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md +++ b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md @@ -100,7 +100,7 @@ flowchart LR ## 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). - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). diff --git a/docs/manual-qa/NEO-26.md b/docs/manual-qa/NEO-26.md new file mode 100644 index 0000000..b8d4419 --- /dev/null +++ b/docs/manual-qa/NEO-26.md @@ -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. diff --git a/docs/plans/NEO-26-implementation-plan.md b/docs/plans/NEO-26-implementation-plan.md index b0720e1..56c4421 100644 --- a/docs/plans/NEO-26-implementation-plan.md +++ b/docs/plans/NEO-26-implementation-plan.md @@ -32,10 +32,10 @@ ## 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). -- [ ] **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). -- [ ] **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] **Tab** path: after a successful selection cycle that **changes** `lockedTargetId`, a `SelectionEvent` is emitted with `cause` **`tab`** (and correct previous/next ids). +- [x] **Clear** path: clearing the lock emits **`clear`** when `lockedTargetId` transitions from non-null to `null`. +- [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`. +- [x] **E1.M4 hook:** this plan + **`client/README.md`** (“SelectionEvent (NEO-26)”) document **`TargetSelectionClient.selection_event`** and stable payload keys. ## 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. -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. @@ -61,6 +61,7 @@ | 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). | +| `docs/manual-qa/NEO-26.md` | Manual QA for `log_selection_events` + optional subscription check. | ## 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/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