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}