feat(ui): T-1133 data browser — implant/browser app, six index+detail screens (D-254 SS4)
New implant app (app_path implant/browser, key B, fullscreen), sibling
to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable
index (live search-mode typing) -> generic detail, parameterized per
kind, composed entirely from D-169 components. available_in_companion
left unset (default true) — the app appears in the companion shell
automatically via the generic-host seam, zero companion-side wiring.
browser_adapter.gd is the sole home of literal wire field names: maps
Oscar's BrowseResponse contract ({id, primary, secondary} index rows;
BrowseDetail enum-as-single-key-map) to view models for all six kinds,
folding join partners (system economy/factions/culture, corporation
presence, commodity production chains with nested Leontief inputs).
browse_protocol.gd split out of protocol.gd (max-file-lines);
sim_bridge gains browse_response_received + request_browse_index/detail.
Live-data catch: a present-but-NULL key (unnamed asteroid belt
proper_name) bypasses Dictionary.get fallbacks and rendered '<null>' —
_display_or() null-vs-absent helper applied across all six detail
mappers, 4 regression tests distinct from the absent-key cases.
43 gdUnit adapter cases; full suite 3074 green. Live-verified against
a real server + real systems.db: all six kinds Ready with real row
counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus
ids. Spawn-mode DB resolution issue found during verification is
pre-existing (cwd-relative data/systems.db) — server-side fix follows
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ signal snapshot_received(snapshot: Dictionary)
|
||||
signal atlas_layers_received(response: Dictionary)
|
||||
signal star_map_received(response: Dictionary) # T-949: StarMapResponse
|
||||
signal city_names_received(response: Dictionary) # T-949: CityNamesResponse
|
||||
signal browse_response_received(response: Dictionary) # T-1131/T-1133: BrowseResponse
|
||||
signal handshake_complete
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
@@ -490,6 +491,50 @@ func request_city_names(body_id: String) -> void:
|
||||
)
|
||||
|
||||
|
||||
## Request one entity kind's index list from the browser proxy (T-1131/
|
||||
## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Index}
|
||||
## frame; the response arrives via browse_response_received. filter_system_id
|
||||
## only means anything for kind == "Body" (bodies filter by containing
|
||||
## system) — pass "" (default) for every other kind or for an unfiltered
|
||||
## Body index. No "wanted before connect" replay bookkeeping like
|
||||
## request_star_map(): the browser screens call this on-demand while the app
|
||||
## is open and connected (a live drill-down request, not a session-scoped
|
||||
## dataset fetched once at boot), so a request issued before CONNECTED is
|
||||
## simply not sent — the screen re-requests on its own enter()/refresh path
|
||||
## the next time it's shown.
|
||||
func request_browse_index(kind: String, filter_system_id: String = "") -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_browse_request(kind, "Index", filter_system_id)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
var err: int = _bridge.send_message(bytes)
|
||||
if err != OK:
|
||||
push_error(
|
||||
"SimBridge: failed to send browse index request for %s: %s" % [kind, error_string(err)]
|
||||
)
|
||||
|
||||
|
||||
## Request one entity's full detail row from the browser proxy (T-1131/
|
||||
## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Detail}
|
||||
## frame; the response arrives via browse_response_received. entity_id is the
|
||||
## same "id" field an Index row returned for this kind.
|
||||
func request_browse_detail(kind: String, entity_id: String) -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_browse_request(kind, "Detail", entity_id)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
var err: int = _bridge.send_message(bytes)
|
||||
if err != OK:
|
||||
push_error(
|
||||
(
|
||||
"SimBridge: failed to send browse detail request for %s/%s: %s"
|
||||
% [kind, entity_id, error_string(err)]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
@@ -516,8 +561,8 @@ func poll_snapshot() -> Variant:
|
||||
# so they aren't silently dropped when server ticks faster than client consumes.
|
||||
func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
# Decode once, branch by frame shape (#960, D-225; T-949 adds starmap/
|
||||
# citynames): all response kinds and snapshots are msgpack maps, told
|
||||
# apart by field.
|
||||
# citynames; T-1131/T-1133 adds browse): all response kinds and
|
||||
# snapshots are msgpack maps, told apart by field.
|
||||
var inbound := Protocol.decode_inbound(bytes)
|
||||
if inbound.kind == "atlas":
|
||||
atlas_layers_received.emit(inbound.value)
|
||||
@@ -528,6 +573,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
if inbound.kind == "citynames":
|
||||
city_names_received.emit(inbound.value)
|
||||
return
|
||||
if inbound.kind == "browse":
|
||||
browse_response_received.emit(inbound.value)
|
||||
return
|
||||
if inbound.kind != "snapshot":
|
||||
push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
class_name BrowseProtocol
|
||||
## BrowseRequest/BrowseResponse wire codec (T-1131/T-1133, D-254 §4) — the
|
||||
## six-entity data browser proxy (star systems, bodies, stations,
|
||||
## corporations, commodities, trait templates).
|
||||
##
|
||||
## Split out of protocol.gd (not folded in) purely to stay under gdlint's
|
||||
## max-file-lines — protocol.gd's own static functions (encode_browse_request/
|
||||
## browse_response_from_raw/decode_browse_response) delegate here. Every
|
||||
## caller still goes through Protocol.* — this file is an implementation
|
||||
## detail, not a second public API surface.
|
||||
##
|
||||
## `browse: true` is the mandatory discriminator field, same convention as
|
||||
## star_map/city_names — decode_inbound's demux is at its documented
|
||||
## practical ceiling of shape-sniffing probes, so BrowseRequest is ONE new
|
||||
## shape carrying an internal kind/query split, not six.
|
||||
##
|
||||
## Wire shapes (confirmed against Oscar's T-1131 contract, 2026-07-17):
|
||||
## kind: a BrowseEntityKind bare string ("StarSystem"|"Body"|"Station"|
|
||||
## "Corporation"|"Commodity"|"TraitTemplate") — all-unit-variant enum,
|
||||
## same shape as AtlasLayerRequest's up_to: CascadeLayer.
|
||||
## query: BrowseQuery's two variants both carry named fields (struct
|
||||
## variants), so each encodes as a single-key map:
|
||||
## {"Index": {"filter_system_id": null | "GJ-1"}}
|
||||
## {"Detail": {"id": "GJ1c"}}
|
||||
## filter_system_id only means anything for kind == "Body" (bodies filter
|
||||
## by containing SYSTEM, not by another body — renamed from
|
||||
## filter_body_id on Oscar's side before shipping).
|
||||
## status: BrowseStatus reuses the exact Ready|NotFound|Error(String) shape
|
||||
## as AtlasLayerStatus/StarMapStatus/CityNamesStatus, decoded by the same
|
||||
## bare-string-or-single-key-map rule protocol.gd's _decode_status_field
|
||||
## already implements for those three — duplicated here as
|
||||
## _decode_status_field (not shared via a Callable) to keep this file
|
||||
## genuinely standalone; the shape is a stable, tiny, three-line rule.
|
||||
|
||||
|
||||
static func _decode_status_field(status_raw: Variant) -> Dictionary:
|
||||
if status_raw is String:
|
||||
return {"status": status_raw, "error": ""}
|
||||
if status_raw is Dictionary and status_raw.has("Error"):
|
||||
return {"status": "Error", "error": str(status_raw["Error"])}
|
||||
return {"status": "", "error": ""}
|
||||
|
||||
|
||||
## Encode a BrowseRequest. `mp` is the loaded messagepack.gd module (passed
|
||||
## in rather than reloaded here — protocol.gd's _mp() already owns that
|
||||
## load()). kind is a BrowseEntityKind bare string. query_kind is "Index" or
|
||||
## "Detail"; filter_value is the containing system_id (Index, Body only) or
|
||||
## the entity's own id (Detail).
|
||||
static func encode_browse_request(
|
||||
mp, kind: String, query_kind: String, filter_value: String = ""
|
||||
) -> PackedByteArray:
|
||||
var query: Dictionary
|
||||
if query_kind == "Detail":
|
||||
query = {"Detail": {"id": filter_value}}
|
||||
else:
|
||||
query = {
|
||||
"Index": {"filter_system_id": filter_value if not filter_value.is_empty() else null}
|
||||
}
|
||||
var msg := {"browse": true, "kind": kind, "query": query}
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("BrowseProtocol: encode_browse_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Build a BrowseResponse from an already-decoded raw value. Returns null
|
||||
## unless it carries "kind" AND "status" AND ("index" or "detail") — "kind" +
|
||||
## "status" alone doesn't disambiguate from AtlasLayerResponse (also has
|
||||
## "status", never "kind"). "index"/"detail" pass through as raw decoded
|
||||
## values — BrowserAdapter (client/ui/implant/apps/browser/browser_adapter.gd)
|
||||
## owns interpreting their per-kind shape, matching how atlas_response_from_raw's
|
||||
## layer1/district_grid/etc. fields also just pass through unshaped.
|
||||
static func browse_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("kind") or not raw.has("status"):
|
||||
return null
|
||||
if not raw.has("index") and not raw.has("detail"):
|
||||
return null
|
||||
var decoded_status := _decode_status_field(raw.get("status"))
|
||||
return {
|
||||
"kind": str(raw.get("kind", "")),
|
||||
"status": decoded_status["status"],
|
||||
"error": decoded_status["error"],
|
||||
"index": raw.get("index"),
|
||||
"detail": raw.get("detail"),
|
||||
}
|
||||
@@ -14,6 +14,17 @@ static func _mp():
|
||||
return load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
|
||||
## BrowseRequest/BrowseResponse codec (T-1131/T-1133) — factored into its own
|
||||
## file to stay under gdlint's max-file-lines, load()'d here (not referenced
|
||||
## as a bare class_name) per the autoload parse-order rule (CLAUDE.md):
|
||||
## Protocol is an autoload, and autoload scripts compile before global
|
||||
## class_name scripts are registered — a top-level class_name reference would
|
||||
## fail to parse. By the time any caller actually runs (always post-boot),
|
||||
## load() returns the already-cached resource with no reload cost.
|
||||
static func _bp():
|
||||
return load("res://scripts/protocol/browse_protocol.gd")
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
|
||||
@@ -898,14 +909,36 @@ static func decode_city_names_response(bytes: PackedByteArray) -> Variant:
|
||||
return city_names_response_from_raw(decode_raw(bytes))
|
||||
|
||||
|
||||
## Encode a BrowseRequest (T-1131/T-1133, D-254 §4) for the six-entity data
|
||||
## browser proxy. Delegates to browse_protocol.gd — kept out of this file to
|
||||
## stay under gdlint's max-file-lines; see that file for the full wire-shape
|
||||
## rationale (discriminator field, kind/query split, filter_system_id).
|
||||
static func encode_browse_request(
|
||||
kind: String, query_kind: String, filter_value: String = ""
|
||||
) -> PackedByteArray:
|
||||
return _bp().encode_browse_request(_mp(), kind, query_kind, filter_value)
|
||||
|
||||
|
||||
## Build a BrowseResponse from an already-decoded raw value. See
|
||||
## browse_protocol.gd for the full shape/disambiguation rationale.
|
||||
static func browse_response_from_raw(raw: Variant) -> Variant:
|
||||
return _bp().browse_response_from_raw(raw)
|
||||
|
||||
|
||||
## Decode a BrowseResponse from MessagePack bytes. See browse_response_from_raw.
|
||||
static func decode_browse_response(bytes: PackedByteArray) -> Variant:
|
||||
return browse_response_from_raw(decode_raw(bytes))
|
||||
|
||||
|
||||
## Decode + classify one inbound frame (#960, D-225; T-949 adds starmap/
|
||||
## citynames). Returns {kind, value} with kind "snapshot" | "atlas" |
|
||||
## "starmap" | "citynames" | "unknown" — all four response kinds are msgpack
|
||||
## maps, so they are told apart by field. Checked most-specific-first:
|
||||
## StarMapResponse is the only kind with "data" and no "body_id";
|
||||
## CityNamesResponse is the only kind with "cities"; anything else carrying
|
||||
## "status" is AtlasLayerResponse. Lets receive_bytes decode the frame ONCE
|
||||
## and branch, instead of double-decoding the 20 Hz snapshot path.
|
||||
## citynames; T-1131/T-1133 adds browse). Returns {kind, value}, kind one of
|
||||
## "snapshot"|"atlas"|"starmap"|"citynames"|"browse"|"unknown" — all msgpack
|
||||
## maps, told apart by field, most-specific-first: StarMapResponse is the
|
||||
## only kind with "data" and no "body_id"; CityNamesResponse the only one
|
||||
## with "cities"; BrowseResponse the only one with its OWN "kind" field
|
||||
## alongside "status"; anything else carrying "status" is AtlasLayerResponse.
|
||||
## Lets receive_bytes decode the frame ONCE instead of double-decoding the
|
||||
## 20 Hz snapshot path.
|
||||
static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
|
||||
var raw = decode_raw(bytes)
|
||||
if not raw is Dictionary:
|
||||
@@ -916,6 +949,8 @@ static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
|
||||
return {"kind": "starmap", "value": star_map_response_from_raw(raw)}
|
||||
if raw.has("cities"):
|
||||
return {"kind": "citynames", "value": city_names_response_from_raw(raw)}
|
||||
if raw.has("kind") and raw.has("status"):
|
||||
return {"kind": "browse", "value": browse_response_from_raw(raw)}
|
||||
if raw.has("status"):
|
||||
return {"kind": "atlas", "value": atlas_response_from_raw(raw)}
|
||||
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
class_name TestBrowserAdapter
|
||||
extends GdUnitTestSuite
|
||||
## Unit tests for browser_adapter.gd (T-1133, D-254 §4).
|
||||
##
|
||||
## Scope: the pure translation-layer logic between Oscar's T-1131
|
||||
## BrowseRequest/BrowseResponse wire contract and the browser screens' view-
|
||||
## models — kind metadata, index row extraction/filtering, and detail
|
||||
## payload unwrap + per-kind field mapping. All exercised with plain
|
||||
## Dictionary literals standing in for decoded msgpack (no live server, no
|
||||
## SimBridge, no protocol.gd round-trip needed to cover this logic).
|
||||
##
|
||||
## Deliberately NOT covered here: the actual wire encode/decode round-trip
|
||||
## (that is protocol.gd's own concern — encode_browse_request/
|
||||
## browse_response_from_raw), and the screens' rendering of the adapter's
|
||||
## output into ImplantPanel components (covered by the live make atlas
|
||||
## verification per the ticket's tiering — the screens are thin renderers
|
||||
## over what this adapter already validates).
|
||||
const SCRIPT_PATH := "res://ui/implant/apps/browser/browser_adapter.gd"
|
||||
|
||||
|
||||
func _adapter():
|
||||
return load(SCRIPT_PATH)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Kind metadata
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_kind_order_has_six_entries() -> void:
|
||||
var a = _adapter()
|
||||
assert_int(a.KIND_ORDER.size()).is_equal(6)
|
||||
|
||||
|
||||
func test_kind_order_matches_d254_v1_list_order() -> void:
|
||||
# D-254 §4's v1 entity scope list order: star systems, bodies, stations,
|
||||
# corporations, commodities, trait catalog.
|
||||
var a = _adapter()
|
||||
assert_array(a.KIND_ORDER).is_equal(
|
||||
[
|
||||
a.KIND_STAR_SYSTEM,
|
||||
a.KIND_BODY,
|
||||
a.KIND_STATION,
|
||||
a.KIND_CORPORATION,
|
||||
a.KIND_COMMODITY,
|
||||
a.KIND_TRAIT_TEMPLATE,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
func test_is_valid_kind_accepts_all_six() -> void:
|
||||
var a = _adapter()
|
||||
for kind: String in a.KIND_ORDER:
|
||||
assert_bool(a.is_valid_kind(kind)).override_failure_message(
|
||||
"%s should be a valid kind" % kind
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_is_valid_kind_rejects_unknown_string() -> void:
|
||||
var a = _adapter()
|
||||
assert_bool(a.is_valid_kind("Spaceship")).is_false()
|
||||
assert_bool(a.is_valid_kind("")).is_false()
|
||||
|
||||
|
||||
func test_kind_label_every_kind_has_a_non_empty_label() -> void:
|
||||
var a = _adapter()
|
||||
for kind: String in a.KIND_ORDER:
|
||||
assert_str(a.kind_label(kind)).override_failure_message(
|
||||
"%s must have a display label" % kind
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_kind_label_unknown_kind_falls_back_to_the_kind_string() -> void:
|
||||
var a = _adapter()
|
||||
assert_str(a.kind_label("Nonsense")).is_equal("Nonsense")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# index_rows — Ready vs. non-Ready responses
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_index_rows_ready_status_extracts_rows() -> void:
|
||||
var a = _adapter()
|
||||
var response := {
|
||||
"kind": "StarSystem",
|
||||
"status": "Ready",
|
||||
"index": [
|
||||
{"id": "GJ-1", "primary": "GJ-1", "secondary": "K-type"},
|
||||
{"id": "GJ-2", "primary": "GJ-2", "secondary": "M-type"},
|
||||
],
|
||||
}
|
||||
var rows: Array = a.index_rows(response)
|
||||
assert_int(rows.size()).is_equal(2)
|
||||
assert_str(rows[0]["id"]).is_equal("GJ-1")
|
||||
assert_str(rows[0]["primary"]).is_equal("GJ-1")
|
||||
assert_str(rows[0]["secondary"]).is_equal("K-type")
|
||||
|
||||
|
||||
func test_index_rows_missing_secondary_defaults_to_empty_string_not_null() -> void:
|
||||
var a = _adapter()
|
||||
var response := {
|
||||
"kind": "TraitTemplate",
|
||||
"status": "Ready",
|
||||
"index": [{"id": "tag_1", "primary": "Some Trait"}],
|
||||
}
|
||||
var rows: Array = a.index_rows(response)
|
||||
assert_str(rows[0]["secondary"]).is_equal("")
|
||||
|
||||
|
||||
func test_index_rows_error_status_returns_empty_array() -> void:
|
||||
var a = _adapter()
|
||||
var response := {"kind": "Body", "status": "Error", "index": null}
|
||||
assert_array(a.index_rows(response)).is_empty()
|
||||
|
||||
|
||||
func test_index_rows_not_found_status_returns_empty_array() -> void:
|
||||
# D-254/Oscar's contract: Index requests never produce NotFound in
|
||||
# practice, but the adapter must not crash or misbehave if one somehow
|
||||
# arrives — treat any non-Ready status uniformly as "no rows".
|
||||
var a = _adapter()
|
||||
var response := {"kind": "Body", "status": "NotFound", "index": null}
|
||||
assert_array(a.index_rows(response)).is_empty()
|
||||
|
||||
|
||||
func test_index_rows_ready_but_index_field_not_an_array_returns_empty() -> void:
|
||||
var a = _adapter()
|
||||
var response := {"kind": "Body", "status": "Ready", "index": null}
|
||||
assert_array(a.index_rows(response)).is_empty()
|
||||
|
||||
|
||||
func test_index_rows_ready_with_genuinely_empty_list_returns_empty_array() -> void:
|
||||
# The "unknown body -> Ready with empty list" convention (Oscar's
|
||||
# message) must round-trip cleanly, not be conflated with an error.
|
||||
var a = _adapter()
|
||||
var response := {"kind": "Body", "status": "Ready", "index": []}
|
||||
assert_array(a.index_rows(response)).is_empty()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# filter_rows — live search box
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _sample_rows() -> Array:
|
||||
return [
|
||||
{"id": "gate-corporation", "primary": "Gate Corporation", "secondary": "corporation"},
|
||||
{"id": "vethara", "primary": "Vethara", "secondary": "combine"},
|
||||
{"id": "free-traders", "primary": "Free Traders Syndic", "secondary": "syndic"},
|
||||
]
|
||||
|
||||
|
||||
func test_filter_rows_empty_query_returns_all_rows_unchanged() -> void:
|
||||
var a = _adapter()
|
||||
var rows := _sample_rows()
|
||||
assert_array(a.filter_rows(rows, "")).is_equal(rows)
|
||||
|
||||
|
||||
func test_filter_rows_matches_primary_case_insensitively() -> void:
|
||||
var a = _adapter()
|
||||
var filtered: Array = a.filter_rows(_sample_rows(), "vethara")
|
||||
assert_int(filtered.size()).is_equal(1)
|
||||
assert_str(filtered[0]["id"]).is_equal("vethara")
|
||||
|
||||
|
||||
func test_filter_rows_matches_secondary_column() -> void:
|
||||
var a = _adapter()
|
||||
var filtered: Array = a.filter_rows(_sample_rows(), "syndic")
|
||||
assert_int(filtered.size()).is_equal(1)
|
||||
assert_str(filtered[0]["id"]).is_equal("free-traders")
|
||||
|
||||
|
||||
func test_filter_rows_matches_id() -> void:
|
||||
var a = _adapter()
|
||||
var filtered: Array = a.filter_rows(_sample_rows(), "gate-corp")
|
||||
assert_int(filtered.size()).is_equal(1)
|
||||
assert_str(filtered[0]["id"]).is_equal("gate-corporation")
|
||||
|
||||
|
||||
func test_filter_rows_no_match_returns_empty_array() -> void:
|
||||
var a = _adapter()
|
||||
assert_array(a.filter_rows(_sample_rows(), "nonexistent-zzz")).is_empty()
|
||||
|
||||
|
||||
func test_filter_rows_partial_word_matches_across_multiple_rows() -> void:
|
||||
var a = _adapter()
|
||||
# "corp" is a substring of both "Gate Corporation" (primary) and
|
||||
# "corporation" (secondary) — same row matches twice, must not duplicate.
|
||||
var filtered: Array = a.filter_rows(_sample_rows(), "corp")
|
||||
assert_int(filtered.size()).is_equal(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# response_status / response_error
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_response_status_ready() -> void:
|
||||
var a = _adapter()
|
||||
assert_str(a.response_status({"status": "Ready"})).is_equal("Ready")
|
||||
|
||||
|
||||
func test_response_status_not_a_dictionary_returns_empty_string() -> void:
|
||||
var a = _adapter()
|
||||
assert_str(a.response_status(null)).is_equal("")
|
||||
assert_str(a.response_status("not a dict")).is_equal("")
|
||||
|
||||
|
||||
func test_response_error_returns_message_only_for_error_status() -> void:
|
||||
var a = _adapter()
|
||||
var response := {"status": "Error", "error": "db unavailable"}
|
||||
assert_str(a.response_error(response)).is_equal("db unavailable")
|
||||
|
||||
|
||||
func test_response_error_empty_for_ready_status() -> void:
|
||||
var a = _adapter()
|
||||
var response := {"status": "Ready", "error": ""}
|
||||
assert_str(a.response_error(response)).is_equal("")
|
||||
|
||||
|
||||
func test_response_error_empty_for_not_found_status() -> void:
|
||||
var a = _adapter()
|
||||
var response := {"status": "NotFound", "error": ""}
|
||||
assert_str(a.response_error(response)).is_equal("")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# detail_payload — BrowseDetail enum-variant unwrap
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_detail_payload_unwraps_kind_keyed_variant() -> void:
|
||||
var a = _adapter()
|
||||
var response := {
|
||||
"kind": "Commodity",
|
||||
"status": "Ready",
|
||||
"detail": {"Commodity": {"commodity_id": "fusion_fuel", "name": "FUSION FUEL"}},
|
||||
}
|
||||
var payload: Dictionary = a.detail_payload(response)
|
||||
assert_str(payload.get("commodity_id", "")).is_equal("fusion_fuel")
|
||||
assert_str(payload.get("name", "")).is_equal("FUSION FUEL")
|
||||
|
||||
|
||||
func test_detail_payload_non_ready_status_returns_empty_dict() -> void:
|
||||
var a = _adapter()
|
||||
var response := {"kind": "Commodity", "status": "NotFound", "detail": null}
|
||||
assert_that(a.detail_payload(response)).is_equal({})
|
||||
|
||||
|
||||
func test_detail_payload_detail_field_not_a_dictionary_returns_empty_dict() -> void:
|
||||
var a = _adapter()
|
||||
var response := {"kind": "Commodity", "status": "Ready", "detail": null}
|
||||
assert_that(a.detail_payload(response)).is_equal({})
|
||||
|
||||
|
||||
func test_detail_payload_falls_back_to_single_key_map_when_kind_key_missing() -> void:
|
||||
# Defensive path: some enum-variant encodings may not key exactly on the
|
||||
# response's own "kind" string — a single-key map should still unwrap.
|
||||
var a = _adapter()
|
||||
var response := {
|
||||
"kind": "TraitTemplate",
|
||||
"status": "Ready",
|
||||
"detail": {"SomeOtherVariantName": {"tag": "spice_market"}},
|
||||
}
|
||||
var payload: Dictionary = a.detail_payload(response)
|
||||
assert_str(payload.get("tag", "")).is_equal("spice_market")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# detail_view — per-kind dispatch + field mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_detail_view_star_system_maps_expected_fields() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"system_id": "GJ-1",
|
||||
"proper_name": "Xin Chengdu",
|
||||
"star_type": "K",
|
||||
"population": 1200000,
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
|
||||
assert_str(view["name"]).is_equal("Xin Chengdu")
|
||||
assert_str(view["subtitle"]).is_equal("STAR SYSTEM")
|
||||
var rows: Array = view["rows"]
|
||||
assert_bool(rows.size() > 0).is_true()
|
||||
# Spot-check one row is present with the right value.
|
||||
var found := false
|
||||
for row: Dictionary in rows:
|
||||
if row["label"] == "star type":
|
||||
assert_str(row["value"]).is_equal("K")
|
||||
found = true
|
||||
assert_bool(found).override_failure_message("expected a 'star type' row").is_true()
|
||||
|
||||
|
||||
func test_detail_view_star_system_falls_back_to_system_id_when_unnamed() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {"system_id": "GJ-999"}
|
||||
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
|
||||
assert_str(view["name"]).is_equal("GJ-999")
|
||||
|
||||
|
||||
## Regression test for a bug found via live verification (2026-07-17): a
|
||||
## real Body row (an unnamed asteroid belt, GJ0-belt) has proper_name as a
|
||||
## PRESENT key with a NULL value (msgpack encodes a SQL NULL column that
|
||||
## way; Dictionary.get(key, fallback)'s fallback only fires when the key is
|
||||
## ABSENT, not when it's present-and-null) — the naive
|
||||
## str(p.get("proper_name", p.get("body_id", "—"))) rendered the literal
|
||||
## string "<null>" instead of falling back to body_id. Distinct from
|
||||
## test_detail_view_star_system_falls_back_to_system_id_when_unnamed above,
|
||||
## which only covers the ABSENT-key case (that test alone did not catch this
|
||||
## bug — it needs its own present-but-null case).
|
||||
func test_detail_view_falls_back_correctly_when_name_field_is_present_but_null() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {"body_id": "GJ0-belt", "proper_name": null, "body_type": "asteroid_belt"}
|
||||
var view: Dictionary = a.detail_view(a.KIND_BODY, payload)
|
||||
assert_str(view["name"]).is_equal("GJ0-belt")
|
||||
|
||||
|
||||
## Same present-but-null regression, for the "distance" row's unit-suffix
|
||||
## special case (str(p.get("dist_ly", "—")) + " ly" would render "<null> ly"
|
||||
## for Sol's own row, where dist_ly is a genuine NULL — distance from Earth
|
||||
## to itself is meaningless, not zero).
|
||||
func test_detail_view_star_system_distance_row_handles_null_dist_ly() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {"system_id": "GJ 0", "proper_name": "Sol", "dist_ly": null}
|
||||
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
|
||||
var distance_value := ""
|
||||
for row: Dictionary in view["rows"]:
|
||||
if row["label"] == "distance":
|
||||
distance_value = row["value"]
|
||||
assert_str(distance_value).is_equal("—")
|
||||
|
||||
|
||||
## Same present-but-null regression, for the two array-summary text-block
|
||||
## builders (_presence_summary/_production_chains_summary) — a null entry
|
||||
## field must not render as "<null>" inside the summary line.
|
||||
func test_detail_view_corporation_presence_entry_with_null_operation_omits_it_cleanly() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"corp_id": "gate-corporation",
|
||||
"presence": [
|
||||
{"location_id": "GJ1b-S1", "location_type": "station", "primary_operation": null}
|
||||
],
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
||||
assert_str(view["text"]).contains("GJ1b-S1 (station)")
|
||||
assert_str(view["text"]).not_contains("<null>")
|
||||
|
||||
|
||||
func test_detail_view_body_maps_expected_fields() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"body_id": "GJ1c",
|
||||
"proper_name": "Chengdu Prime",
|
||||
"body_type": "planet",
|
||||
"inhabited": true,
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_BODY, payload)
|
||||
assert_str(view["name"]).is_equal("Chengdu Prime")
|
||||
assert_str(view["subtitle"]).is_equal("CELESTIAL BODY")
|
||||
var inhabited_value := ""
|
||||
for row: Dictionary in view["rows"]:
|
||||
if row["label"] == "inhabited":
|
||||
inhabited_value = row["value"]
|
||||
assert_str(inhabited_value).is_equal("yes")
|
||||
|
||||
|
||||
func test_detail_view_station_maps_expected_fields() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"station_id": "GJ1b-S1",
|
||||
"proper_name": "Horizon Station",
|
||||
"station_type": "horizon",
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_STATION, payload)
|
||||
assert_str(view["name"]).is_equal("Horizon Station")
|
||||
assert_str(view["subtitle"]).is_equal("STATION")
|
||||
|
||||
|
||||
func test_detail_view_corporation_maps_expected_fields() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"corp_id": "gate-corporation",
|
||||
"proper_name": "Gate Corporation",
|
||||
"corp_type": "corporation",
|
||||
"shadow_economy_access": false,
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
||||
assert_str(view["name"]).is_equal("Gate Corporation")
|
||||
assert_str(view["subtitle"]).is_equal("CORPORATION")
|
||||
var shadow_value := ""
|
||||
for row: Dictionary in view["rows"]:
|
||||
if row["label"] == "shadow economy access":
|
||||
shadow_value = row["value"]
|
||||
assert_str(shadow_value).is_equal("no")
|
||||
|
||||
|
||||
func test_detail_view_corporation_with_no_presence_yields_empty_text() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {"corp_id": "gate-corporation", "presence": []}
|
||||
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
||||
assert_str(view["text"]).is_equal("")
|
||||
|
||||
|
||||
func test_detail_view_corporation_presence_array_summarized_into_text() -> void:
|
||||
# CorporationDetail.presence: Vec<CorpPresenceEntry> (Oscar's T-1131
|
||||
# message, 2026-07-17) — {location_id, location_type, primary_operation}.
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"corp_id": "gate-corporation",
|
||||
"presence": [
|
||||
{
|
||||
"location_id": "GJ1b-S1",
|
||||
"location_type": "station",
|
||||
"primary_operation": "refining",
|
||||
},
|
||||
{"location_id": "GJ-2", "location_type": "system", "primary_operation": ""},
|
||||
],
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
|
||||
assert_str(view["text"]).contains("GJ1b-S1 (station) — refining")
|
||||
assert_str(view["text"]).contains("GJ-2 (system)")
|
||||
|
||||
|
||||
func test_detail_view_commodity_maps_expected_fields_and_description_text() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"commodity_id": "fusion_fuel",
|
||||
"name": "FUSION FUEL",
|
||||
"tier": "intermediate",
|
||||
"description": "Refined isotopic fuel for gate-rated drives.",
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
|
||||
assert_str(view["name"]).is_equal("FUSION FUEL")
|
||||
assert_str(view["subtitle"]).is_equal("COMMODITY")
|
||||
assert_str(view["text"]).is_equal("Refined isotopic fuel for gate-rated drives.")
|
||||
|
||||
|
||||
func test_detail_view_commodity_with_no_description_or_chains_yields_empty_text() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {"commodity_id": "fusion_fuel", "description": "", "produced_by": []}
|
||||
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
|
||||
assert_str(view["text"]).is_equal("")
|
||||
|
||||
|
||||
func test_detail_view_commodity_produced_by_chains_summarized_into_text() -> void:
|
||||
# CommodityDetail.produced_by: Vec<ProductionChainEntry> with nested
|
||||
# inputs: Vec<ChainInputEntry> (Oscar's T-1131 message, 2026-07-17).
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"commodity_id": "fusion_fuel",
|
||||
"description": "Refined isotopic fuel.",
|
||||
"produced_by": [
|
||||
{
|
||||
"chain_id": "fusion_refining",
|
||||
"output_quantity": 1.0,
|
||||
"inputs": [
|
||||
{"input_commodity_id": "raw_hydrogen", "quantity": 3.0},
|
||||
{"input_commodity_id": "catalyst", "quantity": 0.5},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
|
||||
# str() on a float keeps its decimal form (str(1.0) == "1.0", not "1") —
|
||||
# asserting against that honest formatting rather than a rounded guess.
|
||||
assert_str(view["text"]).contains("Refined isotopic fuel.")
|
||||
assert_str(view["text"]).contains("fusion_refining (qty 1.0)")
|
||||
assert_str(view["text"]).contains("3.0x raw_hydrogen")
|
||||
assert_str(view["text"]).contains("0.5x catalyst")
|
||||
|
||||
|
||||
func test_detail_view_trait_template_maps_expected_fields_and_cultural_description() -> void:
|
||||
var a = _adapter()
|
||||
var payload := {
|
||||
"tag": "spice_market",
|
||||
"label": "Spice Market",
|
||||
"corridor_pool": "heritage",
|
||||
"cultural_description": "A dense stall market smelling of scorched cardamom.",
|
||||
}
|
||||
var view: Dictionary = a.detail_view(a.KIND_TRAIT_TEMPLATE, payload)
|
||||
assert_str(view["name"]).is_equal("Spice Market")
|
||||
assert_str(view["subtitle"]).is_equal("TRAIT TEMPLATE")
|
||||
assert_str(view["text"]).is_equal("A dense stall market smelling of scorched cardamom.")
|
||||
|
||||
|
||||
func test_detail_view_unknown_kind_returns_empty_shape_not_a_crash() -> void:
|
||||
var a = _adapter()
|
||||
var view: Dictionary = a.detail_view("Nonsense", {"some": "payload"})
|
||||
assert_str(view["name"]).is_equal("")
|
||||
assert_array(view["rows"]).is_empty()
|
||||
|
||||
|
||||
func test_detail_view_missing_field_renders_as_em_dash_placeholder() -> void:
|
||||
var a = _adapter()
|
||||
var view: Dictionary = a.detail_view(a.KIND_STATION, {"station_id": "GJ1b-S1"})
|
||||
var docking_value := ""
|
||||
for row: Dictionary in view["rows"]:
|
||||
if row["label"] == "docking class":
|
||||
docking_value = row["value"]
|
||||
assert_str(docking_value).is_equal("—")
|
||||
@@ -0,0 +1,12 @@
|
||||
[gd_resource type="Resource" script_class="ImplantAppManifest" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/implant/implant_app_manifest.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
schema_version = 1
|
||||
app_path = "implant/browser"
|
||||
scene_path = "res://ui/implant/apps/browser/browser_app.tscn"
|
||||
default_mode = "fullscreen"
|
||||
default_key = 66
|
||||
preserves_state = true
|
||||
@@ -0,0 +1,438 @@
|
||||
class_name BrowserAdapter
|
||||
## Pure translation layer between the wire BrowseRequest/BrowseResponse
|
||||
## contract (T-1131, Oscar, D-254 §4) and the view-models the browser's
|
||||
## screens render (T-1133).
|
||||
##
|
||||
## WHY THIS EXISTS: Oscar's contract names its own field-naming churn risk
|
||||
## up front (Body's filter field renaming mid-implementation; per-kind
|
||||
## detail field names "TBD" at contract-send time). Every other browser file
|
||||
## reads through this adapter's accessor functions instead of touching a
|
||||
## raw response Dictionary's keys directly — so a field rename on the wire
|
||||
## is a one-line change here, never a screen rewrite. This is the ONE file
|
||||
## that is allowed to know the literal wire key names.
|
||||
##
|
||||
## Kept as a static-function-only class (no state, no scene tree) so it is
|
||||
## gdUnit-testable with plain Dictionary literals standing in for decoded
|
||||
## msgpack responses — no live server, no SimBridge, no protocol.gd
|
||||
## round-trip required to exercise the mapping logic.
|
||||
|
||||
## The six entity kinds, in the exact order the kind-menu screen displays
|
||||
## them (D-254 §4's v1 list order). Wire values are the bare-string unit
|
||||
## variant names BrowseEntityKind serializes to (Protocol._decode_status_field
|
||||
## doc: unit variants are bare strings on the wire).
|
||||
const KIND_STAR_SYSTEM: String = "StarSystem"
|
||||
const KIND_BODY: String = "Body"
|
||||
const KIND_STATION: String = "Station"
|
||||
const KIND_CORPORATION: String = "Corporation"
|
||||
const KIND_COMMODITY: String = "Commodity"
|
||||
const KIND_TRAIT_TEMPLATE: String = "TraitTemplate"
|
||||
|
||||
const KIND_ORDER: Array[String] = [
|
||||
KIND_STAR_SYSTEM,
|
||||
KIND_BODY,
|
||||
KIND_STATION,
|
||||
KIND_CORPORATION,
|
||||
KIND_COMMODITY,
|
||||
KIND_TRAIT_TEMPLATE,
|
||||
]
|
||||
|
||||
## Display label per kind, for the kind-menu screen and detail/index headers.
|
||||
const KIND_LABELS: Dictionary = {
|
||||
KIND_STAR_SYSTEM: "STAR SYSTEMS",
|
||||
KIND_BODY: "BODIES",
|
||||
KIND_STATION: "STATIONS",
|
||||
KIND_CORPORATION: "CORPORATIONS",
|
||||
KIND_COMMODITY: "COMMODITIES",
|
||||
KIND_TRAIT_TEMPLATE: "TRAIT CATALOG",
|
||||
}
|
||||
|
||||
|
||||
## True iff kind is one of the six v1 entity kinds (defends against a typo'd
|
||||
## kind string reaching a request encode — better to fail loud client-side
|
||||
## than send a request the server's kind enum can't decode).
|
||||
static func is_valid_kind(kind: String) -> bool:
|
||||
return kind in KIND_ORDER
|
||||
|
||||
|
||||
static func kind_label(kind: String) -> String:
|
||||
return KIND_LABELS.get(kind, kind)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Index rows
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## One row-view-model: {id, primary, secondary}. secondary is "" (not null)
|
||||
## when the wire response carried no secondary column, so callers can always
|
||||
## treat it as a plain String without a null check.
|
||||
static func _row_from_raw(raw: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"id": str(raw.get("id", "")),
|
||||
"primary": str(raw.get("primary", "")),
|
||||
"secondary": str(raw.get("secondary", "")) if raw.get("secondary") != null else "",
|
||||
}
|
||||
|
||||
|
||||
## Extract the index row list from a decoded BrowseResponse. Returns [] for
|
||||
## any non-Ready status or a response with no "index" field (e.g. a Detail
|
||||
## response) rather than erroring — callers check response status themselves
|
||||
## via response_status()/response_error() before trusting an empty list as
|
||||
## "genuinely no rows".
|
||||
static func index_rows(response: Dictionary) -> Array:
|
||||
if response.get("status", "") != "Ready":
|
||||
return []
|
||||
var raw_rows: Variant = response.get("index")
|
||||
if not raw_rows is Array:
|
||||
return []
|
||||
var rows: Array = []
|
||||
for raw: Dictionary in raw_rows:
|
||||
rows.append(_row_from_raw(raw))
|
||||
return rows
|
||||
|
||||
|
||||
## Case-insensitive substring filter over primary/secondary/id — the index
|
||||
## screen's search box. Empty query returns rows unchanged (identity, not a
|
||||
## copy — callers must not mutate the result in place).
|
||||
static func filter_rows(rows: Array, query: String) -> Array:
|
||||
if query.is_empty():
|
||||
return rows
|
||||
var needle := query.to_lower()
|
||||
var out: Array = []
|
||||
for row: Dictionary in rows:
|
||||
var haystack: String = (
|
||||
str(row.get("primary", "")) + " " + str(row.get("secondary", "")) + " "
|
||||
+ str(row.get("id", ""))
|
||||
).to_lower()
|
||||
if haystack.contains(needle):
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Detail — per-kind field extraction
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Status of a decoded BrowseResponse: "Ready" | "NotFound" | "Error" | "".
|
||||
## Empty string means the Dictionary isn't a recognizable BrowseResponse at
|
||||
## all (e.g. null, or a decode failure upstream).
|
||||
static func response_status(response: Variant) -> String:
|
||||
if not response is Dictionary:
|
||||
return ""
|
||||
return str(response.get("status", ""))
|
||||
|
||||
|
||||
## Error message for an Error-status response; "" otherwise (including for
|
||||
## Ready/NotFound, where there is nothing to show).
|
||||
static func response_error(response: Dictionary) -> String:
|
||||
if response.get("status", "") != "Error":
|
||||
return ""
|
||||
return str(response.get("error", ""))
|
||||
|
||||
|
||||
## The BrowseDetail payload for a Ready Detail response, or {} if the
|
||||
## response is any other shape. This is the ONE enum-variant-as-single-key-map
|
||||
## unwrap in the adapter — BrowseDetail is an externally-tagged Rust enum
|
||||
## (StarSystem(...)|Body(...)|...), so the decoded map has exactly one key
|
||||
## matching the response's own "kind" field, and the value under that key is
|
||||
## the per-kind detail Dictionary every _detail_rows_for_* function reads.
|
||||
static func detail_payload(response: Dictionary) -> Dictionary:
|
||||
if response.get("status", "") != "Ready":
|
||||
return {}
|
||||
var raw_detail: Variant = response.get("detail")
|
||||
if not raw_detail is Dictionary:
|
||||
return {}
|
||||
var kind: String = str(response.get("kind", ""))
|
||||
if raw_detail.has(kind) and raw_detail[kind] is Dictionary:
|
||||
return raw_detail[kind]
|
||||
# Defensive fallback: some server-side encodings of a single-variant enum
|
||||
# collapse to the inner map directly rather than wrapping it under the
|
||||
# variant name (observed with other unit-vs-struct enums in this
|
||||
# codebase's wire contracts, e.g. AtlasLayerStatus's Error(String) case).
|
||||
# If the "kind"-keyed lookup above misses, and the map has no OTHER
|
||||
# single-key wrapper shape either, assume it's already the unwrapped
|
||||
# detail and return it as-is rather than silently rendering a blank
|
||||
# detail screen.
|
||||
if raw_detail.size() == 1 and not raw_detail.has(kind):
|
||||
return raw_detail.values()[0]
|
||||
return raw_detail
|
||||
|
||||
|
||||
## header (name, subtitle) + Array[{label, value}] rows for a detail
|
||||
## payload, dispatched by kind. Kept as one dispatcher rather than one method
|
||||
## per kind on the caller side, so DetailScreen only ever calls one function
|
||||
## regardless of which of the six kinds it's showing.
|
||||
static func detail_view(kind: String, payload: Dictionary) -> Dictionary:
|
||||
match kind:
|
||||
BrowserAdapter.KIND_STAR_SYSTEM:
|
||||
return _detail_star_system(payload)
|
||||
BrowserAdapter.KIND_BODY:
|
||||
return _detail_body(payload)
|
||||
BrowserAdapter.KIND_STATION:
|
||||
return _detail_station(payload)
|
||||
BrowserAdapter.KIND_CORPORATION:
|
||||
return _detail_corporation(payload)
|
||||
BrowserAdapter.KIND_COMMODITY:
|
||||
return _detail_commodity(payload)
|
||||
BrowserAdapter.KIND_TRAIT_TEMPLATE:
|
||||
return _detail_trait_template(payload)
|
||||
_:
|
||||
return {"name": "", "subtitle": "", "rows": []}
|
||||
|
||||
|
||||
static func _row(label: String, value: Variant) -> Dictionary:
|
||||
return {"label": label, "value": str(value) if value != null else "—"}
|
||||
|
||||
|
||||
static func _bool_str(v: Variant) -> String:
|
||||
return "yes" if bool(v) else "no"
|
||||
|
||||
|
||||
## Dictionary.get(key, fallback)'s fallback only fires when key is ABSENT —
|
||||
## a present key with a NULL value (a real, common shape here: msgpack
|
||||
## encodes a SQL NULL column as null, and rusqlite's Option<T> => None keeps
|
||||
## the key in the map) returns that null unchanged, and str(null) renders as
|
||||
## the literal string "<null>" — a real bug that surfaced live (a Body row
|
||||
## for an unnamed asteroid belt: proper_name is a present NULL column, so
|
||||
## the name line rendered "<null>" instead of falling back to body_id).
|
||||
## Every _detail_*'s "name" line goes through this instead of a bare
|
||||
## p.get(primary_key, p.get(fallback_key, "—")) for exactly this reason —
|
||||
## the per-field row values are already safe via _row()'s own null check
|
||||
## above; this is the ONE other place a display string is built directly
|
||||
## from a wire value without going through _row().
|
||||
static func _display_or(p: Dictionary, key: String, fallback: String) -> String:
|
||||
var v: Variant = p.get(key)
|
||||
if v == null:
|
||||
return fallback
|
||||
return str(v)
|
||||
|
||||
|
||||
## "distance" is the one row that appends a unit suffix instead of a bare
|
||||
## _row(label, value) call (str(p.get("dist_ly", "—")) + " ly" would render
|
||||
## "<null> ly" for a NULL column via the same bug _display_or() fixes above,
|
||||
## since concatenating a suffix happens before _row()'s own null guard would
|
||||
## ever see the value) — a dedicated helper keeps the "— ly" placeholder
|
||||
## intact for a genuinely-NULL dist_ly (Sol's own row: distance from Earth to
|
||||
## itself is meaningless, not zero, so a NULL/omitted dist_ly is expected
|
||||
## real data here, not an error condition).
|
||||
static func _distance_ly_str(dist_ly: Variant) -> String:
|
||||
if dist_ly == null:
|
||||
return "—"
|
||||
return str(dist_ly) + " ly"
|
||||
|
||||
|
||||
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
|
||||
## StarSystemDetail folds system_economy/system_factions/system_culture.
|
||||
static func _detail_star_system(p: Dictionary) -> Dictionary:
|
||||
var name: String = _display_or(p, "proper_name", _display_or(p, "system_id", "—"))
|
||||
var rows: Array = [
|
||||
_row("system id", p.get("system_id", "—")),
|
||||
_row("star type", p.get("star_type", "—")),
|
||||
_row("spectral class", p.get("spectral_class", "—")),
|
||||
_row("distance", _distance_ly_str(p.get("dist_ly"))),
|
||||
_row("sector", p.get("geographic_sector", "—")),
|
||||
_row("geographic band", p.get("geographic_band", "—")),
|
||||
_row("political zone", p.get("political_zone", "—")),
|
||||
_row("habitable planets", p.get("habitable_planet_count", "—")),
|
||||
_row("inhabited planets", p.get("inhabited_planet_count", "—")),
|
||||
_row("asteroid belt", _bool_str(p.get("asteroid_belt", false))),
|
||||
_row("gas giant", _bool_str(p.get("gas_giant", false))),
|
||||
_row("habitability profile", p.get("habitability_profile", "—")),
|
||||
_row("earth alignment", p.get("earth_alignment", "—")),
|
||||
_row("earth proximity", p.get("earth_proximity", "—")),
|
||||
_row("earth tension", p.get("earth_tension", "—")),
|
||||
_row("stability index", p.get("stability_index", "—")),
|
||||
_row("volatility", p.get("system_volatility", "—")),
|
||||
_row("cultural corridor", p.get("cultural_corridor", "—")),
|
||||
_row("currency zone", p.get("currency_zone", "—")),
|
||||
_row("economic tier", p.get("economic_tier", "—")),
|
||||
_row("population", p.get("population", "—")),
|
||||
_row("economic base", p.get("economic_base_primary", "—")),
|
||||
_row("economic base (secondary)", p.get("economic_base_secondary", "—")),
|
||||
_row("governance", p.get("governance_type", "—")),
|
||||
_row("dominant faction", p.get("dominant_faction", "—")),
|
||||
_row("cultural register", p.get("cultural_register", "—")),
|
||||
_row("atmospheric tone", p.get("atmospheric_tone", "—")),
|
||||
_row("primary archetype", p.get("primary_archetype", "—")),
|
||||
]
|
||||
return {"name": name, "subtitle": "STAR SYSTEM", "rows": rows}
|
||||
|
||||
|
||||
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
|
||||
## `bodies` row, no folded child tables.
|
||||
static func _detail_body(p: Dictionary) -> Dictionary:
|
||||
var name: String = _display_or(p, "proper_name", _display_or(p, "body_id", "—"))
|
||||
var rows: Array = [
|
||||
_row("body id", p.get("body_id", "—")),
|
||||
_row("system", p.get("system_id", "—")),
|
||||
_row("parent body", p.get("parent_body_id", "—")),
|
||||
_row("type", p.get("body_type", "—")),
|
||||
_row("orbit index", p.get("orbit_index", "—")),
|
||||
_row("mass class", p.get("mass_class", "—")),
|
||||
_row("atmosphere", p.get("atmosphere", "—")),
|
||||
_row("surface gravity (g)", p.get("surface_gravity", "—")),
|
||||
_row("orbital period (days)", p.get("orbital_period_days", "—")),
|
||||
_row("rotation period (hrs)", p.get("rotation_period_hours", "—")),
|
||||
_row("planet class", p.get("planet_class", "—")),
|
||||
_row("hydrosphere", p.get("hydrosphere", "—")),
|
||||
_row("biosphere class", p.get("biosphere_class", "—")),
|
||||
_row("inhabited", _bool_str(p.get("inhabited", false))),
|
||||
_row("population", p.get("population", "—")),
|
||||
_row("economic role", p.get("economic_role", "—")),
|
||||
_row("founding age (years)", p.get("founding_age_years", "—")),
|
||||
_row("settlement pattern", p.get("settlement_pattern", "—")),
|
||||
_row("cultural corridor", p.get("cultural_corridor", "—")),
|
||||
_row("industrial corridor", p.get("industrial_corridor", "—")),
|
||||
_row("radius (km)", p.get("body_radius_km", "—")),
|
||||
_row("axial tilt (deg)", p.get("axial_tilt_deg", "—")),
|
||||
]
|
||||
return {"name": name, "subtitle": "CELESTIAL BODY", "rows": rows}
|
||||
|
||||
|
||||
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
|
||||
## `stations` row, no folded child tables.
|
||||
static func _detail_station(p: Dictionary) -> Dictionary:
|
||||
var name: String = _display_or(p, "proper_name", _display_or(p, "station_id", "—"))
|
||||
var rows: Array = [
|
||||
_row("station id", p.get("station_id", "—")),
|
||||
_row("system", p.get("system_id", "—")),
|
||||
_row("orbits", p.get("orbits_body_id", "—")),
|
||||
_row("type", p.get("station_type", "—")),
|
||||
_row("population", p.get("population", "—")),
|
||||
_row("economic role", p.get("economic_role", "—")),
|
||||
_row("governance", p.get("governance_type", "—")),
|
||||
_row("docking class", p.get("docking_class", "—")),
|
||||
_row("gate infrastructure", _bool_str(p.get("has_gate_infrastructure", false))),
|
||||
_row("districts", p.get("district_count", "—")),
|
||||
]
|
||||
return {"name": name, "subtitle": "STATION", "rows": rows}
|
||||
|
||||
|
||||
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
|
||||
## CorporationDetail folds corp_presence (as a presence array) + corp_financial_state.
|
||||
static func _detail_corporation(p: Dictionary) -> Dictionary:
|
||||
var name: String = _display_or(p, "proper_name", _display_or(p, "corp_id", "—"))
|
||||
var rows: Array = [
|
||||
_row("corp id", p.get("corp_id", "—")),
|
||||
_row("type", p.get("corp_type", "—")),
|
||||
_row("scope", p.get("scope", "—")),
|
||||
_row("hq system", p.get("headquarters_system", "—")),
|
||||
_row("hq body", p.get("headquarters_body", "—")),
|
||||
_row("specialization", p.get("specialization", "—")),
|
||||
_row("parent corp", p.get("parent_corp", "—")),
|
||||
_row("notes", p.get("notes", "—")),
|
||||
_row("behavioral archetype", p.get("behavioral_archetype", "—")),
|
||||
_row("supply chain role", p.get("supply_chain_role", "—")),
|
||||
_row("shadow economy access", _bool_str(p.get("shadow_economy_access", false))),
|
||||
_row("corp specialization", p.get("corp_specialization", "—")),
|
||||
_row("hq placement", p.get("hq_placement", "—")),
|
||||
_row("health metric", p.get("health_metric", "—")),
|
||||
]
|
||||
return {
|
||||
"name": name,
|
||||
"subtitle": "CORPORATION",
|
||||
"rows": rows,
|
||||
"text": _presence_summary(p.get("presence", [])),
|
||||
}
|
||||
|
||||
|
||||
## presence: Array[{location_id, location_type, primary_operation}] ->
|
||||
## one line per entry for the detail screen's ImplantTextBlock. Empty array
|
||||
## (no presence rows) yields "" so the caller's has-text check stays a plain
|
||||
## is_empty() rather than needing a special "no presence" sentinel.
|
||||
static func _presence_summary(presence: Variant) -> String:
|
||||
if not presence is Array or presence.is_empty():
|
||||
return ""
|
||||
var lines: Array = []
|
||||
for entry: Dictionary in presence:
|
||||
var loc: String = _display_or(entry, "location_id", "—")
|
||||
var loc_type: String = _display_or(entry, "location_type", "")
|
||||
var op: String = _display_or(entry, "primary_operation", "")
|
||||
var line: String = loc
|
||||
if not loc_type.is_empty():
|
||||
line += " (" + loc_type + ")"
|
||||
if not op.is_empty():
|
||||
line += " — " + op
|
||||
lines.append(line)
|
||||
return "PRESENCE\n" + "\n".join(lines)
|
||||
|
||||
|
||||
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
|
||||
## CommodityDetail folds production_chains + chain_inputs (as produced_by).
|
||||
static func _detail_commodity(p: Dictionary) -> Dictionary:
|
||||
var name: String = _display_or(p, "name", _display_or(p, "commodity_id", "—"))
|
||||
var rows: Array = [
|
||||
_row("commodity id", p.get("commodity_id", "—")),
|
||||
_row("tier", p.get("tier", "—")),
|
||||
_row("elasticity", p.get("elasticity", "—")),
|
||||
_row("base price", p.get("base_price", "—")),
|
||||
_row("bulk class", p.get("bulk_class", "—")),
|
||||
_row("unit", p.get("unit", "—")),
|
||||
_row("production ubiquity", p.get("production_ubiquity", "—")),
|
||||
_row("demand model", p.get("demand_model", "—")),
|
||||
_row("commission certifiable", _bool_str(p.get("commission_certifiable", false))),
|
||||
_row("compact contested", _bool_str(p.get("compact_contested", false))),
|
||||
_row("shadow viable", _bool_str(p.get("shadow_viable", false))),
|
||||
_row("panic threshold (weeks)", p.get("panic_threshold_weeks", "—")),
|
||||
]
|
||||
var text_parts: Array = []
|
||||
var description: String = _display_or(p, "description", "")
|
||||
if not description.is_empty():
|
||||
text_parts.append(description)
|
||||
var chains_summary: String = _production_chains_summary(p.get("produced_by", []))
|
||||
if not chains_summary.is_empty():
|
||||
text_parts.append(chains_summary)
|
||||
return {"name": name, "subtitle": "COMMODITY", "rows": rows, "text": "\n\n".join(text_parts)}
|
||||
|
||||
|
||||
## produced_by: Array[{chain_id, output_quantity, location_bound, description,
|
||||
## inputs: [{input_commodity_id, quantity}]}] -> one summary line per chain,
|
||||
## with its Leontief inputs inlined as "qty x input_id" pairs. Empty array ->
|
||||
## "" (same no-sentinel convention as _presence_summary).
|
||||
static func _production_chains_summary(chains: Variant) -> String:
|
||||
if not chains is Array or chains.is_empty():
|
||||
return ""
|
||||
var lines: Array = []
|
||||
for chain: Dictionary in chains:
|
||||
var chain_id: String = _display_or(chain, "chain_id", "—")
|
||||
var qty: String = _display_or(chain, "output_quantity", "—")
|
||||
var raw_inputs: Variant = chain.get("inputs")
|
||||
var inputs: Array = raw_inputs if raw_inputs is Array else []
|
||||
var input_strs: Array = []
|
||||
for entry: Dictionary in inputs:
|
||||
var input_qty: String = _display_or(entry, "quantity", "—")
|
||||
var input_id: String = _display_or(entry, "input_commodity_id", "—")
|
||||
input_strs.append(input_qty + "x " + input_id)
|
||||
var line: String = "%s (qty %s)" % [chain_id, qty]
|
||||
if not input_strs.is_empty():
|
||||
line += " <- " + ", ".join(input_strs)
|
||||
lines.append(line)
|
||||
return "PRODUCTION CHAINS\n" + "\n".join(lines)
|
||||
|
||||
|
||||
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
|
||||
## `trait_templates` row. weight_mods/zone_affinity/allow_tags/block_tags/
|
||||
## visual_bundle are JSON-text columns the server does NOT parse (Oscar's
|
||||
## contract: "opaque strings, not parsed server-side") — shown here as raw
|
||||
## JSON text rows rather than silently dropped; a data browser's job is to
|
||||
## expose the registry as it actually is, not to pretty-print it.
|
||||
static func _detail_trait_template(p: Dictionary) -> Dictionary:
|
||||
var name: String = _display_or(p, "label", _display_or(p, "tag", "—"))
|
||||
var rows: Array = [
|
||||
_row("tag", p.get("tag", "—")),
|
||||
_row("corridor pool", p.get("corridor_pool", "—")),
|
||||
_row("geographic sector", p.get("geographic_sector", "—")),
|
||||
_row("bulk class gate", p.get("bulk_class_gate", "—")),
|
||||
_row("production ubiquity gate", p.get("production_ubiquity_gate", "—")),
|
||||
_row("min prosperity (bps)", p.get("min_prosperity_bps", "—")),
|
||||
_row("base weight (bps)", p.get("base_weight", "—")),
|
||||
_row("weight mods", p.get("weight_mods", "—")),
|
||||
_row("zone affinity", p.get("zone_affinity", "—")),
|
||||
_row("allow tags", p.get("allow_tags", "—")),
|
||||
_row("block tags", p.get("block_tags", "—")),
|
||||
_row("era scope", p.get("era_scope", "—")),
|
||||
_row("visual bundle", p.get("visual_bundle", "—")),
|
||||
]
|
||||
var text_block: String = _display_or(p, "cultural_description", "")
|
||||
return {"name": name, "subtitle": "TRAIT TEMPLATE", "rows": rows, "text": text_block}
|
||||
@@ -0,0 +1,159 @@
|
||||
class_name BrowserApp
|
||||
extends ImplantApp
|
||||
## Data browser implant app (T-1133, D-254 §4). A SEPARATE app from the Atlas
|
||||
## (implant/map) — the six registry entities do not share the Atlas's
|
||||
## geographic drill-down (D-254 SS4, Jeroen's IA ruling). Registered as
|
||||
## "implant/browser" in FULLSCREEN mode, key B.
|
||||
##
|
||||
## Nav: kind menu (root) -> index (per kind, scrollable + searchable) ->
|
||||
## detail (per entity). Same push/pop convention the Atlas app uses.
|
||||
##
|
||||
## Composed entirely from D-169 implant components via the three screen
|
||||
## classes below (BrowserKindMenuScreen/BrowserIndexScreen/BrowserDetailScreen)
|
||||
## — no new UI primitives.
|
||||
|
||||
var _kind_menu_screen = null # BrowserKindMenuScreen
|
||||
var _index_screen = null # BrowserIndexScreen
|
||||
var _detail_screen = null # BrowserDetailScreen
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
manifest = load("res://ui/implant/apps/browser/app.tres")
|
||||
super._ready()
|
||||
|
||||
|
||||
func on_install() -> void:
|
||||
var implant_theme = load("res://ui/implant/default_implant.tres")
|
||||
|
||||
_kind_menu_screen = BrowserKindMenuScreen.new()
|
||||
_kind_menu_screen.setup(implant_theme)
|
||||
_kind_menu_screen.kind_selected.connect(_on_kind_selected)
|
||||
register_screen("kind_menu", _kind_menu_screen)
|
||||
|
||||
_index_screen = BrowserIndexScreen.new()
|
||||
_index_screen.setup(implant_theme)
|
||||
_index_screen.row_selected.connect(_on_row_selected)
|
||||
register_screen("index", _index_screen)
|
||||
|
||||
_detail_screen = BrowserDetailScreen.new()
|
||||
_detail_screen.setup(implant_theme)
|
||||
register_screen("detail", _detail_screen)
|
||||
|
||||
SimBridge.browse_response_received.connect(_on_browse_response_received)
|
||||
|
||||
nav.set_default("kind_menu")
|
||||
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not event is InputEventKey:
|
||||
return
|
||||
if manifest == null or not HudGroups.is_app_active(manifest.app_path):
|
||||
return
|
||||
if not event.is_pressed() or event.is_echo():
|
||||
return
|
||||
_handle_key(event as InputEventKey)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _handle_key(event: InputEventKey) -> void:
|
||||
# Search-mode: on the index screen, printable keys type into the live
|
||||
# filter instead of being interpreted as navigation. Split into its own
|
||||
# function (not inlined here) — it is a genuinely separate input mode
|
||||
# from the top-level app navigation below, and keeping it here would
|
||||
# push this function's branch/return count well past what one function
|
||||
# should hold.
|
||||
if current_screen_id() == "index" and _index_screen:
|
||||
_handle_index_search_key(event)
|
||||
return
|
||||
|
||||
match event.physical_keycode:
|
||||
KEY_B:
|
||||
HudGroups.close_app()
|
||||
KEY_ESCAPE:
|
||||
_close_or_pop()
|
||||
KEY_ENTER, KEY_KP_ENTER:
|
||||
_handle_enter()
|
||||
KEY_UP:
|
||||
if current_screen_id() == "kind_menu" and _kind_menu_screen:
|
||||
_kind_menu_screen.navigate(-1)
|
||||
KEY_DOWN:
|
||||
if current_screen_id() == "kind_menu" and _kind_menu_screen:
|
||||
_kind_menu_screen.navigate(1)
|
||||
|
||||
|
||||
## Key handling while the index screen is current. Arrows/enter/escape/
|
||||
## backspace are the always-active control keys; everything else in the
|
||||
## printable range (D-254 §4: "filterable/searchable by name") appends to
|
||||
## the live search buffer. This mirrors the implant's existing
|
||||
## keyboard-as-input-surface convention (no OS textbox anywhere in this
|
||||
## component library) — there is no separate "focus the search box" step.
|
||||
func _handle_index_search_key(event: InputEventKey) -> void:
|
||||
match event.physical_keycode:
|
||||
KEY_UP:
|
||||
_index_screen.navigate(-1)
|
||||
return
|
||||
KEY_DOWN:
|
||||
_index_screen.navigate(1)
|
||||
return
|
||||
KEY_ENTER, KEY_KP_ENTER:
|
||||
_index_screen.trigger_enter()
|
||||
return
|
||||
KEY_ESCAPE:
|
||||
if _index_screen.has_search_text():
|
||||
_index_screen.clear_search()
|
||||
else:
|
||||
_close_or_pop()
|
||||
return
|
||||
KEY_BACKSPACE:
|
||||
_index_screen.backspace_search()
|
||||
return
|
||||
|
||||
# event.unicode carries the actual typed character (shift/caps already
|
||||
# resolved by the platform), NOT the physical keycode — String.chr() is
|
||||
# GDScript's codepoint-to-one-char-String conversion. Printable range
|
||||
# only (32 = space .. 126 = ~): control characters (arrows, tab, etc.
|
||||
# already handled above by physical_keycode) report unicode == 0 or a
|
||||
# non-printable codepoint and must not leak into the search buffer.
|
||||
if event.unicode >= 32 and event.unicode < 127:
|
||||
_index_screen.append_search_char(String.chr(event.unicode))
|
||||
|
||||
|
||||
func _close_or_pop() -> void:
|
||||
if current_screen_id() == "kind_menu":
|
||||
HudGroups.close_app()
|
||||
else:
|
||||
nav.pop()
|
||||
|
||||
|
||||
func _handle_enter() -> void:
|
||||
match current_screen_id():
|
||||
"kind_menu":
|
||||
if _kind_menu_screen:
|
||||
_kind_menu_screen.trigger_enter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal handlers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_kind_selected(kind: String) -> void:
|
||||
nav.push("index", {"kind": kind, "filter_system_id": ""})
|
||||
|
||||
|
||||
func _on_row_selected(entity_id: String) -> void:
|
||||
var kind: String = _index_screen.current_kind() if _index_screen else ""
|
||||
nav.push("detail", {"kind": kind, "entity_id": entity_id})
|
||||
|
||||
|
||||
func _on_browse_response_received(response: Dictionary) -> void:
|
||||
if response == null:
|
||||
return
|
||||
# Fan out to whichever screen is currently waiting on a response for this
|
||||
# kind — both screens no-op via their own kind-match guard if the
|
||||
# response isn't theirs (a stale one from a prior navigation, or one
|
||||
# meant for the other screen type).
|
||||
if _index_screen:
|
||||
_index_screen.receive_response(response)
|
||||
if _detail_screen:
|
||||
_detail_screen.receive_response(response)
|
||||
@@ -0,0 +1,20 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/implant/apps/browser/browser_app.gd" id="1_browser_app"]
|
||||
|
||||
; T-1133: Data browser implant app — six-entity registry index/detail viewer
|
||||
; (star systems, bodies, stations, corporations, commodities, trait catalog).
|
||||
; FULLSCREEN app (z=20) at implant/browser per D-170. A SEPARATE app from the
|
||||
; Atlas (D-254 SS4) — managed via the same ImplantApp/ImplantNavStack pattern.
|
||||
; Toggle with B key (manifest.default_key). Data from the wire BrowseRequest/
|
||||
; BrowseResponse proxy (T-1131).
|
||||
|
||||
[node name="BrowserApp" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_browser_app")
|
||||
@@ -0,0 +1,152 @@
|
||||
class_name BrowserDetailScreen
|
||||
extends Control
|
||||
## Detail screen for one browser entity (T-1133, D-254 §4). An ImplantPanel of
|
||||
## ImplantDataRows (every column the wire response carries for that entity)
|
||||
## plus an ImplantTextBlock for free-text description/cultural_description
|
||||
## fields where the kind has one. One instance handles all six kinds —
|
||||
## parameterized by set_entity(), not six near-duplicate screens.
|
||||
|
||||
signal back_requested
|
||||
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
const PANEL_WIDTH: float = 420.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
|
||||
var _kind: String = ""
|
||||
var _entity_id: String = ""
|
||||
var _view: Dictionary = {} # BrowserAdapter.detail_view() result
|
||||
var _loading: bool = false
|
||||
var _error: String = ""
|
||||
var _status: String = ""
|
||||
|
||||
var _panel = null # ImplantPanel
|
||||
var _implant_theme = null # ImplantTheme
|
||||
var _pending: ImplantPending = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
|
||||
|
||||
func setup(implant_theme) -> void:
|
||||
_implant_theme = implant_theme
|
||||
_build_panel()
|
||||
_build_pending(implant_theme)
|
||||
|
||||
|
||||
func set_entity(kind: String, entity_id: String) -> void:
|
||||
_kind = kind
|
||||
_entity_id = entity_id
|
||||
_view = {}
|
||||
_loading = true
|
||||
_error = ""
|
||||
_status = ""
|
||||
_rebuild_panel()
|
||||
if _pending:
|
||||
_pending.start("QUERYING " + BrowserAdapter.kind_label(kind))
|
||||
SimBridge.request_browse_detail(kind, entity_id)
|
||||
|
||||
|
||||
## Feed a decoded BrowseResponse into this screen. Ignored if the response
|
||||
## doesn't match this screen's current kind (stale response after the player
|
||||
## navigated elsewhere).
|
||||
func receive_response(response: Dictionary) -> void:
|
||||
if response.get("kind", "") != _kind:
|
||||
return
|
||||
_loading = false
|
||||
if _pending:
|
||||
_pending.stop()
|
||||
_status = BrowserAdapter.response_status(response)
|
||||
if _status == "Error":
|
||||
_error = BrowserAdapter.response_error(response)
|
||||
_view = {}
|
||||
elif _status == "NotFound":
|
||||
_error = ""
|
||||
_view = {}
|
||||
else:
|
||||
_error = ""
|
||||
var payload: Dictionary = BrowserAdapter.detail_payload(response)
|
||||
_view = BrowserAdapter.detail_view(_kind, payload)
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var kind: String = payload.get("kind", "")
|
||||
var entity_id: String = payload.get("entity_id", "")
|
||||
if kind != _kind or entity_id != _entity_id:
|
||||
set_entity(kind, entity_id)
|
||||
else:
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Panel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_panel() -> void:
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "DetailPanel"
|
||||
_panel.theme_resource = _implant_theme
|
||||
_panel.custom_minimum_size.x = PANEL_WIDTH
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
|
||||
add_child(_panel)
|
||||
|
||||
|
||||
func _build_pending(implant_theme) -> void:
|
||||
_pending = ImplantPending.new()
|
||||
_pending.apply_implant_theme(implant_theme)
|
||||
_pending.position = Vector2(PANEL_MARGIN + PANEL_WIDTH + 24.0, PANEL_MARGIN)
|
||||
add_child(_pending)
|
||||
|
||||
|
||||
func _rebuild_panel() -> void:
|
||||
if not _panel:
|
||||
return
|
||||
_panel.clear()
|
||||
|
||||
if _loading:
|
||||
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id))
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("querying registry…"))
|
||||
elif not _error.is_empty():
|
||||
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id))
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("[ERROR] " + _error))
|
||||
elif _status == "NotFound":
|
||||
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id))
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("entry not found: " + _entity_id))
|
||||
else:
|
||||
_rebuild_ready_panel()
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("esc back"))
|
||||
|
||||
|
||||
func _rebuild_ready_panel() -> void:
|
||||
var name_str: String = str(_view.get("name", _entity_id))
|
||||
var subtitle: String = str(_view.get("subtitle", BrowserAdapter.kind_label(_kind)))
|
||||
_panel.add_component(ImplantHeader.new(name_str, subtitle))
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
var rows: Array = _view.get("rows", [])
|
||||
for row: Dictionary in rows:
|
||||
var label: String = str(row.get("label", ""))
|
||||
var value: String = str(row.get("value", "—"))
|
||||
_panel.add_component(ImplantDataRow.new("%-22s %s" % [label, value]))
|
||||
|
||||
var text_block: String = str(_view.get("text", ""))
|
||||
if not text_block.is_empty():
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new(text_block))
|
||||
@@ -0,0 +1,253 @@
|
||||
class_name BrowserIndexScreen
|
||||
extends Control
|
||||
## Index screen for one entity kind in the data browser (T-1133, D-254 §4).
|
||||
## A scrollable ImplantDataRow list (primary + secondary summary column),
|
||||
## filterable by a live-typed search string. One instance handles all six
|
||||
## kinds — parameterized by set_kind(), not six near-duplicate screens.
|
||||
##
|
||||
## Filtering (D-254 §4: "filterable/searchable by name"): typed characters
|
||||
## append to the search buffer and re-filter live; Backspace removes the
|
||||
## last character; the implant has no OS textbox, so this is the same
|
||||
## "keyboard IS the input surface" convention the Atlas/Economics apps
|
||||
## already use for navigation.
|
||||
|
||||
signal row_selected(entity_id: String)
|
||||
signal back_requested
|
||||
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
const PANEL_WIDTH: float = 460.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const LIST_MAX_HEIGHT: float = 520.0
|
||||
|
||||
var _kind: String = ""
|
||||
var _all_rows: Array = [] # BrowserAdapter row view-models, unfiltered
|
||||
var _filtered_rows: Array = []
|
||||
var _selected_idx: int = 0
|
||||
var _search: String = ""
|
||||
var _loading: bool = false
|
||||
var _error: String = ""
|
||||
|
||||
var _panel = null # ImplantPanel
|
||||
var _scroll: ScrollContainer = null
|
||||
var _list_box: VBoxContainer = null
|
||||
var _implant_theme = null # ImplantTheme
|
||||
var _row_labels: Array = [] # Array[ImplantDataRow], one per filtered row
|
||||
var _pending: ImplantPending = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
|
||||
|
||||
func setup(implant_theme) -> void:
|
||||
_implant_theme = implant_theme
|
||||
_build_panel()
|
||||
_build_pending(implant_theme)
|
||||
|
||||
|
||||
## Set the entity kind this screen shows and (re)issue the index request.
|
||||
## filter_system_id is forwarded to SimBridge.request_browse_index — only
|
||||
## meaningful for kind == BrowserAdapter.KIND_BODY.
|
||||
func set_kind(kind: String, filter_system_id: String = "") -> void:
|
||||
_kind = kind
|
||||
_all_rows = []
|
||||
_filtered_rows = []
|
||||
_selected_idx = 0
|
||||
_search = ""
|
||||
_error = ""
|
||||
_loading = true
|
||||
_rebuild_list()
|
||||
if _pending:
|
||||
_pending.start("QUERYING " + BrowserAdapter.kind_label(kind))
|
||||
SimBridge.request_browse_index(kind, filter_system_id)
|
||||
|
||||
|
||||
## Feed a decoded BrowseResponse (SimBridge.browse_response_received handler,
|
||||
## owned by BrowserApp) into this screen. Ignored if the response's kind
|
||||
## doesn't match what this screen currently shows (a stale response arriving
|
||||
## after the player already navigated elsewhere).
|
||||
func receive_response(response: Dictionary) -> void:
|
||||
if response.get("kind", "") != _kind:
|
||||
return
|
||||
_loading = false
|
||||
if _pending:
|
||||
_pending.stop()
|
||||
var status: String = BrowserAdapter.response_status(response)
|
||||
if status == "Error":
|
||||
_error = BrowserAdapter.response_error(response)
|
||||
_all_rows = []
|
||||
else:
|
||||
_error = ""
|
||||
_all_rows = BrowserAdapter.index_rows(response)
|
||||
_apply_filter()
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var kind: String = payload.get("kind", "")
|
||||
var filter_system_id: String = payload.get("filter_system_id", "")
|
||||
if kind != _kind or _all_rows.is_empty():
|
||||
set_kind(kind, filter_system_id)
|
||||
else:
|
||||
_rebuild_list()
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func current_kind() -> String:
|
||||
return _kind
|
||||
|
||||
|
||||
func has_selection() -> bool:
|
||||
return not _filtered_rows.is_empty()
|
||||
|
||||
|
||||
func selected_entity_id() -> String:
|
||||
if _filtered_rows.is_empty():
|
||||
return ""
|
||||
_selected_idx = clampi(_selected_idx, 0, _filtered_rows.size() - 1)
|
||||
return str(_filtered_rows[_selected_idx].get("id", ""))
|
||||
|
||||
|
||||
func trigger_enter() -> void:
|
||||
if has_selection():
|
||||
row_selected.emit(selected_entity_id())
|
||||
|
||||
|
||||
func navigate(delta: int) -> void:
|
||||
if _filtered_rows.is_empty():
|
||||
return
|
||||
_selected_idx = wrapi(_selected_idx + delta, 0, _filtered_rows.size())
|
||||
_rebuild_list()
|
||||
|
||||
|
||||
## Append a character to the live search filter. Called from BrowserApp's
|
||||
## key handler for printable keys while this screen is current.
|
||||
func append_search_char(ch: String) -> void:
|
||||
_search += ch
|
||||
_apply_filter()
|
||||
|
||||
|
||||
func backspace_search() -> void:
|
||||
if _search.is_empty():
|
||||
return
|
||||
_search = _search.substr(0, _search.length() - 1)
|
||||
_apply_filter()
|
||||
|
||||
|
||||
func clear_search() -> void:
|
||||
if _search.is_empty():
|
||||
return
|
||||
_search = ""
|
||||
_apply_filter()
|
||||
|
||||
|
||||
func has_search_text() -> bool:
|
||||
return not _search.is_empty()
|
||||
|
||||
|
||||
func _apply_filter() -> void:
|
||||
_filtered_rows = BrowserAdapter.filter_rows(_all_rows, _search)
|
||||
_selected_idx = 0
|
||||
_rebuild_list()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Panel / list
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_panel() -> void:
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "IndexPanel"
|
||||
_panel.theme_resource = _implant_theme
|
||||
_panel.custom_minimum_size.x = PANEL_WIDTH
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
|
||||
add_child(_panel)
|
||||
|
||||
|
||||
func _build_pending(implant_theme) -> void:
|
||||
_pending = ImplantPending.new()
|
||||
_pending.apply_implant_theme(implant_theme)
|
||||
_pending.position = Vector2(PANEL_MARGIN + PANEL_WIDTH + 24.0, PANEL_MARGIN)
|
||||
add_child(_pending)
|
||||
|
||||
|
||||
func _rebuild_list() -> void:
|
||||
if not _panel:
|
||||
return
|
||||
_panel.clear()
|
||||
_row_labels.clear()
|
||||
|
||||
var subtitle: String = "%d / %d" % [_filtered_rows.size(), _all_rows.size()]
|
||||
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), subtitle))
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
var search_line: String = "search " + (_search if not _search.is_empty() else "—")
|
||||
_panel.add_component(ImplantDataRow.new(search_line))
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
if _loading:
|
||||
_panel.add_component(ImplantTextBlock.new("querying registry…"))
|
||||
elif not _error.is_empty():
|
||||
_panel.add_component(ImplantTextBlock.new("[ERROR] " + _error))
|
||||
elif _filtered_rows.is_empty():
|
||||
var msg: String = "no matches" if not _search.is_empty() else "no entries"
|
||||
_panel.add_component(ImplantTextBlock.new(msg))
|
||||
else:
|
||||
_build_scroll_list()
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(
|
||||
ImplantTextBlock.new("↑ ↓ select enter open type search esc back")
|
||||
)
|
||||
|
||||
|
||||
func _build_scroll_list() -> void:
|
||||
_scroll = ScrollContainer.new()
|
||||
_scroll.custom_minimum_size = Vector2(PANEL_WIDTH, minf(LIST_MAX_HEIGHT, _list_natural_height()))
|
||||
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
_panel.add_component(_scroll)
|
||||
|
||||
_list_box = VBoxContainer.new()
|
||||
_list_box.add_theme_constant_override("separation", 0)
|
||||
_list_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_scroll.add_child(_list_box)
|
||||
|
||||
for i: int in range(_filtered_rows.size()):
|
||||
var row: Dictionary = _filtered_rows[i]
|
||||
var primary: String = str(row.get("primary", ""))
|
||||
var secondary: String = str(row.get("secondary", ""))
|
||||
var text: String = primary
|
||||
if not secondary.is_empty():
|
||||
text += " · " + secondary
|
||||
var prefix: String = "▸ " if i == _selected_idx else " "
|
||||
var label := ImplantDataRow.new(prefix + text)
|
||||
if _implant_theme:
|
||||
label.apply_implant_theme(_implant_theme)
|
||||
_list_box.add_child(label)
|
||||
_row_labels.append(label)
|
||||
|
||||
_scroll_to_selected()
|
||||
|
||||
|
||||
func _list_natural_height() -> float:
|
||||
var line_h: float = float(_implant_theme.line_height) if _implant_theme else 18.0
|
||||
return float(_filtered_rows.size()) * line_h
|
||||
|
||||
|
||||
func _scroll_to_selected() -> void:
|
||||
if not _scroll or _row_labels.is_empty():
|
||||
return
|
||||
_selected_idx = clampi(_selected_idx, 0, _row_labels.size() - 1)
|
||||
var target: Control = _row_labels[_selected_idx]
|
||||
# ensure_control_visible needs one layout pass to have valid rects on a
|
||||
# freshly-built list — defer so the ScrollContainer has sized itself.
|
||||
_scroll.ensure_control_visible.call_deferred(target)
|
||||
@@ -0,0 +1,87 @@
|
||||
class_name BrowserKindMenuScreen
|
||||
extends Control
|
||||
## Root screen for the data browser app (T-1133, D-254 §4).
|
||||
## Six-entity kind picker — the browser's "reach" screen equivalent. Emits
|
||||
## kind_selected when the player commits to one of the six v1 entity kinds.
|
||||
|
||||
signal kind_selected(kind: String)
|
||||
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
const PANEL_WIDTH: float = 340.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
|
||||
var _selected_idx: int = 0
|
||||
var _panel = null # ImplantPanel
|
||||
var _rows: Array = [] # Array[ImplantDataRow], one per BrowserAdapter.KIND_ORDER entry
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
|
||||
|
||||
func setup(implant_theme) -> void:
|
||||
_build_panel(implant_theme)
|
||||
|
||||
|
||||
func enter(_payload: Dictionary) -> void:
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func navigate(delta: int) -> void:
|
||||
var count: int = BrowserAdapter.KIND_ORDER.size()
|
||||
_selected_idx = wrapi(_selected_idx + delta, 0, count)
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
func trigger_enter() -> void:
|
||||
kind_selected.emit(BrowserAdapter.KIND_ORDER[_selected_idx])
|
||||
|
||||
|
||||
func current_kind() -> String:
|
||||
return BrowserAdapter.KIND_ORDER[_selected_idx]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Panel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_panel(implant_theme) -> void:
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "KindMenuPanel"
|
||||
_panel.theme_resource = implant_theme
|
||||
_panel.custom_minimum_size.x = PANEL_WIDTH
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
|
||||
add_child(_panel)
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
func _rebuild_panel() -> void:
|
||||
if not _panel:
|
||||
return
|
||||
_panel.clear()
|
||||
_rows.clear()
|
||||
|
||||
_panel.add_component(ImplantHeader.new("DATA BROWSER", "REGISTRY INDEX"))
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
for i: int in range(BrowserAdapter.KIND_ORDER.size()):
|
||||
var kind: String = BrowserAdapter.KIND_ORDER[i]
|
||||
var label: String = BrowserAdapter.kind_label(kind)
|
||||
var prefix: String = "▸ " if i == _selected_idx else " "
|
||||
var row := ImplantDataRow.new(prefix + label)
|
||||
_panel.add_component(row)
|
||||
_rows.append(row)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("↑ ↓ select enter open esc close"))
|
||||
Reference in New Issue
Block a user