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)