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>
339 lines
16 KiB
GDScript
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()
|