Files
settled-reach/client/ui/implant/apps/atlas/atlas_agent_interface.gd
T
jpmschweitzerandClaude Fable 5 52304d3e37 fix(ui): PR #209 review round — current-screen guards, pending-aware settle, one body guard (T-971)
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 <noreply@anthropic.com>
2026-07-25 19:14:02 +02:00

339 lines
16 KiB
GDScript

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()