Files
settled-reach/client/scripts/protocol/browse_protocol.gd
T
jpmschweitzerandClaude Fable 5 90c562a6a3 feat(ui): T-1133 data browser — implant/browser app, six index+detail screens (D-254 SS4)
New implant app (app_path implant/browser, key B, fullscreen), sibling
to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable
index (live search-mode typing) -> generic detail, parameterized per
kind, composed entirely from D-169 components. available_in_companion
left unset (default true) — the app appears in the companion shell
automatically via the generic-host seam, zero companion-side wiring.

browser_adapter.gd is the sole home of literal wire field names: maps
Oscar's BrowseResponse contract ({id, primary, secondary} index rows;
BrowseDetail enum-as-single-key-map) to view models for all six kinds,
folding join partners (system economy/factions/culture, corporation
presence, commodity production chains with nested Leontief inputs).
browse_protocol.gd split out of protocol.gd (max-file-lines);
sim_bridge gains browse_response_received + request_browse_index/detail.

Live-data catch: a present-but-NULL key (unnamed asteroid belt
proper_name) bypasses Dictionary.get fallbacks and rendered '<null>' —
_display_or() null-vs-absent helper applied across all six detail
mappers, 4 regression tests distinct from the absent-key cases.

43 gdUnit adapter cases; full suite 3074 green. Live-verified against
a real server + real systems.db: all six kinds Ready with real row
counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus
ids. Spawn-mode DB resolution issue found during verification is
pre-existing (cwd-relative data/systems.db) — server-side fix follows
separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:24:39 +02:00

87 lines
4.1 KiB
GDScript

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"),
}