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. ## ## **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"} match intent: "open_atlas": HudGroups.open_app("implant/map") return {"ok": true} "close_atlas": HudGroups.close_app() return {"ok": true} "select_system": return _act_on_current_screen( app, "reach", func(s: Node) -> void: s.select_system_by_id(str(params.get("system_id", ""))) ) "open_system": return _act_on_current_screen( app, "reach", func(s: Node) -> void: s.open_system_by_id(str(params.get("system_id", ""))) ) "select_body": return _act_on_current_screen( app, "system", func(s: Node) -> void: s.select_body_by_id(str(params.get("body_id", ""))) ) "open_body": 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": return _act_on_current_screen( app, "regional", func(s: Node) -> void: s.get_viewer()._reset_to_global() ) "set_overlay": 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)) ) ) "back": app.nav.pop() return {"ok": true} _: 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 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 = ( 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 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, 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: return null return regional_screen.get_viewer()