diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index aaaab9765..0f03e41b9 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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 diff --git a/client/scripts/protocol/browse_protocol.gd b/client/scripts/protocol/browse_protocol.gd new file mode 100644 index 000000000..523f3388c --- /dev/null +++ b/client/scripts/protocol/browse_protocol.gd @@ -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"), + } diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 8702fb8eb..1daf962cd 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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)} diff --git a/client/tests/test_browser_adapter.gd b/client/tests/test_browser_adapter.gd new file mode 100644 index 000000000..2d37ed4c9 --- /dev/null +++ b/client/tests/test_browser_adapter.gd @@ -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 "" 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 " 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 "" 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("") + + +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 (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 with nested + # inputs: Vec (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("—") diff --git a/client/ui/implant/apps/browser/app.tres b/client/ui/implant/apps/browser/app.tres new file mode 100644 index 000000000..8e6fb0313 --- /dev/null +++ b/client/ui/implant/apps/browser/app.tres @@ -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 diff --git a/client/ui/implant/apps/browser/browser_adapter.gd b/client/ui/implant/apps/browser/browser_adapter.gd new file mode 100644 index 000000000..1437d01a0 --- /dev/null +++ b/client/ui/implant/apps/browser/browser_adapter.gd @@ -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 => None keeps +## the key in the map) returns that null unchanged, and str(null) renders as +## the literal string "" — 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 "" 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 +## " 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} diff --git a/client/ui/implant/apps/browser/browser_app.gd b/client/ui/implant/apps/browser/browser_app.gd new file mode 100644 index 000000000..c81f93e0b --- /dev/null +++ b/client/ui/implant/apps/browser/browser_app.gd @@ -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) diff --git a/client/ui/implant/apps/browser/browser_app.tscn b/client/ui/implant/apps/browser/browser_app.tscn new file mode 100644 index 000000000..f8c952cea --- /dev/null +++ b/client/ui/implant/apps/browser/browser_app.tscn @@ -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") diff --git a/client/ui/implant/apps/browser/screens/detail_screen.gd b/client/ui/implant/apps/browser/screens/detail_screen.gd new file mode 100644 index 000000000..808deeca8 --- /dev/null +++ b/client/ui/implant/apps/browser/screens/detail_screen.gd @@ -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)) diff --git a/client/ui/implant/apps/browser/screens/index_screen.gd b/client/ui/implant/apps/browser/screens/index_screen.gd new file mode 100644 index 000000000..becd6f3ec --- /dev/null +++ b/client/ui/implant/apps/browser/screens/index_screen.gd @@ -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) diff --git a/client/ui/implant/apps/browser/screens/kind_menu_screen.gd b/client/ui/implant/apps/browser/screens/kind_menu_screen.gd new file mode 100644 index 000000000..b1a509163 --- /dev/null +++ b/client/ui/implant/apps/browser/screens/kind_menu_screen.gd @@ -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"))