feat(ui): T-1133 data browser — implant/browser app, six index+detail screens (D-254 SS4)
New implant app (app_path implant/browser, key B, fullscreen), sibling
to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable
index (live search-mode typing) -> generic detail, parameterized per
kind, composed entirely from D-169 components. available_in_companion
left unset (default true) — the app appears in the companion shell
automatically via the generic-host seam, zero companion-side wiring.
browser_adapter.gd is the sole home of literal wire field names: maps
Oscar's BrowseResponse contract ({id, primary, secondary} index rows;
BrowseDetail enum-as-single-key-map) to view models for all six kinds,
folding join partners (system economy/factions/culture, corporation
presence, commodity production chains with nested Leontief inputs).
browse_protocol.gd split out of protocol.gd (max-file-lines);
sim_bridge gains browse_response_received + request_browse_index/detail.
Live-data catch: a present-but-NULL key (unnamed asteroid belt
proper_name) bypasses Dictionary.get fallbacks and rendered '<null>' —
_display_or() null-vs-absent helper applied across all six detail
mappers, 4 regression tests distinct from the absent-key cases.
43 gdUnit adapter cases; full suite 3074 green. Live-verified against
a real server + real systems.db: all six kinds Ready with real row
counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus
ids. Spawn-mode DB resolution issue found during verification is
pre-existing (cwd-relative data/systems.db) — server-side fix follows
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ signal snapshot_received(snapshot: Dictionary)
|
||||
signal atlas_layers_received(response: Dictionary)
|
||||
signal star_map_received(response: Dictionary) # T-949: StarMapResponse
|
||||
signal city_names_received(response: Dictionary) # T-949: CityNamesResponse
|
||||
signal browse_response_received(response: Dictionary) # T-1131/T-1133: BrowseResponse
|
||||
signal handshake_complete
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
@@ -490,6 +491,50 @@ func request_city_names(body_id: String) -> void:
|
||||
)
|
||||
|
||||
|
||||
## Request one entity kind's index list from the browser proxy (T-1131/
|
||||
## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Index}
|
||||
## frame; the response arrives via browse_response_received. filter_system_id
|
||||
## only means anything for kind == "Body" (bodies filter by containing
|
||||
## system) — pass "" (default) for every other kind or for an unfiltered
|
||||
## Body index. No "wanted before connect" replay bookkeeping like
|
||||
## request_star_map(): the browser screens call this on-demand while the app
|
||||
## is open and connected (a live drill-down request, not a session-scoped
|
||||
## dataset fetched once at boot), so a request issued before CONNECTED is
|
||||
## simply not sent — the screen re-requests on its own enter()/refresh path
|
||||
## the next time it's shown.
|
||||
func request_browse_index(kind: String, filter_system_id: String = "") -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_browse_request(kind, "Index", filter_system_id)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
var err: int = _bridge.send_message(bytes)
|
||||
if err != OK:
|
||||
push_error(
|
||||
"SimBridge: failed to send browse index request for %s: %s" % [kind, error_string(err)]
|
||||
)
|
||||
|
||||
|
||||
## Request one entity's full detail row from the browser proxy (T-1131/
|
||||
## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Detail}
|
||||
## frame; the response arrives via browse_response_received. entity_id is the
|
||||
## same "id" field an Index row returned for this kind.
|
||||
func request_browse_detail(kind: String, entity_id: String) -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_browse_request(kind, "Detail", entity_id)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
var err: int = _bridge.send_message(bytes)
|
||||
if err != OK:
|
||||
push_error(
|
||||
(
|
||||
"SimBridge: failed to send browse detail request for %s/%s: %s"
|
||||
% [kind, entity_id, error_string(err)]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
@@ -516,8 +561,8 @@ func poll_snapshot() -> Variant:
|
||||
# so they aren't silently dropped when server ticks faster than client consumes.
|
||||
func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
# Decode once, branch by frame shape (#960, D-225; T-949 adds starmap/
|
||||
# citynames): all response kinds and snapshots are msgpack maps, told
|
||||
# apart by field.
|
||||
# citynames; T-1131/T-1133 adds browse): all response kinds and
|
||||
# snapshots are msgpack maps, told apart by field.
|
||||
var inbound := Protocol.decode_inbound(bytes)
|
||||
if inbound.kind == "atlas":
|
||||
atlas_layers_received.emit(inbound.value)
|
||||
@@ -528,6 +573,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
if inbound.kind == "citynames":
|
||||
city_names_received.emit(inbound.value)
|
||||
return
|
||||
if inbound.kind == "browse":
|
||||
browse_response_received.emit(inbound.value)
|
||||
return
|
||||
if inbound.kind != "snapshot":
|
||||
push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
class_name BrowseProtocol
|
||||
## BrowseRequest/BrowseResponse wire codec (T-1131/T-1133, D-254 §4) — the
|
||||
## six-entity data browser proxy (star systems, bodies, stations,
|
||||
## corporations, commodities, trait templates).
|
||||
##
|
||||
## Split out of protocol.gd (not folded in) purely to stay under gdlint's
|
||||
## max-file-lines — protocol.gd's own static functions (encode_browse_request/
|
||||
## browse_response_from_raw/decode_browse_response) delegate here. Every
|
||||
## caller still goes through Protocol.* — this file is an implementation
|
||||
## detail, not a second public API surface.
|
||||
##
|
||||
## `browse: true` is the mandatory discriminator field, same convention as
|
||||
## star_map/city_names — decode_inbound's demux is at its documented
|
||||
## practical ceiling of shape-sniffing probes, so BrowseRequest is ONE new
|
||||
## shape carrying an internal kind/query split, not six.
|
||||
##
|
||||
## Wire shapes (confirmed against Oscar's T-1131 contract, 2026-07-17):
|
||||
## kind: a BrowseEntityKind bare string ("StarSystem"|"Body"|"Station"|
|
||||
## "Corporation"|"Commodity"|"TraitTemplate") — all-unit-variant enum,
|
||||
## same shape as AtlasLayerRequest's up_to: CascadeLayer.
|
||||
## query: BrowseQuery's two variants both carry named fields (struct
|
||||
## variants), so each encodes as a single-key map:
|
||||
## {"Index": {"filter_system_id": null | "GJ-1"}}
|
||||
## {"Detail": {"id": "GJ1c"}}
|
||||
## filter_system_id only means anything for kind == "Body" (bodies filter
|
||||
## by containing SYSTEM, not by another body — renamed from
|
||||
## filter_body_id on Oscar's side before shipping).
|
||||
## status: BrowseStatus reuses the exact Ready|NotFound|Error(String) shape
|
||||
## as AtlasLayerStatus/StarMapStatus/CityNamesStatus, decoded by the same
|
||||
## bare-string-or-single-key-map rule protocol.gd's _decode_status_field
|
||||
## already implements for those three — duplicated here as
|
||||
## _decode_status_field (not shared via a Callable) to keep this file
|
||||
## genuinely standalone; the shape is a stable, tiny, three-line rule.
|
||||
|
||||
|
||||
static func _decode_status_field(status_raw: Variant) -> Dictionary:
|
||||
if status_raw is String:
|
||||
return {"status": status_raw, "error": ""}
|
||||
if status_raw is Dictionary and status_raw.has("Error"):
|
||||
return {"status": "Error", "error": str(status_raw["Error"])}
|
||||
return {"status": "", "error": ""}
|
||||
|
||||
|
||||
## Encode a BrowseRequest. `mp` is the loaded messagepack.gd module (passed
|
||||
## in rather than reloaded here — protocol.gd's _mp() already owns that
|
||||
## load()). kind is a BrowseEntityKind bare string. query_kind is "Index" or
|
||||
## "Detail"; filter_value is the containing system_id (Index, Body only) or
|
||||
## the entity's own id (Detail).
|
||||
static func encode_browse_request(
|
||||
mp, kind: String, query_kind: String, filter_value: String = ""
|
||||
) -> PackedByteArray:
|
||||
var query: Dictionary
|
||||
if query_kind == "Detail":
|
||||
query = {"Detail": {"id": filter_value}}
|
||||
else:
|
||||
query = {
|
||||
"Index": {"filter_system_id": filter_value if not filter_value.is_empty() else null}
|
||||
}
|
||||
var msg := {"browse": true, "kind": kind, "query": query}
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("BrowseProtocol: encode_browse_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Build a BrowseResponse from an already-decoded raw value. Returns null
|
||||
## unless it carries "kind" AND "status" AND ("index" or "detail") — "kind" +
|
||||
## "status" alone doesn't disambiguate from AtlasLayerResponse (also has
|
||||
## "status", never "kind"). "index"/"detail" pass through as raw decoded
|
||||
## values — BrowserAdapter (client/ui/implant/apps/browser/browser_adapter.gd)
|
||||
## owns interpreting their per-kind shape, matching how atlas_response_from_raw's
|
||||
## layer1/district_grid/etc. fields also just pass through unshaped.
|
||||
static func browse_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("kind") or not raw.has("status"):
|
||||
return null
|
||||
if not raw.has("index") and not raw.has("detail"):
|
||||
return null
|
||||
var decoded_status := _decode_status_field(raw.get("status"))
|
||||
return {
|
||||
"kind": str(raw.get("kind", "")),
|
||||
"status": decoded_status["status"],
|
||||
"error": decoded_status["error"],
|
||||
"index": raw.get("index"),
|
||||
"detail": raw.get("detail"),
|
||||
}
|
||||
@@ -14,6 +14,17 @@ static func _mp():
|
||||
return load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
|
||||
## BrowseRequest/BrowseResponse codec (T-1131/T-1133) — factored into its own
|
||||
## file to stay under gdlint's max-file-lines, load()'d here (not referenced
|
||||
## as a bare class_name) per the autoload parse-order rule (CLAUDE.md):
|
||||
## Protocol is an autoload, and autoload scripts compile before global
|
||||
## class_name scripts are registered — a top-level class_name reference would
|
||||
## fail to parse. By the time any caller actually runs (always post-boot),
|
||||
## load() returns the already-cached resource with no reload cost.
|
||||
static func _bp():
|
||||
return load("res://scripts/protocol/browse_protocol.gd")
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
|
||||
@@ -898,14 +909,36 @@ static func decode_city_names_response(bytes: PackedByteArray) -> Variant:
|
||||
return city_names_response_from_raw(decode_raw(bytes))
|
||||
|
||||
|
||||
## Encode a BrowseRequest (T-1131/T-1133, D-254 §4) for the six-entity data
|
||||
## browser proxy. Delegates to browse_protocol.gd — kept out of this file to
|
||||
## stay under gdlint's max-file-lines; see that file for the full wire-shape
|
||||
## rationale (discriminator field, kind/query split, filter_system_id).
|
||||
static func encode_browse_request(
|
||||
kind: String, query_kind: String, filter_value: String = ""
|
||||
) -> PackedByteArray:
|
||||
return _bp().encode_browse_request(_mp(), kind, query_kind, filter_value)
|
||||
|
||||
|
||||
## Build a BrowseResponse from an already-decoded raw value. See
|
||||
## browse_protocol.gd for the full shape/disambiguation rationale.
|
||||
static func browse_response_from_raw(raw: Variant) -> Variant:
|
||||
return _bp().browse_response_from_raw(raw)
|
||||
|
||||
|
||||
## Decode a BrowseResponse from MessagePack bytes. See browse_response_from_raw.
|
||||
static func decode_browse_response(bytes: PackedByteArray) -> Variant:
|
||||
return browse_response_from_raw(decode_raw(bytes))
|
||||
|
||||
|
||||
## Decode + classify one inbound frame (#960, D-225; T-949 adds starmap/
|
||||
## citynames). Returns {kind, value} with kind "snapshot" | "atlas" |
|
||||
## "starmap" | "citynames" | "unknown" — all four response kinds are msgpack
|
||||
## maps, so they are told apart by field. Checked most-specific-first:
|
||||
## StarMapResponse is the only kind with "data" and no "body_id";
|
||||
## CityNamesResponse is the only kind with "cities"; anything else carrying
|
||||
## "status" is AtlasLayerResponse. Lets receive_bytes decode the frame ONCE
|
||||
## and branch, instead of double-decoding the 20 Hz snapshot path.
|
||||
## citynames; T-1131/T-1133 adds browse). Returns {kind, value}, kind one of
|
||||
## "snapshot"|"atlas"|"starmap"|"citynames"|"browse"|"unknown" — all msgpack
|
||||
## maps, told apart by field, most-specific-first: StarMapResponse is the
|
||||
## only kind with "data" and no "body_id"; CityNamesResponse the only one
|
||||
## with "cities"; BrowseResponse the only one with its OWN "kind" field
|
||||
## alongside "status"; anything else carrying "status" is AtlasLayerResponse.
|
||||
## Lets receive_bytes decode the frame ONCE instead of double-decoding the
|
||||
## 20 Hz snapshot path.
|
||||
static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
|
||||
var raw = decode_raw(bytes)
|
||||
if not raw is Dictionary:
|
||||
@@ -916,6 +949,8 @@ static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
|
||||
return {"kind": "starmap", "value": star_map_response_from_raw(raw)}
|
||||
if raw.has("cities"):
|
||||
return {"kind": "citynames", "value": city_names_response_from_raw(raw)}
|
||||
if raw.has("kind") and raw.has("status"):
|
||||
return {"kind": "browse", "value": browse_response_from_raw(raw)}
|
||||
if raw.has("status"):
|
||||
return {"kind": "atlas", "value": atlas_response_from_raw(raw)}
|
||||
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}
|
||||
|
||||
Reference in New Issue
Block a user