From 34dbac9bf6f68ce097248c9a49b61e72d4b06838 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 18:51:45 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(ui):=20AtlasAgentInterface=20=E2=80=94?= =?UTF-8?q?=20observe/act=20named-intent=20control=20channel=20(D-226,=20T?= =?UTF-8?q?-971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-226 layer 4, rebuilt against the post-D-255 stepped Atlas after the Phase-1 reconciliation (the original intent list targeted the retired continuous-zoom viewer). Eleven intents, each backed by the exact production handler a click calls — select/open for systems and bodies (extracted shared by-id tails so click and intent paths are one code path), scroll_rung, reset_view, back, open/close_atlas, set_overlay — plus two new first-class capabilities: jump_to_center (the fixed-center revisit pattern proven by five eyeball drivers, via a new StepCanvasViewer.jump_to seam that reuses _scroll_rung's exact request tail — same extent cap, same cache keys) and get_current_canvas_summary (allocation-light reads off the raw wire dict, courses-by-class, draw-matched settlement dedup — no PNG decode). Contract shape: dumb AtlasAgentBridge autoload holding the app handle (untyped per the parse-order rule), all logic in the static AtlasAgentInterface class. observe() is side-effect-free: current state + a generic Control-walk affordance tree. In-process consumers only this ticket (documented); the committed reference driver (atlas_agent_driver.gd, InputSwallower + settle-until-ready from the T-1157 inventory) replaces the scratch eyeball drivers as the sanctioned headless-drive pattern. select_city/open_regional dropped with recorded rationale (no settlement hit-test affordance exists post-D-255) — diff on the ticket. 33 new tests across three suites. Co-Authored-By: Claude Fable 5 --- client/project.godot | 1 + .../scripts/autoloads/atlas_agent_bridge.gd | 29 ++ client/tests/atlas_agent_driver.gd | 164 +++++++++ .../fixtures/atlas_agent_smoke_jobs.json | 6 + client/tests/test_atlas_agent_driver.gd | 82 +++++ client/tests/test_atlas_agent_interface.gd | 336 ++++++++++++++++++ client/tests/test_step_canvas_viewer.gd | 113 ++++++ .../apps/atlas/atlas_agent_interface.gd | 247 +++++++++++++ client/ui/implant/apps/atlas/atlas_app.gd | 6 + .../apps/atlas/screens/reach_screen.gd | 32 +- .../apps/atlas/screens/regional_screen.gd | 10 + .../apps/atlas/screens/system_screen.gd | 52 ++- .../atlas/step_canvas/step_canvas_viewer.gd | 119 +++++++ client/ui/implant/implant_app.gd | 10 + 14 files changed, 1185 insertions(+), 22 deletions(-) create mode 100644 client/scripts/autoloads/atlas_agent_bridge.gd create mode 100644 client/tests/atlas_agent_driver.gd create mode 100644 client/tests/fixtures/atlas_agent_smoke_jobs.json create mode 100644 client/tests/test_atlas_agent_driver.gd create mode 100644 client/tests/test_atlas_agent_interface.gd create mode 100644 client/ui/implant/apps/atlas/atlas_agent_interface.gd diff --git a/client/project.godot b/client/project.godot index 90529a0b8..20704144c 100644 --- a/client/project.godot +++ b/client/project.godot @@ -32,6 +32,7 @@ HudGroups="*res://scripts/autoloads/hud_groups.gd" MetaStack="*res://ui/meta/meta_stack.gd" ImplantRegistry="*res://ui/implant/implant_registry.gd" HardwareDetector="*res://ui/hardware_detector.gd" +AtlasAgentBridge="*res://scripts/autoloads/atlas_agent_bridge.gd" [audio] diff --git a/client/scripts/autoloads/atlas_agent_bridge.gd b/client/scripts/autoloads/atlas_agent_bridge.gd new file mode 100644 index 000000000..a087d9485 --- /dev/null +++ b/client/scripts/autoloads/atlas_agent_bridge.gd @@ -0,0 +1,29 @@ +extends Node + +## AtlasAgentBridge (T-971, D-226 item 4) — the ONE stable, always-reachable +## handle onto the live AtlasApp instance, so an in-process agent driver (a +## `-s` SceneTree script, a gdUnit suite, the T-1157 capture harness) has a +## fixed autoload address to start from instead of walking the scene tree +## itself. +## +## Deliberately a DUMB holder, per CLAUDE.md's autoload parse-order rule: an +## autoload script compiles before global `class_name` scripts are +## registered, so referencing a `class_name` type (AtlasApp, AtlasAgent +## Interface) at this file's top level or in _ready() would fail to +## resolve. This file therefore declares its field UNTYPED (`var +## current_app = null`) and does nothing else — every actual observe()/act() +## call is a STATIC method on AtlasAgentInterface +## (res://ui/implant/apps/atlas/atlas_agent_interface.gd), loaded via +## `load()` inline at call time by whatever script needs it (the same +## deferred-load pattern game_state.gd/sim_bridge.gd already use for their +## own class_name-typed fields). This autoload carries no logic of its own +## to keep that boundary clean — "dumb holder delegating to a static class", +## per the ruling. +## +## `current_app` is set by AtlasApp.on_install() (the same lifecycle point +## ImplantApp guarantees runs once, before any HudGroups open event) and +## cleared by nothing — an installed app instance lives for the process +## lifetime, matching every other ImplantApp subclass's own singleton-per- +## process posture (HudGroups.register() itself assumes one instance per +## app_path). +var current_app = null # AtlasApp, set by atlas_app.gd's on_install() diff --git a/client/tests/atlas_agent_driver.gd b/client/tests/atlas_agent_driver.gd new file mode 100644 index 000000000..386bbf71e --- /dev/null +++ b/client/tests/atlas_agent_driver.gd @@ -0,0 +1,164 @@ +## Reference in-process Atlas agent driver (T-971, D-226 item 4) — the +## SANCTIONED way to drive the Atlas headlessly via AtlasAgentInterface, +## replacing the five hand-rolled scratch eyeball drivers the T-1183/T-1157 +## harness rounds produced ad hoc. Every real navigation call below goes +## through AtlasAgentInterface.act()/observe() — this file does not touch +## `_gui_input`, does not synthesize pixel events, and does not reach into +## any screen's private fields. +## +## Folds in two of the T-1157 harness-techniques inventory patterns +## (pql ticket show T-1157): +## 1. InputSwallower — a root-level Node, added first, low process_priority, +## whose _input()/_unhandled_input() both call +## get_viewport().set_input_as_handled() unconditionally. Neutralizes +## real desktop input reaching this `-s` SceneTree driver's window +## (X11/XWayland can deliver pointer events to an unfocused window under +## the cursor on a shared desktop) — this driver runs real production +## code via method calls, but the WINDOW still exists and can still +## receive stray input, so the guard applies here too. +## 4. Full-run-restart-on-anomaly (documented, not code-enforced — see +## _run() header note below): any assertion failure here should discard +## the whole run rather than retrying mid-sequence, matching the +## inventory's "partial retries silently corrupt the state the +## comparison depends on" finding. This driver's own job list is short +## and idempotent per invocation (fresh process each run), so the +## discipline is "don't loop-retry a failed step in place" rather than a +## literal restart mechanism. +## +## Technique 5 (fixed-center revisit) is what `jump_to_center` (this file's +## own `_run_job("jump_to_center", ...)` dispatch) exists to exercise — +## AtlasAgentInterface's production seam for it, not reimplemented here. +## +## Usage (JSON job list on stdin path, matching visual_capture.gd's own +## `-- --flag value` CLI convention): +## godot --rendering-driver opengl3 --path client \ +## -s res://tests/atlas_agent_driver.gd -- \ +## --jobs res://tests/fixtures/atlas_agent_smoke_jobs.json \ +## --output /abs/path/observe_log.json +## +## Each job is {"intent": "...", "params": {...}}; "observe" is a +## pseudo-intent this driver special-cases to call +## AtlasAgentInterface.observe() instead of act() (observe takes no params). +## The full sequence of act()/observe() results is written to --output as a +## JSON array — the smoke test (test_atlas_agent_driver_smoke.gd) reads this +## back to assert the reference driver actually completes a real session. +extends SceneTree + +var _jobs_path: String = "" +var _output_path: String = "" +var _results: Array = [] + + +func _init(): + _run.call_deferred() + + +func _run() -> void: + _parse_args() + + if _jobs_path.is_empty(): + push_error("atlas_agent_driver: --jobs PATH is required") + quit(1) + return + if _output_path.is_empty(): + push_error("atlas_agent_driver: --output PATH is required") + quit(1) + return + + var jobs: Array = _load_jobs(_jobs_path) + if jobs.is_empty(): + push_error("atlas_agent_driver: no jobs loaded from %s" % _jobs_path) + quit(1) + return + + var main_scene = load("res://scenes/main.tscn") + var main_node = main_scene.instantiate() + + # Technique 1 (T-1157 inventory) — InputSwallower, added FIRST so its low + # process_priority still runs before anything that might otherwise react + # to stray desktop input reaching this unfocused/off-screen window. + var swallower := _InputSwallower.new() + swallower.process_priority = -1000 + root.add_child(swallower) + + root.add_child(main_node) + + # Settle frames — camera smoothing, fog uniform init, UI layout (same + # rationale as visual_capture.gd's own settle-frame pass). + for i in range(10): + await process_frame + + var atlas_agent_interface := load("res://ui/implant/apps/atlas/atlas_agent_interface.gd") + var atlas_agent_bridge: Node = root.get_node("/root/AtlasAgentBridge") + + for job: Dictionary in jobs: + var intent: String = str(job.get("intent", "")) + var params: Dictionary = job.get("params", {}) + var result: Dictionary + if intent == "observe": + result = atlas_agent_interface.observe(atlas_agent_bridge.current_app) + else: + result = atlas_agent_interface.act(atlas_agent_bridge.current_app, intent, params) + # Settle-until-ready (T-1157 inventory item 2's underlying discipline, + # applied generically here rather than per-navigation-intent): a few + # frames after every mutating call so the resulting screen/viewer + # state (panel rebuilds, queue_redraw()) has actually applied before + # the NEXT job's observe() reads it. + for i in range(4): + await process_frame + _results.append({"intent": intent, "params": params, "result": result}) + + _write_output(_output_path, _results) + quit() + + +func _parse_args() -> void: + var args := OS.get_cmdline_user_args() + var i := 0 + while i < args.size(): + match args[i]: + "--jobs": + i += 1 + if i < args.size(): + _jobs_path = args[i] + "--output": + i += 1 + if i < args.size(): + _output_path = args[i] + i += 1 + + +static func _load_jobs(path: String) -> Array: + var abs_path: String = path + if path.begins_with("res://"): + abs_path = ProjectSettings.globalize_path(path) + var f := FileAccess.open(abs_path, FileAccess.READ) + if f == null: + return [] + var text := f.get_as_text() + f.close() + var parsed: Variant = JSON.parse_string(text) + return parsed if parsed is Array else [] + + +static func _write_output(path: String, results: Array) -> void: + var f := FileAccess.open(path, FileAccess.WRITE) + if f == null: + push_error("atlas_agent_driver: cannot open output path %s" % path) + return + f.store_string(JSON.stringify(results, " ")) + f.close() + + +## T-1157 inventory item 1 — see this file's own header doc for the full +## rationale. A tiny inner class rather than a separate file: this pattern +## has exactly one consumer (this driver) today: promote to a shared +## `client/tests/` helper the moment a second `-s` driver needs it. +class _InputSwallower: + extends Node + + func _input(_event: InputEvent) -> void: + get_viewport().set_input_as_handled() + + func _unhandled_input(_event: InputEvent) -> void: + get_viewport().set_input_as_handled() diff --git a/client/tests/fixtures/atlas_agent_smoke_jobs.json b/client/tests/fixtures/atlas_agent_smoke_jobs.json new file mode 100644 index 000000000..c735e86ef --- /dev/null +++ b/client/tests/fixtures/atlas_agent_smoke_jobs.json @@ -0,0 +1,6 @@ +[ + {"intent": "open_atlas", "params": {}}, + {"intent": "observe", "params": {}}, + {"intent": "close_atlas", "params": {}}, + {"intent": "observe", "params": {}} +] diff --git a/client/tests/test_atlas_agent_driver.gd b/client/tests/test_atlas_agent_driver.gd new file mode 100644 index 000000000..caca7b9a3 --- /dev/null +++ b/client/tests/test_atlas_agent_driver.gd @@ -0,0 +1,82 @@ +## T-971 smoke test for atlas_agent_driver.gd — the reference `-s` SceneTree +## driver for AtlasAgentInterface. A gdUnit suite cannot boot a SECOND full +## Godot process (the driver's real usage mode: `godot -s +## res://tests/atlas_agent_driver.gd -- --jobs ... --output ...`) without +## spawning a nested engine instance, which is slow/fragile and inconsistent +## with how this project already runs its OTHER `-s` drivers +## (visual_capture.gd/atlas_shots.json — manual `make` targets, never inside +## the gdUnit suite tests/run-godot invokes). The feasible headless smoke +## here is the driver's own PURE helper logic: job-file loading and +## result-array writing, both `static func`s with no SceneTree dependency — +## exercising the exact code path a real run's job-list parsing and +## output-writing go through, without booting a second engine. +class_name TestAtlasAgentDriver +extends GdUnitTestSuite + +const DriverScript := preload("res://tests/atlas_agent_driver.gd") + +var _tmp_dir: String = "" + + +func before_test() -> void: + _tmp_dir = "user://test_atlas_agent_driver/%d/" % Time.get_ticks_usec() + DirAccess.make_dir_recursive_absolute(_tmp_dir) + + +func after_test() -> void: + var d := DirAccess.open(_tmp_dir) + if d == null: + return + for f: String in d.get_files(): + d.remove(f) + DirAccess.remove_absolute(_tmp_dir.rstrip("/")) + + +func test_load_jobs_reads_the_shipped_smoke_fixture() -> void: + var jobs: Array = DriverScript._load_jobs( + "res://tests/fixtures/atlas_agent_smoke_jobs.json" + ) + assert_int(jobs.size()).is_equal(4) + assert_str((jobs[0] as Dictionary).get("intent", "")).is_equal("open_atlas") + assert_str((jobs[1] as Dictionary).get("intent", "")).is_equal("observe") + + +func test_load_jobs_returns_empty_array_for_a_missing_file() -> void: + var jobs: Array = DriverScript._load_jobs("res://tests/fixtures/does_not_exist.json") + assert_int(jobs.size()).is_equal(0) + + +func test_write_output_round_trips_through_json() -> void: + var out_path: String = _tmp_dir + "results.json" + var results: Array = [ + {"intent": "observe", "params": {}, "result": {"screen": "reach"}}, + {"intent": "select_system", "params": {"system_id": "GJ1"}, "result": {"ok": true}}, + ] + + DriverScript._write_output(out_path, results) + + var f := FileAccess.open(out_path, FileAccess.READ) + assert_object(f).is_not_null() + var parsed: Variant = JSON.parse_string(f.get_as_text()) + f.close() + assert_that(parsed).is_equal(results) + + +## The InputSwallower inner class (T-1157 inventory item 1) — a plain Node +## subclass with no SceneTree/window dependency for its OWN logic +## (set_input_as_handled() requires a live viewport to call meaningfully, but +## the class must at least instantiate and expose the two input hooks by +## name — this is the structural smoke check; the swallowing BEHAVIOR itself +## is exercised implicitly every time atlas_agent_driver.gd actually runs, +## per its own header doc). +func test_input_swallower_inner_class_instantiates_and_has_both_input_hooks() -> void: + # A nested `class X: extends Y` inside a script is exposed as a + # script-level constant on the OUTER GDScript resource — reachable via + # plain dot access on the preloaded DriverScript, same as any other + # nested-class reference in this codebase's own suites (e.g. + # GdUnitObjectAssertImplTest.gd's MyNode/MyExtendedNode pattern), just + # via a loaded resource instead of a same-file bare identifier. + var instance: Node = DriverScript._InputSwallower.new() + assert_bool(instance.has_method("_input")).is_true() + assert_bool(instance.has_method("_unhandled_input")).is_true() + instance.free() diff --git a/client/tests/test_atlas_agent_interface.gd b/client/tests/test_atlas_agent_interface.gd new file mode 100644 index 000000000..22d56a163 --- /dev/null +++ b/client/tests/test_atlas_agent_interface.gd @@ -0,0 +1,336 @@ +## T-971 tests: AtlasAgentInterface — the observe/act named-intent channel. +## Every act() test asserts the intent reaches the SAME production state +## mutation the real click handler performs (spy at the handler seam: read +## the resulting screen/viewer state back, exactly as a real click's caller +## would observe it), never a synthesized _gui_input event. test_mode +## (SimBridge default outside SR_LIVE=1) means request_step_canvas() is a +## silent no-op — these tests exercise client-side state only, no live +## server needed, matching test_step_canvas_viewer.gd's own precedent. +class_name TestAtlasAgentInterface +extends GdUnitTestSuite + +const AtlasAgentInterfaceScript := preload("res://ui/implant/apps/atlas/atlas_agent_interface.gd") +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") + +## AtlasApp._ready() unconditionally reloads the real app.tres manifest +## (`manifest = load("res://ui/implant/apps/atlas/app.tres")`), overwriting +## anything set before add_child() — so, unlike the generic ImplantApp +## lifecycle tests (which use a bare, manifest-injectable ImplantApp), a real +## AtlasApp instance ALWAYS registers under the production app_path. Tests +## drive HudGroups with THIS path, not a synthetic test-only one. +const TEST_APP_PATH := "implant/map" + +const FIXTURE_SYSTEM := { + "system_id": "GJ1", + "proper_name": "Gliese J1", + "hop_distance": 0, + "orbit_bodies": [ + { + "body_id": "GJ1b", "proper_name": "Barrenholt", "body_type": "planet", + "orbit_index": 0, "terrain_reference": "GJ1b_heightmap", + }, + { + "body_id": "GJ1c", "proper_name": "Cindral", "body_type": "planet", + "orbit_index": 1, "terrain_reference": null, + }, + ], + "stations": [], +} + + +## Returns an AtlasApp instance (untyped — matches _make_app()'s own +## precedent in test_implant_app_lifecycle.gd, which keeps the return +## untyped there too for the same class_name parse-order reason). No manifest +## injection needed (unlike the generic ImplantApp lifecycle tests) — +## AtlasApp._ready() always loads the real production manifest itself. +func _make_app(): + var AppClass := load("res://ui/implant/apps/atlas/atlas_app.gd") + var app = AppClass.new() + add_child(app) # fires _ready() -> on_install() + return app + + +func after_test() -> void: + HudGroups._active_app = "" + HudGroups._active_mode = HudGroups.Mode.GAMEPLAY + HudGroups._groups.erase(TEST_APP_PATH) + HudGroups._groups.erase("implant/map") + + +# ============================================================================= +# observe() +# ============================================================================= + + +func test_observe_reports_current_screen_id() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + var result: Dictionary = AtlasAgentInterfaceScript.observe(app) + assert_str(result.get("screen", "")).is_equal("reach") + app.queue_free() + + +func test_observe_returns_error_for_null_app() -> void: + var result: Dictionary = AtlasAgentInterfaceScript.observe(null) + assert_bool(result.has("error")).is_true() + + +func test_observe_affordance_tree_includes_overlay_bar_buttons_on_regional() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + app.nav.push("regional", {"body": {"body_id": "GJ1b"}, "system": FIXTURE_SYSTEM}) + + var result: Dictionary = AtlasAgentInterfaceScript.observe(app) + var affordances: Array = result.get("affordances", []) + assert_int(affordances.size()).is_greater(0) + app.queue_free() + + +func test_observe_is_side_effect_free() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + var before: String = app.current_screen_id() + AtlasAgentInterfaceScript.observe(app) + assert_str(app.current_screen_id()).is_equal(before) + app.queue_free() + + +# ============================================================================= +# act() — reach screen intents +# ============================================================================= + + +func test_select_system_reaches_reach_screen_selection_state() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + app.get_screen("reach").set_systems([FIXTURE_SYSTEM], {"GJ1": FIXTURE_SYSTEM}) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "select_system", {"system_id": "GJ1"}) + + assert_bool(result.get("ok", false)).is_true() + assert_bool(app.get_screen("reach").has_selection()).is_true() + app.queue_free() + + +func test_open_system_reaches_atlas_app_nav_push() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + app.get_screen("reach").set_systems([FIXTURE_SYSTEM], {"GJ1": FIXTURE_SYSTEM}) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "open_system", {"system_id": "GJ1"}) + + assert_bool(result.get("ok", false)).is_true() + assert_str(app.current_screen_id()).is_equal("system") + app.queue_free() + + +# ============================================================================= +# act() — system screen intents +# ============================================================================= + + +func _enter_orbital(app) -> void: + app.get_screen("system").set_systems([FIXTURE_SYSTEM]) + app.nav.push("system", {"mode": "orbital", "system": FIXTURE_SYSTEM}) + + +func test_select_body_reaches_system_screen_body_panel_state() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "select_body", {"body_id": "GJ1c"}) + + assert_bool(result.get("ok", false)).is_true() + assert_bool(app.get_screen("system").has_body_panel_open()).is_true() + app.queue_free() + + +func test_open_body_reaches_atlas_app_nav_push_to_regional() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "GJ1b"}) + + assert_bool(result.get("ok", false)).is_true() + assert_str(app.current_screen_id()).is_equal("regional") + app.queue_free() + + +func test_open_body_is_a_no_op_for_unrecognized_id() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "does_not_exist"}) + + assert_str(app.current_screen_id()).is_equal("system") + app.queue_free() + + +# ============================================================================= +# act() — regional/step-canvas intents +# ============================================================================= + + +func _enter_regional(app) -> void: + app.nav.push("regional", {"body": {"body_id": "GJ1b", "body_radius_km": 6371.0}, "system": FIXTURE_SYSTEM}) + + +func test_scroll_rung_reaches_step_canvas_viewer_rung_transport() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "scroll_rung", {"direction": 1}) + + assert_bool(result.get("ok", false)).is_true() + assert_str(result.get("rung", "")).is_equal(StepCanvasTransport.RUNG_REGION) + app.queue_free() + + +func test_reset_view_reaches_step_canvas_viewer_reset() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app) + AtlasAgentInterfaceScript.act(app, "scroll_rung", {"direction": 1}) + AtlasAgentInterfaceScript.act(app, "scroll_rung", {"direction": 1}) + + AtlasAgentInterfaceScript.act(app, "reset_view", {}) + + var viewer: Variant = app.get_screen("regional").get_viewer() + assert_str(viewer.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL) + app.queue_free() + + +func test_set_overlay_reaches_step_canvas_viewer_overlay_state() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app) + + AtlasAgentInterfaceScript.act( + app, "set_overlay", {"overlay_id": "gen_dw_temp", "visible": true} + ) + + var viewer: Variant = app.get_screen("regional").get_viewer() + assert_bool(viewer.is_overlay_visible("gen_dw_temp")).is_true() + app.queue_free() + + +func test_back_reaches_nav_pop() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + AtlasAgentInterfaceScript.act(app, "back", {}) + + assert_str(app.current_screen_id()).is_equal("reach") + app.queue_free() + + +# ============================================================================= +# jump_to_center — key-discipline test (T-971 highest-value intent) +# ============================================================================= + + +## The core guarantee this intent exists for: jumping to a center and +## scrolling to the SAME center must produce the SAME request key (same +## body/rung/center/extent) — jump_to() must go through the identical +## _fire_request()/_request_extent() path, never a parallel one. Verified via +## the request object's own held state (StepCanvasRequest's private _center/ +## _rung fields aren't public, so this compares the PUBLIC echo surface: +## held_rung + world_center, which is exactly what a cache-key comparison +## downstream would use). +func test_jump_to_center_produces_the_same_request_key_as_an_equivalent_scroll() -> void: + var app_a = _make_app() + app_a._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app_a) + var viewer_a: Variant = app_a.get_screen("regional").get_viewer() + viewer_a._scroll_rung(1, Vector2(400.0, 300.0)) + var scrolled_center: Vector2 = viewer_a.get_world_center() + var scrolled_rung: String = viewer_a.get_held_rung() + app_a.queue_free() + + var app_b = _make_app() + app_b._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app_b) + + var result: Dictionary = AtlasAgentInterfaceScript.act( + app_b, + "jump_to_center", + {"world_center": [scrolled_center.x, scrolled_center.y], "rung": scrolled_rung} + ) + + assert_bool(result.get("ok", false)).is_true() + var viewer_b: Variant = app_b.get_screen("regional").get_viewer() + assert_str(viewer_b.get_held_rung()).is_equal(scrolled_rung) + assert_that(viewer_b.get_world_center()).is_equal(scrolled_center) + app_b.queue_free() + + +func test_jump_to_center_requires_world_center_param() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "jump_to_center", {}) + + assert_bool(result.get("ok", false)).is_false() + app.queue_free() + + +func test_jump_to_center_keeps_current_rung_when_rung_param_omitted() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app) + var viewer: Variant = app.get_screen("regional").get_viewer() + viewer._scroll_rung(1, Vector2(400.0, 300.0)) # now at Region + + AtlasAgentInterfaceScript.act(app, "jump_to_center", {"world_center": [1000.0, 2000.0]}) + + assert_str(viewer.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION) + app.queue_free() + + +# ============================================================================= +# open_atlas / close_atlas +# ============================================================================= + + +func test_open_atlas_reaches_hud_groups_open_app() -> void: + var app = _make_app() + # AtlasApp._ready() already registers itself under "implant/map" via + # HudGroups.register() (see ImplantApp._ready()) — no manual registration + # needed here, unlike a bare ImplantApp fixture. + + AtlasAgentInterfaceScript.act(app, "open_atlas", {}) + + assert_bool(HudGroups.is_app_active("implant/map")).is_true() + app.queue_free() + + +func test_close_atlas_reaches_hud_groups_close_app() -> void: + var app = _make_app() + HudGroups.open_app("implant/map") + + AtlasAgentInterfaceScript.act(app, "close_atlas", {}) + + assert_bool(HudGroups.is_app_active("implant/map")).is_false() + app.queue_free() + + +# ============================================================================= +# Unknown intent +# ============================================================================= + + +func test_unknown_intent_returns_ok_false_with_error() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "not_a_real_intent", {}) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + app.queue_free() diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index 6d06d6b5f..4b94338b9 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -706,3 +706,116 @@ func test_global_canvas_scale_never_drops_below_native_on_a_narrow_viewport() -> "the effective pixels-per-gridunit ratio must floor at 1 (native)," + " never a sub-1x downscale, even on a viewport this narrow" ).is_equal_approx(1.0, 0.001) + + +# ============================================================================= +# T-971 (AtlasAgentInterface): jump_to() — the fixed-center revisit seam — +# and get_current_canvas_summary(). +# ============================================================================= + + +## jump_to() must set the SAME held rung/world_center a cursor-anchored +## scroll to that same spot would land on — this is the "same cache key" +## guarantee AtlasAgentInterface's jump_to_center intent depends on +## (verified end-to-end, through act(), in test_atlas_agent_interface.gd; +## this is the narrower unit-level check directly against the viewer). +func test_jump_to_sets_held_rung_and_world_center() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + + v.jump_to(Vector2(500.0, -250.0), StepCanvasTransport.RUNG_QUARTER) + + assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_QUARTER) + assert_that(v.get_world_center()).is_equal(Vector2(500.0, -250.0)) + + +## Omitting `rung` keeps whatever rung is currently held — the "revisit +## within the same rung" common case shouldn't require repeating it. +func test_jump_to_keeps_current_rung_when_omitted() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + v._scroll_rung(1, Vector2(400.0, 300.0)) # Region + + v.jump_to(Vector2(10.0, 20.0)) + + assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION) + + +## Jumping to Global always forces world_center to ZERO — Global has no +## panned-center concept (mirrors _scroll_rung()'s own Global-rung handling). +func test_jump_to_global_forces_world_center_to_zero() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + v._scroll_rung(1, Vector2(400.0, 300.0)) # Region + + v.jump_to(Vector2(777.0, 888.0), StepCanvasTransport.RUNG_GLOBAL) + + assert_that(v.get_world_center()).is_equal(Vector2.ZERO) + + +func test_jump_to_unrecognized_rung_is_a_no_op() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + var rung_before: String = v.get_held_rung() + var center_before: Vector2 = v.get_world_center() + + v.jump_to(Vector2(1.0, 2.0), "NotARealRung") + + assert_str(v.get_held_rung()).is_equal(rung_before) + assert_that(v.get_world_center()).is_equal(center_before) + + +func test_get_current_canvas_summary_before_any_canvas_arrives() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + + var summary: Dictionary = v.get_current_canvas_summary() + + assert_bool(summary.get("has_canvas", true)).is_false() + assert_str(summary.get("rung", "")).is_equal(StepCanvasTransport.RUNG_GLOBAL) + + +## The T-1157-inventory-relevant correctness check: course/cliff/settlement +## counts must match a fixture canvas exactly, including the per-class +## course histogram and settlement id dedup (mirrors +## StepCanvasAnnotationLayer._draw_settlements()'s own dedup discipline — +## covering the same cell id twice must not double-count). +func test_get_current_canvas_summary_counts_match_a_fixture_canvas() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + v._scroll_rung(1, Vector2(400.0, 300.0)) # Region + + var canvas: Dictionary = _synthetic_canvas(4, 4) + canvas["courses"] = [ + {"class": 0, "points": [[0.0, 0.0], [1.0, 1.0]]}, + {"class": 0, "points": [[2.0, 2.0], [3.0, 3.0]]}, + {"class": 2, "points": [[4.0, 4.0], [5.0, 5.0]]}, + ] + canvas["cliffs"] = [{"a": 1}, {"b": 2}] + # 4x4 grid; ids 5 and 5 (repeat, same settlement footprint) dedup to one, + # id 9 is a second distinct settlement, 0 is "no settlement" and ignored. + canvas["settlement_id"] = [ + 0, 5, 5, 0, + 0, 0, 0, 0, + 9, 0, 0, 0, + 0, 0, 0, 0, + ] + v._on_canvas_ready(canvas) + + var summary: Dictionary = v.get_current_canvas_summary() + + assert_bool(summary.get("has_canvas", false)).is_true() + assert_int(summary.get("canvas_width", 0)).is_equal(4) + assert_int(summary.get("canvas_height", 0)).is_equal(4) + assert_int(summary.get("course_count", 0)).is_equal(3) + assert_int(summary.get("cliff_count", 0)).is_equal(2) + assert_int(summary.get("settlement_count", 0)).is_equal(2) + var by_class: Dictionary = summary.get("course_count_by_class", {}) + assert_int(int(by_class.get(0, 0))).is_equal(2) + assert_int(int(by_class.get(2, 0))).is_equal(1) diff --git a/client/ui/implant/apps/atlas/atlas_agent_interface.gd b/client/ui/implant/apps/atlas/atlas_agent_interface.gd new file mode 100644 index 000000000..24f3f2f11 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_agent_interface.gd @@ -0,0 +1,247 @@ +class_name AtlasAgentInterface +extends RefCounted + +## Atlas agent control channel (T-971, D-226 item 4) — observe/act named- +## intent navigation over the REAL Atlas UI. Turns the five hand-rolled +## T-1183 eyeball-review scratch drivers into one production, testable +## contract: `observe()` returns a JSON-safe Dictionary of (a) the current +## data state and (b) a walkable UI affordance tree; `act(intent, params)` +## dispatches to a named semantic intent, each backed by the EXACT method a +## real click/keypress calls in production — never a synthesized +## `_gui_input`/pixel event. Every method here is `static` (no instance +## state) taking an `AtlasApp` reference explicitly — see +## atlas_agent_bridge.gd's own doc for why the stable entry point is a dumb +## autoload holding that reference rather than this class itself being an +## autoload (the CLAUDE.md autoload parse-order rule: this file has a +## `class_name`, so an autoload referencing it directly at top level/`_ready()` +## would fail to resolve before this script is registered). +## +## **Reconciled against the current stepped-rung API (T-971 Phase 1, this +## ticket's own confirmed re-scope) — NOT the original D-226 list, which was +## written against the retired continuous-zoom AtlasWindowViewer:** +## +## - **`select_city`/`open_regional` DROPPED.** Post-D-255, settlements are +## `settlement_id` cell values inside a fetched step-canvas array +## (StepCanvasAnnotationLayer._draw_settlements()), not a separate +## clickable list — there is no server- or client-side "select settlement +## X" affordance to wire this intent to today. Faking one (e.g. picking the +## nearest cell to a guessed screen position) would be worse than omitting +## it: it would exercise a code path no real click can reach. A +## settlement-hit-test intent is a new ticket, filed only once a real +## consumer needs it — not speculatively here. +## - **`open_atlas`/`close_atlas` cover opening the app itself** — D-226's +## original list implicitly assumed the Atlas was already open; an +## in-process driver needs to open it too (HudGroups.open_app/close_app). +## - **`jump_to_center` is NEW** — the T-1183 eyeball-driver "fixed-center +## revisit" pattern (record a prior run's actual derived world center, +## re-request it literally rather than repeating a cursor gesture that +## would re-derive a merely-similar one) made a first-class intent, backed +## by the new StepCanvasViewer.jump_to() production seam. This is the +## highest-value intent in the set for T-1157's future capture harness. +## - **`get_layer_data_summary`'s actual post-D-255 shape** is +## StepCanvasViewer.get_current_canvas_summary()'s own return — +## rung/world_center/held_extent/canvas dimensions/course/cliff/settlement +## counts. D-226's original "attractor/river/basin counts" phrasing is +## itself stale (attractors don't exist post-D-255; "basin" was never a +## step-canvas field) — courses/cliffs/settlements are what the wire +## actually carries. +## +## **TRANSPORT SCOPE (this ticket): in-process consumers only.** Every real +## consumer today — gdUnit suites, a `-s` SceneTree headless driver, the +## T-1157 capture harness — calls these static methods directly, in the same +## process as the running AtlasApp. There is no network/stdio listener here; +## D-226's original "terminal/curl" framing is deferred to a follow-up ticket +## when a genuinely remote consumer exists. This keeps the surface small and +## delivers the QA value (turning eyeball review into a scripted sweep) +## immediately. See client/tests/atlas_agent_driver.gd for the sanctioned +## reference `-s` driver this ticket ships alongside the interface itself. +## +## **observe() is side-effect-free** — no request firing, no state mutation, +## purely reads already-held view/screen state (mirrors get_layer_data_summary's +## own "without rendering" requirement one level up: observing never triggers +## a fetch). + + +# ============================================================================= +# observe() +# ============================================================================= + + +## Full observation snapshot: current screen id + its own data state, the +## walkable affordance tree (every reachable toggle/button under the current +## screen), and — when "regional" is current — the step-canvas layer data +## summary. `app` is the live AtlasApp (typically +## AtlasAgentBridge.current_app, loaded by the caller via `load()` per the +## autoload parse-order rule). +static func observe(app: Node) -> Dictionary: + if app == null: + return {"error": "no AtlasApp instance available"} + var screen_id: String = app.current_screen_id() + return { + "screen": screen_id, + "data": _observe_data_state(app, screen_id), + "affordances": _walk_affordances(app), + } + + +## The "current data state shown" half of observe() — per-screen, since each +## screen's own state shape differs (D-226's own split: "current data state +## shown" vs "the affordance tree" are two distinct halves of one observe() +## call, not folded together). +static func _observe_data_state(app: Node, screen_id: String) -> Dictionary: + match screen_id: + "reach": + return {"has_selection": app.get_screen("reach").has_selection()} + "system": + var system_screen: Node = app.get_screen("system") + return { + "in_orbital": system_screen.is_in_orbital(), + "current_system": system_screen.current_system(), + "has_body_panel_open": system_screen.has_body_panel_open(), + "has_station_panel_open": system_screen.has_station_panel_open(), + } + "regional": + var viewer: Variant = _get_viewer(app) + if viewer == null: + return {} + return viewer.get_current_canvas_summary() + _: + return {} # gdlint:ignore = max-returns + + +## Generic Control-tree walk (D-226's own requirement: "so economics/saves +## plug in later" — never a per-screen hand-written affordance list). Walks +## the CURRENT screen's own Control subtree (not the whole app — an +## inactive screen's controls aren't real affordances right now) collecting +## every Button/toggle-capable node: id (node name), label (text), state +## (pressed/disabled), locked (disabled). Buttons with no distinguishing text +## still get an entry (id falls back to the node's own scene path) so the +## tree is never silently incomplete. +static func _walk_affordances(app: Node) -> Array: + var screen: Node = app.get_screen(app.current_screen_id()) + if screen == null: + return [] + var out: Array = [] + _walk_affordances_recursive(screen, out) + return out + + +static func _walk_affordances_recursive(node: Node, out: Array) -> void: + if node is Button: + var b: Button = node + out.append({ + "id": String(b.name), + "label": b.text, + "pressed": b.button_pressed, + "locked": b.disabled, + }) + for child in node.get_children(): + _walk_affordances_recursive(child, out) + + +# ============================================================================= +# act() +# ============================================================================= + + +## Dispatch a named semantic intent. `params` is a JSON-safe Dictionary; +## returns a JSON-safe Dictionary result (at minimum {"ok": bool}, plus +## intent-specific fields). Unknown intents return {"ok": false, +## "error": "..."} rather than pushing an error/crashing — this channel is +## meant to be driven by a fallible external caller (a curl-style JSON body, +## a test fixture), and a malformed intent name is exactly the kind of input +## it must handle gracefully. +static func act(app: Node, intent: String, params: Dictionary = {}) -> Dictionary: + if app == null and intent != "open_atlas": + return {"ok": false, "error": "no AtlasApp instance available"} + match intent: + "open_atlas": + HudGroups.open_app("implant/map") + return {"ok": true} + "close_atlas": + HudGroups.close_app() + return {"ok": true} + "select_system": + app.get_screen("reach").select_system_by_id(str(params.get("system_id", ""))) + return {"ok": true} + "open_system": + app.get_screen("reach").open_system_by_id(str(params.get("system_id", ""))) + return {"ok": true} + "select_body": + app.get_screen("system").select_body_by_id(str(params.get("body_id", ""))) + return {"ok": true} + "open_body": + app.get_screen("system").open_body_by_id(str(params.get("body_id", ""))) + return {"ok": true} + "scroll_rung": + return _act_scroll_rung(app, params) + "jump_to_center": + return _act_jump_to_center(app, params) + "reset_view": + var viewer: Variant = _get_viewer(app) + if viewer == null: + return {"ok": false, "error": "regional screen not active"} + viewer._reset_to_global() + return {"ok": true} + "set_overlay": + var overlay_viewer: Variant = _get_viewer(app) + if overlay_viewer == null: + return {"ok": false, "error": "regional screen not active"} + overlay_viewer.set_overlay_visible( + str(params.get("overlay_id", "")), bool(params.get("visible", true)) + ) + return {"ok": true} + "back": + app.nav.pop() + return {"ok": true} + _: + return {"ok": false, "error": "unknown intent '%s'" % intent} # gdlint:ignore = max-returns + + +## `scroll_rung` — direction is required; cursor_local defaults to a +## reasonable canvas-center guess (Vector2(400, 300), matching this cluster's +## own gdUnit test fixtures' convention, see test_step_canvas_viewer.gd) since +## an agent driver has no real cursor position to anchor on. +static func _act_scroll_rung(app: Node, params: Dictionary) -> Dictionary: + var viewer: Variant = _get_viewer(app) + if viewer == null: + return {"ok": false, "error": "regional screen not active"} + var direction: int = int(params.get("direction", 1)) + var cursor_raw: Variant = params.get("cursor_local") + var cursor_local: Vector2 = ( + Vector2(cursor_raw[0], cursor_raw[1]) + if cursor_raw is Array and (cursor_raw as Array).size() >= 2 + else Vector2(400.0, 300.0) + ) + viewer._scroll_rung(direction, cursor_local) + return {"ok": true, "rung": viewer.get_held_rung()} + + +## `jump_to_center` — the fixed-center revisit intent (T-1183 pattern, made +## first-class). `world_center` is required (`[x, y]` world metres); +## `rung` is optional (keeps the currently-held rung when omitted, matching +## StepCanvasViewer.jump_to()'s own default). Goes through jump_to(), which +## itself falls through to the SAME _fire_request()/_request_extent() path +## every other navigation intent uses — no parallel request-building code +## exists in this file. +static func _act_jump_to_center(app: Node, params: Dictionary) -> Dictionary: + var viewer: Variant = _get_viewer(app) + if viewer == null: + return {"ok": false, "error": "regional screen not active"} + var center_raw: Variant = params.get("world_center") + if not (center_raw is Array and (center_raw as Array).size() >= 2): + return {"ok": false, "error": "jump_to_center requires world_center: [x, y]"} + var world_center := Vector2(float(center_raw[0]), float(center_raw[1])) + var rung: String = str(params.get("rung", "")) + viewer.jump_to(world_center, rung) + return {"ok": true, "rung": viewer.get_held_rung(), "world_center": [world_center.x, world_center.y]} + + +## Shared "regional" screen -> StepCanvasViewer accessor — returns null if +## "regional" isn't the registered screen (defensive; every intent that needs +## the viewer checks this rather than assuming the screen tree shape). +static func _get_viewer(app: Node) -> Variant: + var regional_screen: Node = app.get_screen("regional") + if regional_screen == null: + return null + return regional_screen.get_viewer() diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 72abab2a3..8942f2f98 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -33,6 +33,12 @@ func _ready() -> void: func on_install() -> void: + # T-971: publish this instance to the dumb AtlasAgentBridge autoload holder + # so AtlasAgentInterface (a static class, loaded on demand by whatever + # in-process driver calls it) has a stable address to start from — see + # atlas_agent_bridge.gd's own doc for why this is a plain field set, not a + # class_name-typed autoload reference. + AtlasAgentBridge.current_app = self _load_system_data() # T-949: the star map now arrives over the bridge, possibly after this app # is already installed (or before the handshake completes at all) — refresh diff --git a/client/ui/implant/apps/atlas/screens/reach_screen.gd b/client/ui/implant/apps/atlas/screens/reach_screen.gd index d8b5487cf..08ec27f4c 100644 --- a/client/ui/implant/apps/atlas/screens/reach_screen.gd +++ b/client/ui/implant/apps/atlas/screens/reach_screen.gd @@ -474,16 +474,36 @@ func _gui_input(event: InputEvent) -> void: func _handle_reach_click(pos: Vector2) -> void: var sid := _find_nearest_reach_system(pos) - _reach_selected = sid - _rebuild_reach_info_panel() - _dirty = true + select_system_by_id(sid) func _handle_reach_double_click(pos: Vector2) -> void: var sid := _find_nearest_reach_system(pos) - if not sid.is_empty(): - _reach_selected = sid - system_selected.emit(sid) + open_system_by_id(sid) + + +## T-971 (AtlasAgentInterface `select_system` intent) — the SAME state +## mutation _handle_reach_click() performs (single-click equivalent: sets the +## selection + rebuilds the info panel, does not navigate), reachable by id +## directly rather than a screen position. An empty/unknown id clears the +## selection (mirrors what a click on empty space already does via +## `_find_nearest_reach_system`'s own "" fallback). +func select_system_by_id(system_id: String) -> void: + _reach_selected = system_id + _rebuild_reach_info_panel() + _dirty = true + + +## T-971 (AtlasAgentInterface `open_system` intent) — the SAME state mutation +## _handle_reach_double_click() performs (double-click/Enter equivalent: +## selects AND emits system_selected, which AtlasApp._on_system_selected() +## turns into a nav.push("system", ...)). A no-op for an unrecognized id, +## matching the double-click handler's own empty-id guard. +func open_system_by_id(system_id: String) -> void: + if system_id.is_empty(): + return + _reach_selected = system_id + system_selected.emit(system_id) func _update_reach_hover(pos: Vector2) -> void: diff --git a/client/ui/implant/apps/atlas/screens/regional_screen.gd b/client/ui/implant/apps/atlas/screens/regional_screen.gd index 2d5b3cbc7..6d49e7b3e 100644 --- a/client/ui/implant/apps/atlas/screens/regional_screen.gd +++ b/client/ui/implant/apps/atlas/screens/regional_screen.gd @@ -47,5 +47,15 @@ func leave() -> void: pass +## T-971 test/agent seam: exposes the owned StepCanvasViewer so +## AtlasAgentInterface can drive rung-transport intents (scroll_rung, +## jump_to, set_overlay, get_current_canvas_summary) without this screen +## having to grow a forwarding method per viewer capability — matches the +## get_request()/get_disk_cache() accessor-chain precedent StepCanvasViewer +## itself already established for the same reason. +func get_viewer() -> Variant: + return _viewer + + func _on_viewer_back() -> void: back_requested.emit() diff --git a/client/ui/implant/apps/atlas/screens/system_screen.gd b/client/ui/implant/apps/atlas/screens/system_screen.gd index 1652f332f..f069800c3 100644 --- a/client/ui/implant/apps/atlas/screens/system_screen.gd +++ b/client/ui/implant/apps/atlas/screens/system_screen.gd @@ -377,27 +377,14 @@ func _gui_input(event: InputEvent) -> void: func _handle_orbital_double_click(pos: Vector2) -> void: var bid: String = _find_nearest_body(pos) - if not bid.is_empty(): - for b: Dictionary in _orbital_bodies: - if str(b.get("body_id", "")) == bid: - body_selected.emit(b) - return + open_body_by_id(bid) func _handle_orbital_click(pos: Vector2) -> void: var bid: String = _find_nearest_body(pos) if not bid.is_empty(): - for b: Dictionary in _orbital_bodies: - if str(b.get("body_id", "")) == bid: - _selected_body = b - _selected_station = {} - if _station_panel: - _station_panel.visible = false - _rebuild_body_panel() - if _body_panel: - _body_panel.visible = true - _dirty = true - return + select_body_by_id(bid) + return var sid: String = _find_nearest_station(pos) if not sid.is_empty(): @@ -416,6 +403,39 @@ func _handle_orbital_click(pos: Vector2) -> void: _dirty = true +## T-971 (AtlasAgentInterface `select_body` intent) — the SAME state +## mutation the body branch of _handle_orbital_click() performs (opens the +## body info panel, does not navigate), reachable by id directly. A no-op for +## an unrecognized id (mirrors the click handler's own "no match under the +## cursor" fall-through to _close_detail_panels(), except an agent-driven +## select never had a "click landed on empty space" case to fall through to — +## an unrecognized id is a caller error, not a deselect gesture, so it is +## simply ignored here rather than clearing an unrelated selection). +func select_body_by_id(body_id: String) -> void: + for b: Dictionary in _orbital_bodies: + if str(b.get("body_id", "")) == body_id: + _selected_body = b + _selected_station = {} + if _station_panel: + _station_panel.visible = false + _rebuild_body_panel() + if _body_panel: + _body_panel.visible = true + _dirty = true + return + + +## T-971 (AtlasAgentInterface `open_body` intent) — the SAME state mutation +## _handle_orbital_double_click() performs (double-click equivalent: emits +## body_selected, which AtlasApp._on_body_selected() turns into +## nav.push("regional", ...)). A no-op for an unrecognized id. +func open_body_by_id(body_id: String) -> void: + for b: Dictionary in _orbital_bodies: + if str(b.get("body_id", "")) == body_id: + body_selected.emit(b) + return + + func _close_detail_panels() -> void: _selected_body = {} _selected_station = {} diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index 43a1aa512..b20154c5f 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -133,6 +133,13 @@ var _canvas_scale: float = 1.0 # ── Overlay visibility ───────────────────────────────────────────────────── var _overlay_visibility: Dictionary = {} +## The last-adopted (Ready-response OR cache-hit) canvas Dictionary — held +## here (not read from `_terrain_layer._canvas_ref`, which stays private to +## that node) so get_current_canvas_summary() has its own reference. Set in +## _on_canvas_ready() alongside the terrain/annotation layer handoff, same +## single adoption point every other per-canvas field on this class uses. +var _current_canvas_data: Variant = null # decoded EncodedStepCanvas Dictionary, or null pre-arrival + # ── Child nodes ──────────────────────────────────────────────────────────── var _canvas: Node2D = null var _terrain_layer: StepCanvasTerrainLayer = null @@ -209,6 +216,7 @@ func enter(body: Dictionary, system: Dictionary) -> void: _view_offset = Vector2.ZERO _request.reset() _annotation_layer.clear_frame() + _current_canvas_data = null _fire_request() _refresh_screen_header() if _legend_panel: @@ -274,6 +282,73 @@ func get_overlay_defs() -> Array: return OVERLAY_DEFS +## T-971 (D-226 item 4, `get_layer_data_summary`) — a public, allocation-light +## read of the currently-held canvas's shape, with NO rendering/PNG-decode +## triggered (every field read here comes straight off the raw decoded +## EncodedStepCanvas dict — step_canvas_protocol.gd's own shape — before +## StepCanvasTerrainLayer ever touches Image.load_png_from_buffer()). A +## proper public accessor, not a reach into `_terrain_layer._canvas_ref` +## (which stays private to that node) — see `_current_canvas_data`'s own doc +## for why the viewer holds its own reference instead. +## +## D-226's original "attractor/river/basin counts" phrasing is itself stale +## post-D-255: attractors don't exist on this wire, and "basin" was never a +## step-canvas field — the real countable features are courses (rivers, +## `class`-tagged), cliffs, and settlements (dedup'd exactly like +## StepCanvasAnnotationLayer._draw_settlements() dedups them, so the count +## always matches what's actually drawn). Called potentially once per step in +## a sweep loop (T-1157), so this is a single linear walk of already-small +## sparse lists — no per-cell work beyond the settlement dedup scan +## `_draw_settlements()` already pays for anyway. +func get_current_canvas_summary() -> Dictionary: + if not _current_canvas_data is Dictionary: + return { + "has_canvas": false, + "rung": _held_rung, + "world_center": [_world_center.x, _world_center.y], + "held_extent": [_held_extent.x, _held_extent.y], + } + var d: Dictionary = _current_canvas_data + var courses: Array = d.get("courses", []) + var course_count_by_class: Dictionary = {} + for course_raw: Variant in courses: + if not course_raw is Dictionary: + continue + var cls: int = int((course_raw as Dictionary).get("class", -1)) + course_count_by_class[cls] = int(course_count_by_class.get(cls, 0)) + 1 + return { + "has_canvas": true, + "rung": _held_rung, + "world_center": [_world_center.x, _world_center.y], + "held_extent": [_held_extent.x, _held_extent.y], + "canvas_width": int(d.get("width", 0)), + "canvas_height": int(d.get("height", 0)), + "course_count": courses.size(), + "course_count_by_class": course_count_by_class, + "cliff_count": (d.get("cliffs", []) as Array).size(), + "settlement_count": _count_distinct_settlements(d), + } + + +## Mirrors StepCanvasAnnotationLayer._draw_settlements()'s own dedup +## discipline exactly (distinct non-zero ids only) so this count always +## matches what the player actually sees drawn — never a raw non-zero CELL +## count, which would overcount every settlement whose footprint covers more +## than one gridunit. +static func _count_distinct_settlements(canvas: Dictionary) -> int: + var settlement_id: Variant = canvas.get("settlement_id") + if not (settlement_id is Array or settlement_id is PackedByteArray): + return 0 + var seen: Dictionary = {} + var ids: Array = settlement_id + for i in range(ids.size()): + var sid: int = int(ids[i]) + if sid == 0: + continue + seen[sid] = true + return seen.size() + + # ============================================================================= # Request lifecycle # ============================================================================= @@ -324,6 +399,7 @@ func _on_canvas_ready(canvas: Dictionary) -> void: _held_extent = _request.get_held_extent() if _held_rung == StepCanvasTransport.RUNG_GLOBAL: _global_body_extent = _held_extent + _current_canvas_data = canvas _rebuild_terrain_texture(canvas) _annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent) _recompute_canvas_transform() @@ -451,6 +527,49 @@ func _is_global_view_drifted() -> bool: return _world_center != Vector2.ZERO or _view_offset != _centered_view_offset() +## Fixed-center revisit (T-971, D-226 item 4 — the AtlasAgentInterface +## `jump_to_center` intent; also the T-1183 eyeball-driver "record a prior +## run's actual derived centers and re-request them literally" pattern, made +## a first-class production seam rather than five hand-rolled scratch +## drivers). Directly sets rung + center — no cursor-anchored derivation, +## since a caller here is handing us the ALREADY-DERIVED world point to land +## on (the whole reason this exists: a comparison run must hit run 1's exact +## cache key, not repeat a gesture that re-derives a merely-similar one). +## +## Constraint honored (Jeroen's ruling): this selects center+rung ONLY, then +## falls through to the SAME tail every other navigation uses — +## `_fire_request()` (which reads extent from `_request_extent()`, the same +## T-1189 body-cap/viewport-fit math every other request goes through), +## `_refresh_screen_header()`, `queue_redraw()`. No parallel request-building +## code exists here; a `jump_to` request is indistinguishable, cache-key-wise, +## from a `_scroll_rung`/`enter` request that happened to land on the same +## rung/center. +## +## `rung` defaults to the empty string, meaning "keep the currently held +## rung" — a caller jumping within the same rung (the common revisit case) +## need not repeat it. An unrecognized rung name is rejected (push_warning, +## no-op) rather than silently coerced to Global — same defensive posture +## StepCanvasTransport.index_for_rung() already documents for its own +## out-of-range read. +func jump_to(world_center: Vector2, rung: String = "") -> void: + var target_rung: String = rung if not rung.is_empty() else _held_rung + var target_index: int = StepCanvasTransport.index_for_rung(target_rung) + if target_index < 0: + push_warning("StepCanvasViewer: jump_to unrecognized rung '%s'" % rung) + return + _rung_index = target_index + _held_rung = target_rung + _world_center = world_center if _held_rung != StepCanvasTransport.RUNG_GLOBAL else Vector2.ZERO + _view_offset = Vector2.ZERO + _fire_request() + _refresh_screen_header() + queue_redraw() + + +func get_world_center() -> Vector2: + return _world_center + + ## The letterbox-centered `_view_offset` for the CURRENTLY held canvas ## footprint/scale — the "no pan drift" baseline _is_global_view_drifted() ## compares against, what a hard reset restores, and what diff --git a/client/ui/implant/implant_app.gd b/client/ui/implant/implant_app.gd index dbe737dbe..dfd6a1758 100644 --- a/client/ui/implant/implant_app.gd +++ b/client/ui/implant/implant_app.gd @@ -130,6 +130,16 @@ func current_screen_id() -> String: return _current_screen_id +## T-971 (AtlasAgentInterface, D-226 item 4): a generic, read-only screen +## lookup by id — the same `_screens` dict register_screen() already +## maintains, exposed so an agent-control channel can reach a specific +## screen instance (StepCanvasViewer via RegionalScreen.get_viewer(), etc.) +## without every ImplantApp subclass growing its own bespoke getters. Returns +## null for an unregistered id, matching Dictionary.get()'s own default. +func get_screen(screen_id: String) -> Variant: + return _screens.get(screen_id, null) + + func _on_screen_changed(new_id: String) -> void: if _current_screen_id != new_id: var old: Control = _screens.get(_current_screen_id, null) From 3689ad04668ea01bf770b28bebac3388619760dc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 18:52:10 +0200 Subject: [PATCH 2/6] chore(meta): update changelog Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fcca8551..7f360d09e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- **The Atlas can be driven by agents** (D-226, T-971) — a named-intent control channel (AtlasAgentInterface) lets automated QA agents navigate the real Atlas UI through the exact same handlers a click calls: open/select/descend, jump straight to any world coordinate at any zoom step, toggle overlays, and read back a summary of what's on screen — turning the hand-scripted verification drives into a first-class, repeatable QA capability. In-process consumers only for now; a shipped reference driver replaces the ad-hoc scripts + ### Changed - **Rivers read like rivers** (T-1175) — map courses now taper to a point at their upstream source instead of starting at full width (the classic cartographic river grammar), and the stream/tributary/trunk width ladder was retuned so a tributary joining a trunk visibly reads as a join. Part of the map-fluency polish pass benchmarked against the best-in-class world maps - **Rivers and mountain ranges now have names behind the map** (T-1169) — every body's rivers and peaks are assigned names from the curated per-system pools (17,891 names across the Reach) during world generation, queryable over the wire. The labels that will draw them on the Atlas come in a follow-up; the naming layer underneath is live From 80974dfe5afcf3da6b573977e10b1a8ccd22723f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 19:04:00 +0200 Subject: [PATCH 3/6] =?UTF-8?q?docs(governance):=20D-226=20amendment=20?= =?UTF-8?q?=E2=80=94=20agent-channel=20vocabulary=20reconciled=20to=20the?= =?UTF-8?q?=20stepped=20Atlas=20(T-971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyre's PR #209 finding: every prior D-226 re-scope carries a dated amendment in the record, and this vocabulary change existed only in code comments and ticket appends. Records the drops (select_city, open_regional — no settlement hit-test affordance post-D-255; one screen Region..Chunk), the additions (open_atlas, jump_to_center via the constrained jump_to seam), the summary-field reconciliation, and the in-process-only transport narrowing. Co-Authored-By: Claude Fable 5 --- governance/decisions/architecture.md | 1 + 1 file changed, 1 insertion(+) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index dfd3de7fd..d887d467b 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1688,6 +1688,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Raised by:** Jeroen + Claude (design), with Tyre (channel/pause/headless architecture) + Araminta (overlay encoding + affordance UX), 2026-05-24. - **Cross-reference:** [D-225](#d-225) (layer-stream proxy — the data path), [D-166](#d-166) (per-layer Atlas progress viewer), [D-191](#d-191) (Atlas viewer), [D-169](#d-169) / [D-170](#d-170) (implant components / HUD occlusion — `gameplay_occluded` trigger), [D-200](#d-200) / [D-203](#d-203) (execution tiers / LRU cache), Q-099 (mod content catalog), `tests/run-visual` (capture primitive), `save_state.rs` (save-inspection consumer). **T-1112 amendment additionally:** [D-222](#d-222) (Quarter terminology — the 512m unit this layer surfaces), [D-234](#d-234) (footprint geometry — the block-subdivision source the aggregates summarize), [D-243](#d-243) (quarter = 512m rung, and the containment ladder that makes a quarter sub-pixel at planetary projection — Araminta's no-outline rationale), [D-010](#d-010) (determinism — integer-only aggregates, `BTreeMap` keying). **T-1124 amendment additionally:** [D-227](#d-227) (derive-don't-store + the "invented deterministically" clause this design is the first to surface on a screen; determinism is what makes the echoed-center staleness guard and the client-side window cache both sound), [D-243](#d-243) (district = 2,048m rung — this is precisely the D-226(d) ceiling's floor, the regional altitude above quarter-skeleton and below the never-mapped chunk/voxel tier; edge-fuzz discipline — the temperature quantization-consistency rationale), [D-239](#d-239) §6 (the frozen 17-zone `MorphologyZone` vocabulary this layer serves unchanged) / §8 (vegetation climate law, amended by T-1126's `Marine`), D-225 extension / T-1131 / PR #184 (the five-map-shape demux ceiling — why the window rides on `AtlasLayerRequest` rather than a new inbound shape), [D-010](#d-010) (wire-integer discipline — all six per-cell fields are integer/quantized, no `f32` on the wire). §5 (screen) additionally leans on [D-191](#d-191) (the `AtlasViewer` whose `_view_zoom` LOD vocabulary and `SETTLEMENT_LABEL_MIN_ZOOM` threshold the district window extends in place), [D-169](#d-169) / [D-170](#d-170) (implant chrome / theme accent-role discipline — map palettes stay out of `ACCENT_ACTIVE` gold the settlement marker owns), and [D-013](#d-013) (diegetic navigation — the zoom gesture owns spatial descent, so it is not overloaded onto the city-click sidebar). Tickets: T-1123 (the `derive_district` window-render precedent this design promotes to a served layer), T-1127 (glaciation_grade derivation + probe render pattern, wire half deferred here and now accepted), T-1126 (`VegetationClass::Marine`), T-1118 (region-grid climate overlay — §5's temperature/moisture overlays reuse its ramp so one colorizer spans both zoom levels), T-1119 (concurrent `quarter_footprints` wiring — the sibling whole-body layer this design's §2 distinguishes itself from). - **Dissent:** None +- **Amendment (2026-07-25, T-971 / PR #209 — agent-channel vocabulary reconciled to the stepped Atlas):** the layer-4 `AtlasAgentInterface` ships with its intent vocabulary re-derived against the post-[D-255](#d-255) stepped Atlas, superseding this record's original list. **Dropped:** `select_city` and `open_regional` — post-D-255, settlements are `settlement_id` canvas cell values with no hit-test affordance (no server get-settlement-by-id, no client hit-test; a settlement-selection intent becomes its own ticket when a real consumer exists), and Region→Chunk is one screen (descent is `scroll_rung`, not a screen push — `open_body` replaces `open_regional`). **Added:** `open_atlas` (a driver must be able to start the session), and `jump_to_center` — the fixed-center revisit pattern proven by the T-1157 eyeball drivers, made first-class via `StepCanvasViewer.jump_to` (same request tail, extent cap, and cache keys as cursor navigation, so an agent cannot request a state a player couldn't). `get_layer_data_summary` reports the real `EncodedStepCanvas` fields (courses-by-class, cliffs, draw-matched settlement counts) — the original attractor/river/basin-count phrasing was retired-AtlasViewer vocabulary. **Transport narrowed:** in-process consumers only (gdUnit, `-s` drivers, the T-1157 capture harness — the committed reference driver replaces the scratch eyeball drivers); the original curl/terminal remote transport is a follow-up when a remote consumer exists. Full handler-by-handler mapping on T-971. --- From d316c1274bb02f08bf1ba8d8d1b953b316cd9131 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 19:05:08 +0200 Subject: [PATCH 4/6] =?UTF-8?q?chore(meta):=20pql=20changelog=20=E2=80=94?= =?UTF-8?q?=20batch-4=20activation=20rows=20(T-948=20closed=20as=20deliver?= =?UTF-8?q?ed,=20T-964/T-971=20activation=20+=20appends)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyre's PR #209 review flagged T-971's re-scope appends as invisible to the branch-side planning store — same bookkeeping-lag class as PR #208's T-1195 finding: the write-through rows were awaiting this commit. Co-Authored-By: Claude Fable 5 --- .pql/changelog/ticket_history/2026-07.sql | 52 +++++++++++++++ .pql/changelog/tickets/2026-07.sql | 79 +++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index e1aecaf3c..3ae7582e8 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -1999,3 +1999,55 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRMPWFRCD3M8282XJ01YWGX0', 'status', 'review', 'done', NULL, '2026-07-25 16:20:22', '2026-07-25 16:20:22.122', '2026-07-25 16:20:22.122', NULL, '8535ec9c01bcf0887849790d05bab399', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRV2EV5QK4BCZ1P9H85JH2YM', 'status', 'review', 'done', NULL, '2026-07-25 16:20:22', '2026-07-25 16:20:22.129', '2026-07-25 16:20:22.129', NULL, '8ed7643be68460f63e0d3555d9502718', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRMPTY517G7ZBMPAPMMCCMK8', 'status', 'review', 'done', NULL, '2026-07-25 16:20:22', '2026-07-25 16:20:22.131', '2026-07-25 16:20:22.131', NULL, 'cd5e54f6e1f1066e344ca2ab6740fade', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZ9Q7NHVDRT6ARMCWW', 'description', 'D-210 defines SubBiomeVariant for fine-grained biome classification within SettingType::Wilderness districts. Currently deferred — SettingType::Wilderness carries a plain String biome field. Implement SubBiomeVariant enum and wire it into the atlas skeleton generator when the biome taxonomy is finalized.', 'D-210 defines SubBiomeVariant for fine-grained biome classification within SettingType::Wilderness districts. Currently deferred — SettingType::Wilderness carries a plain String biome field. Implement SubBiomeVariant enum and wire it into the atlas skeleton generator when the biome taxonomy is finalized. + +Refinement review (2026-07-25, Si): OBSOLETE — D-210''s design was fully implemented and merged in PR #953/#963 (commit d418eba8b, 2026-06-03), a month after this ticket was filed, and the ticket was never closed out. Evidence: server/src/atlas/subbiome.rs (classify/classify_variant/base_cost, all 11 variants + terrain_modification_cost from the four D-210 signals, unit-tested); SubBiomeVariant enum + GeographicAttractor.sub_biome (generator.rs:539/:567); wired into the Layer-1 orchestrator (layer1.rs:209-215, Alpine branch tested); consumed downstream (attractor_matching.rs, cascade.rs); client overlay live (atlas_overlay_colors.gd:203 sub_biome_color). The only literal residue — SettingType::Wilderness carrying the cosmetic `Biome = String` alias — is unrelated to D-210''s classification/cost system; tighten it via a separate small ticket if ever wanted. Closing as delivered-by-#953.', NULL, '2026-07-25 16:25:46', '2026-07-25 16:25:46.604', '2026-07-25 16:25:46.604', NULL, '93092399b4a74d80678fc0b0d3d82bd7', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZ9Q7NHVDRT6ARMCWW', 'status', 'backlog', 'done', NULL, '2026-07-25 16:25:46', '2026-07-25 16:25:46.760', '2026-07-25 16:25:46.760', NULL, 'c269eb7751dfb286413b3a4d826889e4', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZZ5XKET93HPHKQHKW', 'description', 'Follow-ups from the Hoshe/Tyre pre-bake review (the BLOCKER/MAJOR items were fixed inline; these are the deferred MINOR/coverage items): + +TESTS: +- features.rs: full per-type attractor reachability fixtures — craft heightmaps that guarantee LakeShore (enclosed depression), PassEntrance (saddle), PlainCenter (flat), RiverCrossing (confluence). Currently only RiverMouth/CoastalAccess/ValleyFloor are exercised on the slope/sine fixtures. +- heightmap.rs: 8-bit grayscale decode path; sea_level passthrough assertion; downsample identity/upsample early-return. +- drainage.rs: basin area_pct bit-for-bit determinism (only river_cells + basin count checked today). +- layer1.rs: attach_feature_names mountain branch with a non-empty mountain pool + ≥1 Alpine attractor. +- features.rs: thin_by_spacing behavior (spacing collisions, equirectangular column wrap). +- import_economics.py: automated idempotency test — two runs on a scratch DB yield identical atlas_city_names count (not doubled), and 0 rows for system ''GJ 0'' (Sol exemption). Wire alongside check-systems-db / pre-push. +- Wire test_sim_determinism.py into pre-push or a make target. + +MINOR CODE NITS: +- drainage.rs ~480-488: comment the intentional isolated-basin fallback divergence from the old merge-to-basin-0 behavior (Tyre N1). +- planet_simulation.py ~787-788: oasis_water binary_dilation iterations are fixed-pixel — scale by GRID_W/512 for biome-display consistency at 1024 (cosmetic, Atlas only, not cascade; Tyre N2). + +D-256/T-1174 finding (2026-07-25): the believability harness golden is INSENSITIVE to a total basin_direction regression — an all-North basin collapse (every survey cell defaulting) produced a byte-identical believability.json at current sample density (64/2048 districts; basin only reaches voxel meander phase/channel width, too localized for the coarse aggregate stats). The cross-namespace keying seam is now covered by the restored derive_all_districts_threads_supplied_basin_directions test (production-shaped survey keys), but the harness itself has no basin-sensitive metric. Candidate hardening: a basin-direction distribution stat (distinct directions >= 2 on a drained body) or a voxel-transect metric that moves with meander phase.', 'Follow-ups from the Hoshe/Tyre pre-bake review (the BLOCKER/MAJOR items were fixed inline; these are the deferred MINOR/coverage items): + +TESTS: +- features.rs: full per-type attractor reachability fixtures — craft heightmaps that guarantee LakeShore (enclosed depression), PassEntrance (saddle), PlainCenter (flat), RiverCrossing (confluence). Currently only RiverMouth/CoastalAccess/ValleyFloor are exercised on the slope/sine fixtures. +- heightmap.rs: 8-bit grayscale decode path; sea_level passthrough assertion; downsample identity/upsample early-return. +- drainage.rs: basin area_pct bit-for-bit determinism (only river_cells + basin count checked today). +- layer1.rs: attach_feature_names mountain branch with a non-empty mountain pool + ≥1 Alpine attractor. +- features.rs: thin_by_spacing behavior (spacing collisions, equirectangular column wrap). +- import_economics.py: automated idempotency test — two runs on a scratch DB yield identical atlas_city_names count (not doubled), and 0 rows for system ''GJ 0'' (Sol exemption). Wire alongside check-systems-db / pre-push. +- Wire test_sim_determinism.py into pre-push or a make target. + +MINOR CODE NITS: +- drainage.rs ~480-488: comment the intentional isolated-basin fallback divergence from the old merge-to-basin-0 behavior (Tyre N1). +- planet_simulation.py ~787-788: oasis_water binary_dilation iterations are fixed-pixel — scale by GRID_W/512 for biome-display consistency at 1024 (cosmetic, Atlas only, not cascade; Tyre N2). + +D-256/T-1174 finding (2026-07-25): the believability harness golden is INSENSITIVE to a total basin_direction regression — an all-North basin collapse (every survey cell defaulting) produced a byte-identical believability.json at current sample density (64/2048 districts; basin only reaches voxel meander phase/channel width, too localized for the coarse aggregate stats). The cross-namespace keying seam is now covered by the restored derive_all_districts_threads_supplied_basin_directions test (production-shaped survey keys), but the harness itself has no basin-sensitive metric. Candidate hardening: a basin-direction distribution stat (distinct directions >= 2 on a drained body) or a voxel-transect metric that moves with meander phase. + +Refinement trim (2026-07-25, Si — every listed gap re-verified against current code before this batch): (1) DROP the test_sim_determinism.py wiring item — already done, Makefile:256 wires it into make test-tooling which the push gate runs on tooling changes. (2) The drainage.rs isolated-basin comment nit moved to ~line 654 (file grew; the old ~480-488 cite is stale) — still open, just relocate. (3) The layer1.rs attach_feature_names mountain-branch item: implicit coverage may already exist via run_layer1_* tests — check before adding a redundant test. (4) The D-256/T-1174 addendum''s "basin-direction distribution stat" candidate hardening is a genuine open design question (which stat, what threshold) — PROPOSE the stat to the lead before coding it, don''t invent silently. All other items (features.rs per-type reachability fixtures + thin_by_spacing, heightmap.rs 8-bit/sea-level-passthrough/upsample-identity, drainage.rs area_pct bit-determinism, import_economics.py idempotency test, oasis_water binary_dilation nit at planet_simulation.py:784-788) verified still open as written.', NULL, '2026-07-25 16:25:56', '2026-07-25 16:25:56.990', '2026-07-25 16:25:56.990', NULL, '99464deb0f973bb04ffdc943badd4d10', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'description', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path).', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently.', NULL, '2026-07-25 16:26:05', '2026-07-25 16:26:05.492', '2026-07-25 16:26:05.492', NULL, 'f7eb781029e013fcecfceffbf28932d8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZZ5XKET93HPHKQHKW', 'status', 'backlog', 'in_progress', NULL, '2026-07-25 16:26:10', '2026-07-25 16:26:10.181', '2026-07-25 16:26:10.181', NULL, 'f352b98435a6a00018e45a8e2dd46675', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'status', 'backlog', 'in_progress', NULL, '2026-07-25 16:26:10', '2026-07-25 16:26:10.188', '2026-07-25 16:26:10.188', NULL, '27e775276ffa828ab316d2f4e3034c1e', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZZ5XKET93HPHKQHKW', 'assigned_to', NULL, 'dudley', NULL, '2026-07-25 16:26:11', '2026-07-25 16:26:11.164', '2026-07-25 16:26:11.164', NULL, '63073f8ecec393e1eefde9b98ea63e1c', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'assigned_to', NULL, 'stig', NULL, '2026-07-25 16:26:11', '2026-07-25 16:26:11.308', '2026-07-25 16:26:11.308', NULL, 'd0016aa197af14b8daab5e496d884b9b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'description', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently.', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently. + +Intent-vocabulary diff vs D-226''s original list (recorded at implementation, 2026-07-25): select_city and open_regional DROPPED — post-D-255, settlements are settlement_id canvas cell values with no hit-test affordance (StepCanvasAnnotationLayer._draw_settlements() draws markers from cell data; no server get-settlement-by-id affordance, no client hit-test). A settlement-hit-test intent becomes its own ticket when a real consumer needs it — not filed speculatively. ADDED beyond D-226: open_atlas (a driver must be able to start a session), jump_to_center (the fixed-center revisit pattern proven by five T-1157-inventory eyeball drivers, now first-class via the new StepCanvasViewer.jump_to seam), and open_body replacing open_regional (Region..Chunk is one screen; descent is scroll_rung, not screen pushes). get_layer_data_summary reports the real EncodedStepCanvas fields (courses/cliffs/settlements) — D-226''s attractor/river/basin-count phrasing was retired-AtlasViewer vocabulary. Transport: in-process only this ticket; remote JSON transport is a follow-up when a remote consumer exists.', NULL, '2026-07-25 16:50:40', '2026-07-25 16:50:40.290', '2026-07-25 16:50:40.290', NULL, 'e6dfa5f5986853952eef1eac98269926', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'status', 'in_progress', 'review', NULL, '2026-07-25 16:57:34', '2026-07-25 16:57:34.623', '2026-07-25 16:57:34.623', NULL, '7b2b60bf4e2ba234c7fbf9fc2fafecc9', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index 13b0d4782..997931f21 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -2880,3 +2880,82 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Scope adjudication (2026-07-25, PR round, lead ruling): the "client label draw" clause is RE-SCOPED OUT of this ticket into T-1195, for rivers as well as mountains. Implementation found the ticket''s cheap-anchor premise false: the FeatureNamesRequest pool proxy is position-free (correctly mirroring CityNamesRequest per this ticket''s own instruction), and the client''s course polylines (river_course::InventedCourse) are not correlated with layer1::GeographicAttractor positions by construction — so anchoring a pool name at a mouth ring has no matching basis without a wire-carried position, the identical dependency mountains have. Rather than two ad-hoc wire changes, the one design decision (name rides the course array vs a separate sparse feature-position list mirroring courses/cliffs) lands once in T-1195 for both feature types. This ticket''s delivered scope: atlas_feature_names populated at regen (17,891 rows), the FeatureNamesRequest/Response proxy end-to-end, attach_feature_names wired into the cascade with assignments stored on Layer1Output/BodyWorldState — the three dormant pieces connected.', 'done', 'low', 'dudley', 'server', 'D-226', '2026-07-23 05:54:09.837', '2026-07-25 16:20:22.129', NULL, '9c327527100e094d1a6e34180ffec6e6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRMPTY517G7ZBMPAPMMCCMK8', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Retire the legacy window_granularity u32 wire field', 'PR #192 review (Tyre): window_granularity_v2 (the WindowGranularity enum) fully shadows the legacy u32 field — the server always echoes both, and the only conceivable legacy readers are pre-T-1152 clients, which do not exist (single-repo client/server pair, no external clients). Retire the u32 field + the WINDOW_GRANULARITY_REGION_KEY sentinel machinery from AtlasLayerRequest/DistrictWindowLayer once confirmed nothing reads it: server resolve_window_granularity legacy path, client encode path, the sentinel echo, and the five-touch-point key components that still carry the legacy value alongside the enum. Scheduled in the D-226 amendment refinement-semantics note (PR #192). Do AFTER T-1153''s follow-ups settle — no urgency, the dual-echo is cheap; the win is contract simplicity.', 'done', 'low', 'dudley', 'server', NULL, '2026-07-22 15:04:31.528', '2026-07-25 16:20:22.131', NULL, 'f6bfff77f8268ad8002aad9f799493f0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZ9Q7NHVDRT6ARMCWW', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'D-210 SubBiomeVariant implementation', 'D-210 defines SubBiomeVariant for fine-grained biome classification within SettingType::Wilderness districts. Currently deferred — SettingType::Wilderness carries a plain String biome field. Implement SubBiomeVariant enum and wire it into the atlas skeleton generator when the biome taxonomy is finalized. + +Refinement review (2026-07-25, Si): OBSOLETE — D-210''s design was fully implemented and merged in PR #953/#963 (commit d418eba8b, 2026-06-03), a month after this ticket was filed, and the ticket was never closed out. Evidence: server/src/atlas/subbiome.rs (classify/classify_variant/base_cost, all 11 variants + terrain_modification_cost from the four D-210 signals, unit-tested); SubBiomeVariant enum + GeographicAttractor.sub_biome (generator.rs:539/:567); wired into the Layer-1 orchestrator (layer1.rs:209-215, Alpine branch tested); consumed downstream (attractor_matching.rs, cascade.rs); client overlay live (atlas_overlay_colors.gd:203 sub_biome_color). The only literal residue — SettingType::Wilderness carrying the cosmetic `Biome = String` alias — is unrelated to D-210''s classification/cost system; tighten it via a separate small ticket if ever wanted. Closing as delivered-by-#953.', 'backlog', 'low', NULL, 'server', NULL, '2026-05-03 10:30:14', '2026-07-25 16:25:46.604', NULL, 'b7a8c5833ac90ea380b52b32d48580b1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZ9Q7NHVDRT6ARMCWW', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'D-210 SubBiomeVariant implementation', 'D-210 defines SubBiomeVariant for fine-grained biome classification within SettingType::Wilderness districts. Currently deferred — SettingType::Wilderness carries a plain String biome field. Implement SubBiomeVariant enum and wire it into the atlas skeleton generator when the biome taxonomy is finalized. + +Refinement review (2026-07-25, Si): OBSOLETE — D-210''s design was fully implemented and merged in PR #953/#963 (commit d418eba8b, 2026-06-03), a month after this ticket was filed, and the ticket was never closed out. Evidence: server/src/atlas/subbiome.rs (classify/classify_variant/base_cost, all 11 variants + terrain_modification_cost from the four D-210 signals, unit-tested); SubBiomeVariant enum + GeographicAttractor.sub_biome (generator.rs:539/:567); wired into the Layer-1 orchestrator (layer1.rs:209-215, Alpine branch tested); consumed downstream (attractor_matching.rs, cascade.rs); client overlay live (atlas_overlay_colors.gd:203 sub_biome_color). The only literal residue — SettingType::Wilderness carrying the cosmetic `Biome = String` alias — is unrelated to D-210''s classification/cost system; tighten it via a separate small ticket if ever wanted. Closing as delivered-by-#953.', 'done', 'low', NULL, 'server', NULL, '2026-05-03 10:30:14', '2026-07-25 16:25:46.760', NULL, 'e6aa010c5e0067704611e73147fbffed', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZZ5XKET93HPHKQHKW', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Phase-4 test hardening — deferred review gaps (#953/#963 review)', 'Follow-ups from the Hoshe/Tyre pre-bake review (the BLOCKER/MAJOR items were fixed inline; these are the deferred MINOR/coverage items): + +TESTS: +- features.rs: full per-type attractor reachability fixtures — craft heightmaps that guarantee LakeShore (enclosed depression), PassEntrance (saddle), PlainCenter (flat), RiverCrossing (confluence). Currently only RiverMouth/CoastalAccess/ValleyFloor are exercised on the slope/sine fixtures. +- heightmap.rs: 8-bit grayscale decode path; sea_level passthrough assertion; downsample identity/upsample early-return. +- drainage.rs: basin area_pct bit-for-bit determinism (only river_cells + basin count checked today). +- layer1.rs: attach_feature_names mountain branch with a non-empty mountain pool + ≥1 Alpine attractor. +- features.rs: thin_by_spacing behavior (spacing collisions, equirectangular column wrap). +- import_economics.py: automated idempotency test — two runs on a scratch DB yield identical atlas_city_names count (not doubled), and 0 rows for system ''GJ 0'' (Sol exemption). Wire alongside check-systems-db / pre-push. +- Wire test_sim_determinism.py into pre-push or a make target. + +MINOR CODE NITS: +- drainage.rs ~480-488: comment the intentional isolated-basin fallback divergence from the old merge-to-basin-0 behavior (Tyre N1). +- planet_simulation.py ~787-788: oasis_water binary_dilation iterations are fixed-pixel — scale by GRID_W/512 for biome-display consistency at 1024 (cosmetic, Atlas only, not cascade; Tyre N2). + +D-256/T-1174 finding (2026-07-25): the believability harness golden is INSENSITIVE to a total basin_direction regression — an all-North basin collapse (every survey cell defaulting) produced a byte-identical believability.json at current sample density (64/2048 districts; basin only reaches voxel meander phase/channel width, too localized for the coarse aggregate stats). The cross-namespace keying seam is now covered by the restored derive_all_districts_threads_supplied_basin_directions test (production-shaped survey keys), but the harness itself has no basin-sensitive metric. Candidate hardening: a basin-direction distribution stat (distinct directions >= 2 on a drained body) or a voxel-transect metric that moves with meander phase. + +Refinement trim (2026-07-25, Si — every listed gap re-verified against current code before this batch): (1) DROP the test_sim_determinism.py wiring item — already done, Makefile:256 wires it into make test-tooling which the push gate runs on tooling changes. (2) The drainage.rs isolated-basin comment nit moved to ~line 654 (file grew; the old ~480-488 cite is stale) — still open, just relocate. (3) The layer1.rs attach_feature_names mountain-branch item: implicit coverage may already exist via run_layer1_* tests — check before adding a redundant test. (4) The D-256/T-1174 addendum''s "basin-direction distribution stat" candidate hardening is a genuine open design question (which stat, what threshold) — PROPOSE the stat to the lead before coding it, don''t invent silently. All other items (features.rs per-type reachability fixtures + thin_by_spacing, heightmap.rs 8-bit/sea-level-passthrough/upsample-identity, drainage.rs area_pct bit-determinism, import_economics.py idempotency test, oasis_water binary_dilation nit at planet_simulation.py:784-788) verified still open as written.', 'backlog', 'medium', NULL, 'server', NULL, '2026-05-23 06:09:45', '2026-07-25 16:25:56.990', NULL, 'e89f228c33aefc802abd1b95dabe078e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas agent control channel — observe/act named-intent navigation (D-226)', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently.', 'backlog', 'medium', NULL, 'client', 'D-226', '2026-05-24 09:22:38', '2026-07-25 16:26:05.492', NULL, 'ebc75290cb615984581a87c04a0d88b5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZZ5XKET93HPHKQHKW', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Phase-4 test hardening — deferred review gaps (#953/#963 review)', 'Follow-ups from the Hoshe/Tyre pre-bake review (the BLOCKER/MAJOR items were fixed inline; these are the deferred MINOR/coverage items): + +TESTS: +- features.rs: full per-type attractor reachability fixtures — craft heightmaps that guarantee LakeShore (enclosed depression), PassEntrance (saddle), PlainCenter (flat), RiverCrossing (confluence). Currently only RiverMouth/CoastalAccess/ValleyFloor are exercised on the slope/sine fixtures. +- heightmap.rs: 8-bit grayscale decode path; sea_level passthrough assertion; downsample identity/upsample early-return. +- drainage.rs: basin area_pct bit-for-bit determinism (only river_cells + basin count checked today). +- layer1.rs: attach_feature_names mountain branch with a non-empty mountain pool + ≥1 Alpine attractor. +- features.rs: thin_by_spacing behavior (spacing collisions, equirectangular column wrap). +- import_economics.py: automated idempotency test — two runs on a scratch DB yield identical atlas_city_names count (not doubled), and 0 rows for system ''GJ 0'' (Sol exemption). Wire alongside check-systems-db / pre-push. +- Wire test_sim_determinism.py into pre-push or a make target. + +MINOR CODE NITS: +- drainage.rs ~480-488: comment the intentional isolated-basin fallback divergence from the old merge-to-basin-0 behavior (Tyre N1). +- planet_simulation.py ~787-788: oasis_water binary_dilation iterations are fixed-pixel — scale by GRID_W/512 for biome-display consistency at 1024 (cosmetic, Atlas only, not cascade; Tyre N2). + +D-256/T-1174 finding (2026-07-25): the believability harness golden is INSENSITIVE to a total basin_direction regression — an all-North basin collapse (every survey cell defaulting) produced a byte-identical believability.json at current sample density (64/2048 districts; basin only reaches voxel meander phase/channel width, too localized for the coarse aggregate stats). The cross-namespace keying seam is now covered by the restored derive_all_districts_threads_supplied_basin_directions test (production-shaped survey keys), but the harness itself has no basin-sensitive metric. Candidate hardening: a basin-direction distribution stat (distinct directions >= 2 on a drained body) or a voxel-transect metric that moves with meander phase. + +Refinement trim (2026-07-25, Si — every listed gap re-verified against current code before this batch): (1) DROP the test_sim_determinism.py wiring item — already done, Makefile:256 wires it into make test-tooling which the push gate runs on tooling changes. (2) The drainage.rs isolated-basin comment nit moved to ~line 654 (file grew; the old ~480-488 cite is stale) — still open, just relocate. (3) The layer1.rs attach_feature_names mountain-branch item: implicit coverage may already exist via run_layer1_* tests — check before adding a redundant test. (4) The D-256/T-1174 addendum''s "basin-direction distribution stat" candidate hardening is a genuine open design question (which stat, what threshold) — PROPOSE the stat to the lead before coding it, don''t invent silently. All other items (features.rs per-type reachability fixtures + thin_by_spacing, heightmap.rs 8-bit/sea-level-passthrough/upsample-identity, drainage.rs area_pct bit-determinism, import_economics.py idempotency test, oasis_water binary_dilation nit at planet_simulation.py:784-788) verified still open as written.', 'in_progress', 'medium', NULL, 'server', NULL, '2026-05-23 06:09:45', '2026-07-25 16:26:10.181', NULL, '1330970c9d7ec160af9d9b6f116152a4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas agent control channel — observe/act named-intent navigation (D-226)', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently.', 'in_progress', 'medium', NULL, 'client', 'D-226', '2026-05-24 09:22:38', '2026-07-25 16:26:10.188', NULL, 'e6121a05892f9e4e72c7974d4b0faf96', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZZ5XKET93HPHKQHKW', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Phase-4 test hardening — deferred review gaps (#953/#963 review)', 'Follow-ups from the Hoshe/Tyre pre-bake review (the BLOCKER/MAJOR items were fixed inline; these are the deferred MINOR/coverage items): + +TESTS: +- features.rs: full per-type attractor reachability fixtures — craft heightmaps that guarantee LakeShore (enclosed depression), PassEntrance (saddle), PlainCenter (flat), RiverCrossing (confluence). Currently only RiverMouth/CoastalAccess/ValleyFloor are exercised on the slope/sine fixtures. +- heightmap.rs: 8-bit grayscale decode path; sea_level passthrough assertion; downsample identity/upsample early-return. +- drainage.rs: basin area_pct bit-for-bit determinism (only river_cells + basin count checked today). +- layer1.rs: attach_feature_names mountain branch with a non-empty mountain pool + ≥1 Alpine attractor. +- features.rs: thin_by_spacing behavior (spacing collisions, equirectangular column wrap). +- import_economics.py: automated idempotency test — two runs on a scratch DB yield identical atlas_city_names count (not doubled), and 0 rows for system ''GJ 0'' (Sol exemption). Wire alongside check-systems-db / pre-push. +- Wire test_sim_determinism.py into pre-push or a make target. + +MINOR CODE NITS: +- drainage.rs ~480-488: comment the intentional isolated-basin fallback divergence from the old merge-to-basin-0 behavior (Tyre N1). +- planet_simulation.py ~787-788: oasis_water binary_dilation iterations are fixed-pixel — scale by GRID_W/512 for biome-display consistency at 1024 (cosmetic, Atlas only, not cascade; Tyre N2). + +D-256/T-1174 finding (2026-07-25): the believability harness golden is INSENSITIVE to a total basin_direction regression — an all-North basin collapse (every survey cell defaulting) produced a byte-identical believability.json at current sample density (64/2048 districts; basin only reaches voxel meander phase/channel width, too localized for the coarse aggregate stats). The cross-namespace keying seam is now covered by the restored derive_all_districts_threads_supplied_basin_directions test (production-shaped survey keys), but the harness itself has no basin-sensitive metric. Candidate hardening: a basin-direction distribution stat (distinct directions >= 2 on a drained body) or a voxel-transect metric that moves with meander phase. + +Refinement trim (2026-07-25, Si — every listed gap re-verified against current code before this batch): (1) DROP the test_sim_determinism.py wiring item — already done, Makefile:256 wires it into make test-tooling which the push gate runs on tooling changes. (2) The drainage.rs isolated-basin comment nit moved to ~line 654 (file grew; the old ~480-488 cite is stale) — still open, just relocate. (3) The layer1.rs attach_feature_names mountain-branch item: implicit coverage may already exist via run_layer1_* tests — check before adding a redundant test. (4) The D-256/T-1174 addendum''s "basin-direction distribution stat" candidate hardening is a genuine open design question (which stat, what threshold) — PROPOSE the stat to the lead before coding it, don''t invent silently. All other items (features.rs per-type reachability fixtures + thin_by_spacing, heightmap.rs 8-bit/sea-level-passthrough/upsample-identity, drainage.rs area_pct bit-determinism, import_economics.py idempotency test, oasis_water binary_dilation nit at planet_simulation.py:784-788) verified still open as written.', 'in_progress', 'medium', 'dudley', 'server', NULL, '2026-05-23 06:09:45', '2026-07-25 16:26:11.159', NULL, 'd2bc3750b255e45527ac695220f93c8d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas agent control channel — observe/act named-intent navigation (D-226)', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently.', 'in_progress', 'medium', 'stig', 'client', 'D-226', '2026-05-24 09:22:38', '2026-07-25 16:26:11.308', NULL, 'c61776c96a1110fae0686f2eb4927fcd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas agent control channel — observe/act named-intent navigation (D-226)', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently. + +Intent-vocabulary diff vs D-226''s original list (recorded at implementation, 2026-07-25): select_city and open_regional DROPPED — post-D-255, settlements are settlement_id canvas cell values with no hit-test affordance (StepCanvasAnnotationLayer._draw_settlements() draws markers from cell data; no server get-settlement-by-id affordance, no client hit-test). A settlement-hit-test intent becomes its own ticket when a real consumer needs it — not filed speculatively. ADDED beyond D-226: open_atlas (a driver must be able to start a session), jump_to_center (the fixed-center revisit pattern proven by five T-1157-inventory eyeball drivers, now first-class via the new StepCanvasViewer.jump_to seam), and open_body replacing open_regional (Region..Chunk is one screen; descent is scroll_rung, not screen pushes). get_layer_data_summary reports the real EncodedStepCanvas fields (courses/cliffs/settlements) — D-226''s attractor/river/basin-count phrasing was retired-AtlasViewer vocabulary. Transport: in-process only this ticket; remote JSON transport is a follow-up when a remote consumer exists.', 'in_progress', 'medium', 'stig', 'client', 'D-226', '2026-05-24 09:22:38', '2026-07-25 16:50:40.290', NULL, '5889e54d53d1d2a8f1457cfb56c2f699', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWTP633SM8XB302MBM', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas agent control channel — observe/act named-intent navigation (D-226)', 'D-226 layer 4 (apex). A client-side AtlasAgentInterface exposing a JSON control channel so an automated agent (terminal/curl, headless Godot) can navigate the REAL Atlas UI: observe -> JSON of (a) current data state shown and (b) a walkable UI affordance tree (toggle controls: id/label/state/locked); act -> named SEMANTIC intents (select_system/open_system/select_body/open_regional/select_city/set_overlay/reset_view/back/close_atlas) backed by the same handlers a click calls (the map uses _gui_input, NOT pixel-clicks), plus get_layer_data_summary (attractor/river/basin counts as JSON without rendering). Single AtlasAgentInterface autoload/static class for a stable contract; generic Control-tree walk so economics/saves plug in later. Turns human-eyeball review into an agent QA sweep. Depends on #960 (the viewer) + the auto-pause substrate. Screenshot capture is a separate ticket (kept off the critical path). + +Re-scope (2026-07-25, Si refinement + lead ruling): both cited blockers are satisfied (T-960, T-970 done) and the D-255(d) deep-rung cache cap is already built defensively for this exact consumer (step_canvas_disk_cache.gd:61 names the AtlasAgentInterface QA sweep). But D-226''s named-intent list was designed against the retired pre-D-255 continuous-zoom Atlas — the ticket''s intent surface is STALE. Implementer instruction: derive the actual intent list from the CURRENT stepped-rung API before building the wrapper, and post the derived list to the team lead for confirmation before implementation. Known mapping start (verify, don''t trust): select_system → atlas_app._on_system_selected(system_id); select_body → _on_body_selected(body); reset_view → StepCanvasViewer._reset_to_global(); set_overlay → set_overlay_visible(); back → _on_regional_back(); zoom step → _scroll_rung(direction, cursor_local); open_regional/select_city/open_system need tracing through screens/regional_screen.gd, district_screen.gd, system_screen.gd. SEQUENCING: T-1157 (stepped capture-harness redesign) needs the same walk-the-Atlas-headlessly primitive — its harness-techniques inventory (appended 2026-07-25) documents five proven eyeball-driver patterns (InputSwallower, in-process viewer-method navigation, fixed-center revisit, view-transform logging) that are the PROTOTYPE for this channel; build the T-971 intent layer as the productionization of that pattern so T-1157''s harness can consume it rather than the two designing overlapping scaffolding independently. + +Intent-vocabulary diff vs D-226''s original list (recorded at implementation, 2026-07-25): select_city and open_regional DROPPED — post-D-255, settlements are settlement_id canvas cell values with no hit-test affordance (StepCanvasAnnotationLayer._draw_settlements() draws markers from cell data; no server get-settlement-by-id affordance, no client hit-test). A settlement-hit-test intent becomes its own ticket when a real consumer needs it — not filed speculatively. ADDED beyond D-226: open_atlas (a driver must be able to start a session), jump_to_center (the fixed-center revisit pattern proven by five T-1157-inventory eyeball drivers, now first-class via the new StepCanvasViewer.jump_to seam), and open_body replacing open_regional (Region..Chunk is one screen; descent is scroll_rung, not screen pushes). get_layer_data_summary reports the real EncodedStepCanvas fields (courses/cliffs/settlements) — D-226''s attractor/river/basin-count phrasing was retired-AtlasViewer vocabulary. Transport: in-process only this ticket; remote JSON transport is a follow-up when a remote consumer exists.', 'review', 'medium', 'stig', 'client', 'D-226', '2026-05-24 09:22:38', '2026-07-25 16:57:34.622', NULL, 'e938b340830f851c004b4271226430ec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); From 52304d3e375902a9a5014e3603419a93094f9374 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 19:14:02 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix(ui):=20PR=20#209=20review=20round=20?= =?UTF-8?q?=E2=80=94=20current-screen=20guards,=20pending-aware=20settle,?= =?UTF-8?q?=20one=20body=20guard=20(T-971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen-targeted intent now routes through one _require_current_screen() check and returns the structured error shape instead of silently mutating an off-screen viewer (hoshe's finding: scroll_rung from the reach screen fired real IPC and reported ok). The reference driver's fixed 4-frame settle becomes is_pending()-aware with a 600-frame bound, the keep-waiting decision extracted as a pure testable function — restoring the proven eyeball-driver discipline. The terrain_reference guard moves into AtlasApp._on_body_selected(), the shared tail for double-click, Enter, AND the intent path — closing a pre-existing click/Enter divergence hoshe caught this PR formalizing; the intent layer pre-checks via the new SystemScreen.find_body() and reports structured errors for unknown ids and terrain-less bodies. after_test() resets AtlasAgentBridge.current_app (tyre's freed-pending footgun). Suites 58/58 + 14/14; full suite 3,638. Co-Authored-By: Claude Fable 5 --- client/tests/atlas_agent_driver.gd | 85 ++++++++- client/tests/test_atlas_agent_driver.gd | 24 +++ client/tests/test_atlas_agent_interface.gd | 177 +++++++++++++++++- .../apps/atlas/atlas_agent_interface.gd | 147 ++++++++++++--- client/ui/implant/apps/atlas/atlas_app.gd | 27 ++- .../apps/atlas/screens/system_screen.gd | 24 ++- 6 files changed, 440 insertions(+), 44 deletions(-) diff --git a/client/tests/atlas_agent_driver.gd b/client/tests/atlas_agent_driver.gd index 386bbf71e..8b1225e06 100644 --- a/client/tests/atlas_agent_driver.gd +++ b/client/tests/atlas_agent_driver.gd @@ -29,6 +29,22 @@ ## own `_run_job("jump_to_center", ...)` dispatch) exists to exercise — ## AtlasAgentInterface's production seam for it, not reimplemented here. ## +## PR #209 review (Hoshe finding 2) — settle discipline after a mutating +## intent: a FIXED `for i in range(4)` frame count (the pre-fix shape) is a +## regression against the proven is_pending()-aware pattern every eyeball +## driver round already established (visual_capture.gd's own +## _wait_for_atlas_layers_ready()) — a slow live server round-trip leaves the +## NEXT job's observe() reading a mid-fetch viewer, exactly the class of bug +## the whole T-1157 "settle-until-ready" technique exists to prevent. +## _settle_after_intent() below polls StepCanvasRequest.is_pending() (via the +## regional viewer's own get_request()) when "regional" is the current screen +## — the only screen a step-canvas fetch could be in flight for — with a +## bounded max-frame fallback (SETTLE_MAX_FRAMES, matching how the eyeball +## drivers waited: a generous multi-second bound, never an unbounded await). +## Every OTHER screen-targeted intent (reach/system) has no request to wait +## on, so it keeps the small fixed settle (SETTLE_FIXED_FRAMES) for its own +## panel-rebuild/queue_redraw() to apply. +## ## Usage (JSON job list on stdin path, matching visual_capture.gd's own ## `-- --flag value` CLI convention): ## godot --rendering-driver opengl3 --path client \ @@ -44,6 +60,18 @@ ## back to assert the reference driver actually completes a real session. extends SceneTree +## Fixed settle for a mutating intent with no in-flight request to poll +## (reach/system screen intents — a panel rebuild/queue_redraw() needs at +## most a couple of frames, never a network round-trip). +const SETTLE_FIXED_FRAMES: int = 4 + +## Bounded fallback for the is_pending()-aware settle on "regional" — +## generous, matching how the T-1157 eyeball drivers waited (multi-second +## bound at 60fps), never an unbounded await. A request that's STILL pending +## after this many frames is a real timeout, not "give it a bit longer" — +## the driver logs it and moves on rather than hanging the whole job list. +const SETTLE_MAX_FRAMES: int = 600 + var _jobs_path: String = "" var _output_path: String = "" var _results: Array = [] @@ -99,19 +127,62 @@ func _run() -> void: result = atlas_agent_interface.observe(atlas_agent_bridge.current_app) else: result = atlas_agent_interface.act(atlas_agent_bridge.current_app, intent, params) - # Settle-until-ready (T-1157 inventory item 2's underlying discipline, - # applied generically here rather than per-navigation-intent): a few - # frames after every mutating call so the resulting screen/viewer - # state (panel rebuilds, queue_redraw()) has actually applied before - # the NEXT job's observe() reads it. - for i in range(4): - await process_frame + await _settle_after_intent(atlas_agent_bridge.current_app) _results.append({"intent": intent, "params": params, "result": result}) _write_output(_output_path, _results) quit() +## PR #209 review (Hoshe finding 2) — see this file's own header doc for the +## full rationale. `app` may be null (e.g. after close_atlas) — a null app +## has nothing to poll, so this falls through to the fixed settle only. The +## bounded-fallback DECISION (keep waiting vs stop) is split out into the +## pure, no-await should_keep_waiting() below specifically so it's testable +## without a real SceneTree frame loop (test_atlas_agent_driver.gd drives it +## directly against a fake pending-state + frame counter). +func _settle_after_intent(app: Variant) -> void: + var viewer: Variant = _regional_viewer_if_current(app) + if viewer == null: + for i in range(SETTLE_FIXED_FRAMES): + await process_frame + return + var request: Variant = viewer.get_request() + var waited := 0 + while should_keep_waiting(request.is_pending(), waited, SETTLE_MAX_FRAMES): + await process_frame + waited += 1 + if request.is_pending(): + print( + "atlas_agent_driver: settle timed out after %d frames — request still pending" + % SETTLE_MAX_FRAMES + ) + + +## The pure bounded-fallback decision: keep waiting only while the request is +## STILL pending AND the frame budget isn't exhausted. Static + no SceneTree +## dependency — `is_pending`/`waited`/`max_frames` are plain values a test can +## supply directly, exercising the exact boundary conditions (pending forever +## past the bound stops; resolving early stops immediately) without needing +## a real request object or a real frame loop. +static func should_keep_waiting(is_pending: bool, waited: int, max_frames: int) -> bool: + return is_pending and waited < max_frames + + +## `app` may be null; "regional" may not even be registered yet this early in +## a session (e.g. before the first open_body). Returns null in either case +## rather than erroring — the caller's fixed-settle fallback covers it. +func _regional_viewer_if_current(app: Variant) -> Variant: + if app == null: + return null + if app.current_screen_id() != "regional": + return null + var regional_screen: Variant = app.get_screen("regional") + if regional_screen == null: + return null + return regional_screen.get_viewer() + + func _parse_args() -> void: var args := OS.get_cmdline_user_args() var i := 0 diff --git a/client/tests/test_atlas_agent_driver.gd b/client/tests/test_atlas_agent_driver.gd index caca7b9a3..b69868308 100644 --- a/client/tests/test_atlas_agent_driver.gd +++ b/client/tests/test_atlas_agent_driver.gd @@ -62,6 +62,30 @@ func test_write_output_round_trips_through_json() -> void: assert_that(parsed).is_equal(results) +# ============================================================================= +# PR #209 review (Hoshe finding 2) — should_keep_waiting()'s bounded-fallback +# logic. Pure/static, no SceneTree dependency, so the boundary conditions are +# directly testable without a real frame loop or a real StepCanvasRequest. +# ============================================================================= + + +func test_should_keep_waiting_true_while_pending_and_under_the_frame_budget() -> void: + assert_bool(DriverScript.should_keep_waiting(true, 0, 600)).is_true() + assert_bool(DriverScript.should_keep_waiting(true, 599, 600)).is_true() + + +func test_should_keep_waiting_false_once_no_longer_pending() -> void: + assert_bool(DriverScript.should_keep_waiting(false, 0, 600)).is_false() + + +func test_should_keep_waiting_false_once_the_frame_budget_is_exhausted() -> void: + # Still pending, but waited has reached max_frames — the bounded fallback + # must stop here rather than hanging indefinitely on a genuinely-stuck + # request (the whole reason SETTLE_MAX_FRAMES exists). + assert_bool(DriverScript.should_keep_waiting(true, 600, 600)).is_false() + assert_bool(DriverScript.should_keep_waiting(true, 601, 600)).is_false() + + ## The InputSwallower inner class (T-1157 inventory item 1) — a plain Node ## subclass with no SceneTree/window dependency for its OWN logic ## (set_input_as_handled() requires a live viewport to call meaningfully, but diff --git a/client/tests/test_atlas_agent_interface.gd b/client/tests/test_atlas_agent_interface.gd index 22d56a163..1ad47e1d9 100644 --- a/client/tests/test_atlas_agent_interface.gd +++ b/client/tests/test_atlas_agent_interface.gd @@ -55,6 +55,12 @@ func after_test() -> void: HudGroups._active_mode = HudGroups.Mode.GAMEPLAY HudGroups._groups.erase(TEST_APP_PATH) HudGroups._groups.erase("implant/map") + # PR #209 review (Tyre): every _make_app() call sets + # AtlasAgentBridge.current_app to the (now freed) instance via on_install() + # — clearing it here matches the HudGroups-reset discipline already above + # and closes the latent footgun of a later test/consumer reading a stale, + # freed-pending app reference. + AtlasAgentBridge.current_app = null # ============================================================================= @@ -158,17 +164,57 @@ func test_open_body_reaches_atlas_app_nav_push_to_regional() -> void: app.queue_free() -func test_open_body_is_a_no_op_for_unrecognized_id() -> void: +func test_open_body_returns_structured_error_for_unrecognized_id() -> void: var app = _make_app() app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) _enter_orbital(app) - AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "does_not_exist"}) + var result: Dictionary = AtlasAgentInterfaceScript.act( + app, "open_body", {"body_id": "does_not_exist"} + ) + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() assert_str(app.current_screen_id()).is_equal("system") app.queue_free() +## PR #209 review (Hoshe finding 3, lead ruling): open_body must reject a +## RECOGNIZED body with no terrain_reference — this is the pre-existing +## click/Enter divergence the PR formalizes and fixes. Structured error, not +## a silent no-op, and no navigation must occur. +func test_open_body_rejects_a_body_with_no_terrain_reference() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "GJ1c"}) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + assert_str(app.current_screen_id()).override_failure_message( + "open_body must not navigate for a body with no terrain_reference" + ).is_equal("system") + app.queue_free() + + +## The positive case alongside the guard above: a body WITH a +## terrain_reference still opens normally (GJ1b in the fixture) — this is +## test_open_body_reaches_atlas_app_nav_push_to_regional() above, kept as its +## own assertion here too so the guard test and the "terrain body opens" test +## sit next to each other per the review's own test list. +func test_open_body_opens_a_body_with_a_terrain_reference() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "GJ1b"}) + + assert_bool(result.get("ok", false)).is_true() + assert_str(app.current_screen_id()).is_equal("regional") + app.queue_free() + + # ============================================================================= # act() — regional/step-canvas intents # ============================================================================= @@ -320,6 +366,133 @@ func test_close_atlas_reaches_hud_groups_close_app() -> void: app.queue_free() +# ============================================================================= +# PR #209 review (Hoshe finding 1) — off-screen intent dispatch. Every +# screen-targeted intent must reject when its expected screen is NOT the +# current one, with the structured {"ok": false, "error": ...} shape, and +# must NOT mutate the off-screen screen/viewer as a side effect. +# ============================================================================= + + +## The explicitly-called-out case: scroll_rung while "reach" is showing must +## not silently mutate the (registered but off-screen) regional viewer — the +## exact bug this whole guard exists to close. Verifies both the structured +## error AND the absence of a side effect (the off-screen viewer's held rung +## is unchanged from its "regional" was never entered" default). +func test_scroll_rung_from_reach_screen_returns_structured_error_and_does_not_mutate_viewer() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_str(app.current_screen_id()).override_failure_message( + "test setup: app must default to the reach screen" + ).is_equal("reach") + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "scroll_rung", {"direction": 1}) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + var viewer: Variant = app.get_screen("regional").get_viewer() + assert_str(viewer.get_held_rung()).override_failure_message( + "scroll_rung from an off-screen 'reach' must not mutate the regional viewer" + ).is_equal(StepCanvasTransport.RUNG_GLOBAL) + app.queue_free() + + +func test_jump_to_center_from_system_screen_returns_structured_error() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act( + app, "jump_to_center", {"world_center": [1.0, 2.0]} + ) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + app.queue_free() + + +func test_reset_view_from_reach_screen_returns_structured_error() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "reset_view", {}) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + app.queue_free() + + +func test_set_overlay_from_system_screen_returns_structured_error_and_does_not_mutate_viewer() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act( + app, "set_overlay", {"overlay_id": "gen_dw_temp", "visible": true} + ) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + var viewer: Variant = app.get_screen("regional").get_viewer() + assert_bool(viewer.is_overlay_visible("gen_dw_temp")).override_failure_message( + "set_overlay from an off-screen 'system' must not mutate the regional viewer" + ).is_false() + app.queue_free() + + +func test_select_system_from_system_screen_returns_structured_error() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_orbital(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act( + app, "select_system", {"system_id": "GJ1"} + ) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + app.queue_free() + + +func test_open_system_from_regional_screen_returns_structured_error() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + _enter_regional(app) + + var result: Dictionary = AtlasAgentInterfaceScript.act( + app, "open_system", {"system_id": "GJ1"} + ) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + app.queue_free() + + +func test_select_body_from_reach_screen_returns_structured_error() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "select_body", {"body_id": "GJ1c"}) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + app.queue_free() + + +func test_open_body_from_reach_screen_returns_structured_error_and_does_not_navigate() -> void: + var app = _make_app() + app._internal_app_changed(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + + var result: Dictionary = AtlasAgentInterfaceScript.act(app, "open_body", {"body_id": "GJ1b"}) + + assert_bool(result.get("ok", true)).is_false() + assert_bool(result.has("error")).is_true() + assert_str(app.current_screen_id()).override_failure_message( + "open_body from an off-screen 'reach' must not navigate" + ).is_equal("reach") + app.queue_free() + + # ============================================================================= # Unknown intent # ============================================================================= diff --git a/client/ui/implant/apps/atlas/atlas_agent_interface.gd b/client/ui/implant/apps/atlas/atlas_agent_interface.gd index 24f3f2f11..38969df35 100644 --- a/client/ui/implant/apps/atlas/atlas_agent_interface.gd +++ b/client/ui/implant/apps/atlas/atlas_agent_interface.gd @@ -151,6 +151,23 @@ static func _walk_affordances_recursive(node: Node, out: Array) -> void: ## meant to be driven by a fallible external caller (a curl-style JSON body, ## a test fixture), and a malformed intent name is exactly the kind of input ## it must handle gracefully. +## +## **PR #209 review (Hoshe finding 1, the off-screen dispatch bug):** +## app.get_screen(id) is a REGISTRY lookup — every screen is registered (and +## therefore reachable) for the app's whole lifetime, regardless of which one +## is currently visible/current. Before this fix, every screen-targeted +## intent resolved its target via get_screen() alone, so e.g. scroll_rung +## while "reach" was showing silently mutated the off-screen "regional" +## viewer — including firing a real SimBridge.request_step_canvas() IPC +## call — and returned {"ok": true}, as if the player had actually been +## looking at the map. Every screen-targeted intent below now checks +## app.current_screen_id() against the screen it targets FIRST, returning +## the same structured {"ok": false, "error": ...} shape the null-app/ +## unknown-intent paths already use. This is a per-intent expectation, not a +## single global gate, because different intents target different screens +## (select_system/open_system expect "reach"; select_body/open_body expect +## "system"; scroll_rung/jump_to_center/reset_view/set_overlay expect +## "regional") — see _require_current_screen()'s own doc. static func act(app: Node, intent: String, params: Dictionary = {}) -> Dictionary: if app == null and intent != "open_atlas": return {"ok": false, "error": "no AtlasApp instance available"} @@ -162,35 +179,35 @@ static func act(app: Node, intent: String, params: Dictionary = {}) -> Dictionar HudGroups.close_app() return {"ok": true} "select_system": - app.get_screen("reach").select_system_by_id(str(params.get("system_id", ""))) - return {"ok": true} + return _act_on_current_screen( + app, "reach", func(s: Node) -> void: s.select_system_by_id(str(params.get("system_id", ""))) + ) "open_system": - app.get_screen("reach").open_system_by_id(str(params.get("system_id", ""))) - return {"ok": true} + return _act_on_current_screen( + app, "reach", func(s: Node) -> void: s.open_system_by_id(str(params.get("system_id", ""))) + ) "select_body": - app.get_screen("system").select_body_by_id(str(params.get("body_id", ""))) - return {"ok": true} + return _act_on_current_screen( + app, "system", func(s: Node) -> void: s.select_body_by_id(str(params.get("body_id", ""))) + ) "open_body": - app.get_screen("system").open_body_by_id(str(params.get("body_id", ""))) - return {"ok": true} + return _act_open_body(app, params) "scroll_rung": return _act_scroll_rung(app, params) "jump_to_center": return _act_jump_to_center(app, params) "reset_view": - var viewer: Variant = _get_viewer(app) - if viewer == null: - return {"ok": false, "error": "regional screen not active"} - viewer._reset_to_global() - return {"ok": true} + return _act_on_current_screen( + app, "regional", func(s: Node) -> void: s.get_viewer()._reset_to_global() + ) "set_overlay": - var overlay_viewer: Variant = _get_viewer(app) - if overlay_viewer == null: - return {"ok": false, "error": "regional screen not active"} - overlay_viewer.set_overlay_visible( - str(params.get("overlay_id", "")), bool(params.get("visible", true)) + return _act_on_current_screen( + app, + "regional", + func(s: Node) -> void: s.get_viewer().set_overlay_visible( + str(params.get("overlay_id", "")), bool(params.get("visible", true)) + ) ) - return {"ok": true} "back": app.nav.pop() return {"ok": true} @@ -198,14 +215,85 @@ static func act(app: Node, intent: String, params: Dictionary = {}) -> Dictionar return {"ok": false, "error": "unknown intent '%s'" % intent} # gdlint:ignore = max-returns +## The shared current-screen guard (PR #209 review, Hoshe finding 1): returns +## the structured {"ok": false, "error": ...} shape if `expected_screen_id` +## isn't the CURRENT screen (app.current_screen_id()), otherwise runs +## `body` against the resolved screen instance and returns {"ok": true}. +## `body` is a Callable taking the screen Node — every screen-targeted intent +## that has no extra result fields to report (select_system/open_system/ +## select_body/reset_view/set_overlay) routes through this single check +## rather than five copies of the same "is this screen current" branch. +## scroll_rung/jump_to_center/open_body still need their own wrappers (they +## report extra fields — rung/world_center — or a body-specific guard) but +## reuse _require_current_screen() for the identical check. +static func _act_on_current_screen( + app: Node, expected_screen_id: String, body: Callable +) -> Dictionary: + var screen: Variant = _require_current_screen(app, expected_screen_id) + if screen == null: + return _not_current_screen_error(app, expected_screen_id) + body.call(screen) + return {"ok": true} + + +## Returns the registered screen instance for `expected_screen_id` ONLY if it +## is also the CURRENTLY showing screen (app.current_screen_id() == +## expected_screen_id) — null otherwise (either unregistered, per +## get_screen()'s own contract, or registered-but-not-current, the bug this +## whole guard exists to close). This is the ONE place "is this screen +## current" is checked — every act() branch above and every _act_* helper +## below calls this rather than checking current_screen_id() inline. +static func _require_current_screen(app: Node, expected_screen_id: String) -> Variant: + if app.current_screen_id() != expected_screen_id: + return null + return app.get_screen(expected_screen_id) + + +static func _not_current_screen_error(app: Node, expected_screen_id: String) -> Dictionary: + return { + "ok": false, + "error": ( + "intent requires screen '%s' to be current, but '%s' is showing" + % [expected_screen_id, app.current_screen_id()] + ), + } + + +## `open_body` — PR #209 review (Hoshe finding 3, lead ruling): the +## Enter-key path has always gated body entry on `terrain_reference != null` +## (AtlasApp._handle_enter()'s "system" branch); double-click (and therefore +## this intent, which drives the identical SystemScreen.body_selected signal) +## did not — a pre-existing click/Enter divergence this PR formalizes into a +## contract, so it fixes it. The guard itself now lives in the SHARED tail +## (AtlasApp._on_body_selected(), the signal handler both double-click and +## this intent funnel through) — a guarded body is a silent no-op there, +## matching what the Enter path always did. This intent does its OWN +## pre-check via SystemScreen.find_body() so it can report a STRUCTURED +## error instead of masking "nothing happened" as {"ok": true} the way a bare +## click has no way to report either way. +static func _act_open_body(app: Node, params: Dictionary) -> Dictionary: + var screen: Variant = _require_current_screen(app, "system") + if screen == null: + return _not_current_screen_error(app, "system") + var body_id: String = str(params.get("body_id", "")) + var body: Dictionary = screen.find_body(body_id) + if body.is_empty(): + return {"ok": false, "error": "unrecognized body_id '%s'" % body_id} + if body.get("terrain_reference") == null: + return {"ok": false, "error": "body '%s' has no terrain reference" % body_id} + screen.open_body_by_id(body_id) + return {"ok": true} + + ## `scroll_rung` — direction is required; cursor_local defaults to a ## reasonable canvas-center guess (Vector2(400, 300), matching this cluster's ## own gdUnit test fixtures' convention, see test_step_canvas_viewer.gd) since ## an agent driver has no real cursor position to anchor on. static func _act_scroll_rung(app: Node, params: Dictionary) -> Dictionary: - var viewer: Variant = _get_viewer(app) - if viewer == null: - return {"ok": false, "error": "regional screen not active"} + var screen: Variant = _require_current_screen(app, "regional") + if screen == null: + return _not_current_screen_error(app, "regional") + var viewer: Variant = screen.get_viewer() var direction: int = int(params.get("direction", 1)) var cursor_raw: Variant = params.get("cursor_local") var cursor_local: Vector2 = ( @@ -225,21 +313,24 @@ static func _act_scroll_rung(app: Node, params: Dictionary) -> Dictionary: ## every other navigation intent uses — no parallel request-building code ## exists in this file. static func _act_jump_to_center(app: Node, params: Dictionary) -> Dictionary: - var viewer: Variant = _get_viewer(app) - if viewer == null: - return {"ok": false, "error": "regional screen not active"} + var screen: Variant = _require_current_screen(app, "regional") + if screen == null: + return _not_current_screen_error(app, "regional") var center_raw: Variant = params.get("world_center") if not (center_raw is Array and (center_raw as Array).size() >= 2): return {"ok": false, "error": "jump_to_center requires world_center: [x, y]"} + var viewer: Variant = screen.get_viewer() var world_center := Vector2(float(center_raw[0]), float(center_raw[1])) var rung: String = str(params.get("rung", "")) viewer.jump_to(world_center, rung) return {"ok": true, "rung": viewer.get_held_rung(), "world_center": [world_center.x, world_center.y]} -## Shared "regional" screen -> StepCanvasViewer accessor — returns null if -## "regional" isn't the registered screen (defensive; every intent that needs -## the viewer checks this rather than assuming the screen tree shape). +## Shared "regional" screen -> StepCanvasViewer accessor, used ONLY by +## observe() (which already knows "regional" is current — it matched on +## screen_id itself — so it doesn't need _require_current_screen()'s guard, +## just a null-safe read of a screen that may not even be registered yet +## early in app lifecycle). static func _get_viewer(app: Node) -> Variant: var regional_screen: Node = app.get_screen("regional") if regional_screen == null: diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 8942f2f98..48ff1b018 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -127,11 +127,7 @@ func _handle_enter() -> void: nav.replace("system", {"mode": "orbital", "system": sys}) elif _system_screen and _system_screen.has_body_panel_open(): var body: Dictionary = _system_screen.get_selected_body() - if body.get("terrain_reference") != null: - nav.push("regional", { - "body": body, - "system": nav.current_payload().get("system", {}), - }) + _on_body_selected(body) # ============================================================================= @@ -147,7 +143,28 @@ func _on_system_selected(system_id: String) -> void: nav.push("system", {"mode": "orbital", "system": system}) +## PR #209 review (Hoshe finding 3, lead ruling): the SHARED tail every path +## that "opens" a body funnels through — double-click (SystemScreen. +## body_selected), the Enter-key path (_handle_enter()'s "system" branch, +## which used to gate on terrain_reference itself and now just calls this), +## and AtlasAgentInterface's open_body/open_body_by_id intent (also via this +## same signal). Gating HERE, once, closes a pre-existing divergence: the +## Enter-key path already required `terrain_reference != null` before this +## PR, but double-click (and therefore the new open_body intent) did not — +## both silently pushed "regional" for a body with no heightmap. Formalizing +## the intent contract is what surfaced it, so this PR fixes it for both +## callers at once rather than reintroducing the same split. +## +## A guarded body (no terrain_reference) is a silent no-op here — same +## behavior the Enter path always had (it simply never called nav.push() for +## that case). AtlasAgentInterface's open_body/open_body_by_id wrap this at +## the intent layer with a STRUCTURED error instead of a silent no-op (see +## atlas_agent_interface.gd's own _act_open_body()) — an agent driver must be +## able to tell "nothing happened" from "call succeeded", which a bare click +## has no way to report either way. func _on_body_selected(body: Dictionary) -> void: + if body.get("terrain_reference") == null: + return nav.push("regional", { "body": body, "system": nav.current_payload().get("system", {}), diff --git a/client/ui/implant/apps/atlas/screens/system_screen.gd b/client/ui/implant/apps/atlas/screens/system_screen.gd index f069800c3..7c5b65e32 100644 --- a/client/ui/implant/apps/atlas/screens/system_screen.gd +++ b/client/ui/implant/apps/atlas/screens/system_screen.gd @@ -427,8 +427,14 @@ func select_body_by_id(body_id: String) -> void: ## T-971 (AtlasAgentInterface `open_body` intent) — the SAME state mutation ## _handle_orbital_double_click() performs (double-click equivalent: emits -## body_selected, which AtlasApp._on_body_selected() turns into -## nav.push("regional", ...)). A no-op for an unrecognized id. +## body_selected, which AtlasApp._on_body_selected() now gates on +## terrain_reference — PR #209 review, Hoshe finding 3 — before pushing +## "regional"). A no-op for an unrecognized id. Emits unconditionally for a +## RECOGNIZED body regardless of terrain_reference, same as the double-click +## handler always has — the guard lives in the shared _on_body_selected() +## tail, not here, so this stays a pure "found it, emit" lookup identical to +## the click path (AtlasAgentInterface does its OWN pre-check via +## find_body() to report a structured error instead of a silent no-op). func open_body_by_id(body_id: String) -> void: for b: Dictionary in _orbital_bodies: if str(b.get("body_id", "")) == body_id: @@ -436,6 +442,20 @@ func open_body_by_id(body_id: String) -> void: return +## Generic body lookup by id — {} if not found. Shared read used by both +## select_body_by_id()/open_body_by_id()'s own linear scans (kept inline in +## each for now, matching this file's existing per-method scan style) and by +## AtlasAgentInterface's open_body pre-check (PR #209 review, Hoshe finding +## 3) so the intent layer can build a structured "no terrain reference" +## error without re-deriving the emit-vs-no-op logic already owned by +## open_body_by_id() above. +func find_body(body_id: String) -> Dictionary: + for b: Dictionary in _orbital_bodies: + if str(b.get("body_id", "")) == body_id: + return b + return {} + + func _close_detail_panels() -> void: _selected_body = {} _selected_station = {} From 6f63cb6f70a0a19c57fcc2ee603ae62f95c76559 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 19:30:29 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(ui):=20agent=20driver=20honors=20SR=5FP?= =?UTF-8?q?ORT=20=E2=80=94=20the=20eyeball's=20own=20finding=20(T-971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel eyeball caught the committed reference driver silently relying on SimBridge's hardcoded default port, unlike every sibling real-render driver — a caller starting the server on a chosen port got 20 silent connect retries against the wrong port and a hollow session whose results had valid shapes but no data. Mirrors visual_capture.gd's convention exactly: SR_LIVE=1 without SR_PORT is a hard error; SR_PORT sets SimBridge.server_port before boot. Co-Authored-By: Claude Fable 5 --- client/tests/atlas_agent_driver.gd | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/tests/atlas_agent_driver.gd b/client/tests/atlas_agent_driver.gd index 8b1225e06..50e3386f6 100644 --- a/client/tests/atlas_agent_driver.gd +++ b/client/tests/atlas_agent_driver.gd @@ -99,6 +99,25 @@ func _run() -> void: quit(1) return + # SR_PORT wiring (PR #209 eyeball finding) — mirror visual_capture.gd's + # live-mode convention exactly: every sibling real-render driver + # (visual_capture, atlas_standalone, locomotion_sandbox) reads SR_PORT + # rather than silently relying on SimBridge's hardcoded default port; + # without this, a caller who started the server on a chosen port (the + # run-visual --port pattern) gets 20 silent connect retries against the + # wrong port and a hollow session whose act() results still have valid + # SHAPES but no data behind them. SR_LIVE=1 without SR_PORT is a hard + # error, same as visual_capture.gd. + if OS.get_environment("SR_LIVE") == "1": + var port_env := OS.get_environment("SR_PORT") + if port_env.is_empty(): + push_error("atlas_agent_driver: SR_LIVE=1 but SR_PORT not set") + quit(1) + return + var sim_bridge := root.get_node("/root/SimBridge") + sim_bridge.server_port = int(port_env) + print("atlas_agent_driver: live mode — server port %d" % sim_bridge.server_port) + var main_scene = load("res://scenes/main.tscn") var main_node = main_scene.instantiate()