Merge remote-tracking branch 'origin/atlas-companion-browser'

This commit is contained in:
2026-07-17 10:55:56 +02:00
20 changed files with 5123 additions and 31 deletions
+50 -2
View File
@@ -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"),
}
+42 -7
View File
@@ -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)}
+502
View File
@@ -0,0 +1,502 @@
class_name TestBrowserAdapter
extends GdUnitTestSuite
## Unit tests for browser_adapter.gd (T-1133, D-254 §4).
##
## Scope: the pure translation-layer logic between Oscar's T-1131
## BrowseRequest/BrowseResponse wire contract and the browser screens' view-
## models — kind metadata, index row extraction/filtering, and detail
## payload unwrap + per-kind field mapping. All exercised with plain
## Dictionary literals standing in for decoded msgpack (no live server, no
## SimBridge, no protocol.gd round-trip needed to cover this logic).
##
## Deliberately NOT covered here: the actual wire encode/decode round-trip
## (that is protocol.gd's own concern — encode_browse_request/
## browse_response_from_raw), and the screens' rendering of the adapter's
## output into ImplantPanel components (covered by the live make atlas
## verification per the ticket's tiering — the screens are thin renderers
## over what this adapter already validates).
const SCRIPT_PATH := "res://ui/implant/apps/browser/browser_adapter.gd"
func _adapter():
return load(SCRIPT_PATH)
# =============================================================================
# Kind metadata
# =============================================================================
func test_kind_order_has_six_entries() -> void:
var a = _adapter()
assert_int(a.KIND_ORDER.size()).is_equal(6)
func test_kind_order_matches_d254_v1_list_order() -> void:
# D-254 §4's v1 entity scope list order: star systems, bodies, stations,
# corporations, commodities, trait catalog.
var a = _adapter()
assert_array(a.KIND_ORDER).is_equal(
[
a.KIND_STAR_SYSTEM,
a.KIND_BODY,
a.KIND_STATION,
a.KIND_CORPORATION,
a.KIND_COMMODITY,
a.KIND_TRAIT_TEMPLATE,
]
)
func test_is_valid_kind_accepts_all_six() -> void:
var a = _adapter()
for kind: String in a.KIND_ORDER:
assert_bool(a.is_valid_kind(kind)).override_failure_message(
"%s should be a valid kind" % kind
).is_true()
func test_is_valid_kind_rejects_unknown_string() -> void:
var a = _adapter()
assert_bool(a.is_valid_kind("Spaceship")).is_false()
assert_bool(a.is_valid_kind("")).is_false()
func test_kind_label_every_kind_has_a_non_empty_label() -> void:
var a = _adapter()
for kind: String in a.KIND_ORDER:
assert_str(a.kind_label(kind)).override_failure_message(
"%s must have a display label" % kind
).is_not_empty()
func test_kind_label_unknown_kind_falls_back_to_the_kind_string() -> void:
var a = _adapter()
assert_str(a.kind_label("Nonsense")).is_equal("Nonsense")
# =============================================================================
# index_rows — Ready vs. non-Ready responses
# =============================================================================
func test_index_rows_ready_status_extracts_rows() -> void:
var a = _adapter()
var response := {
"kind": "StarSystem",
"status": "Ready",
"index": [
{"id": "GJ-1", "primary": "GJ-1", "secondary": "K-type"},
{"id": "GJ-2", "primary": "GJ-2", "secondary": "M-type"},
],
}
var rows: Array = a.index_rows(response)
assert_int(rows.size()).is_equal(2)
assert_str(rows[0]["id"]).is_equal("GJ-1")
assert_str(rows[0]["primary"]).is_equal("GJ-1")
assert_str(rows[0]["secondary"]).is_equal("K-type")
func test_index_rows_missing_secondary_defaults_to_empty_string_not_null() -> void:
var a = _adapter()
var response := {
"kind": "TraitTemplate",
"status": "Ready",
"index": [{"id": "tag_1", "primary": "Some Trait"}],
}
var rows: Array = a.index_rows(response)
assert_str(rows[0]["secondary"]).is_equal("")
func test_index_rows_error_status_returns_empty_array() -> void:
var a = _adapter()
var response := {"kind": "Body", "status": "Error", "index": null}
assert_array(a.index_rows(response)).is_empty()
func test_index_rows_not_found_status_returns_empty_array() -> void:
# D-254/Oscar's contract: Index requests never produce NotFound in
# practice, but the adapter must not crash or misbehave if one somehow
# arrives — treat any non-Ready status uniformly as "no rows".
var a = _adapter()
var response := {"kind": "Body", "status": "NotFound", "index": null}
assert_array(a.index_rows(response)).is_empty()
func test_index_rows_ready_but_index_field_not_an_array_returns_empty() -> void:
var a = _adapter()
var response := {"kind": "Body", "status": "Ready", "index": null}
assert_array(a.index_rows(response)).is_empty()
func test_index_rows_ready_with_genuinely_empty_list_returns_empty_array() -> void:
# The "unknown body -> Ready with empty list" convention (Oscar's
# message) must round-trip cleanly, not be conflated with an error.
var a = _adapter()
var response := {"kind": "Body", "status": "Ready", "index": []}
assert_array(a.index_rows(response)).is_empty()
# =============================================================================
# filter_rows — live search box
# =============================================================================
func _sample_rows() -> Array:
return [
{"id": "gate-corporation", "primary": "Gate Corporation", "secondary": "corporation"},
{"id": "vethara", "primary": "Vethara", "secondary": "combine"},
{"id": "free-traders", "primary": "Free Traders Syndic", "secondary": "syndic"},
]
func test_filter_rows_empty_query_returns_all_rows_unchanged() -> void:
var a = _adapter()
var rows := _sample_rows()
assert_array(a.filter_rows(rows, "")).is_equal(rows)
func test_filter_rows_matches_primary_case_insensitively() -> void:
var a = _adapter()
var filtered: Array = a.filter_rows(_sample_rows(), "vethara")
assert_int(filtered.size()).is_equal(1)
assert_str(filtered[0]["id"]).is_equal("vethara")
func test_filter_rows_matches_secondary_column() -> void:
var a = _adapter()
var filtered: Array = a.filter_rows(_sample_rows(), "syndic")
assert_int(filtered.size()).is_equal(1)
assert_str(filtered[0]["id"]).is_equal("free-traders")
func test_filter_rows_matches_id() -> void:
var a = _adapter()
var filtered: Array = a.filter_rows(_sample_rows(), "gate-corp")
assert_int(filtered.size()).is_equal(1)
assert_str(filtered[0]["id"]).is_equal("gate-corporation")
func test_filter_rows_no_match_returns_empty_array() -> void:
var a = _adapter()
assert_array(a.filter_rows(_sample_rows(), "nonexistent-zzz")).is_empty()
func test_filter_rows_partial_word_matches_across_multiple_rows() -> void:
var a = _adapter()
# "corp" is a substring of both "Gate Corporation" (primary) and
# "corporation" (secondary) — same row matches twice, must not duplicate.
var filtered: Array = a.filter_rows(_sample_rows(), "corp")
assert_int(filtered.size()).is_equal(1)
# =============================================================================
# response_status / response_error
# =============================================================================
func test_response_status_ready() -> void:
var a = _adapter()
assert_str(a.response_status({"status": "Ready"})).is_equal("Ready")
func test_response_status_not_a_dictionary_returns_empty_string() -> void:
var a = _adapter()
assert_str(a.response_status(null)).is_equal("")
assert_str(a.response_status("not a dict")).is_equal("")
func test_response_error_returns_message_only_for_error_status() -> void:
var a = _adapter()
var response := {"status": "Error", "error": "db unavailable"}
assert_str(a.response_error(response)).is_equal("db unavailable")
func test_response_error_empty_for_ready_status() -> void:
var a = _adapter()
var response := {"status": "Ready", "error": ""}
assert_str(a.response_error(response)).is_equal("")
func test_response_error_empty_for_not_found_status() -> void:
var a = _adapter()
var response := {"status": "NotFound", "error": ""}
assert_str(a.response_error(response)).is_equal("")
# =============================================================================
# detail_payload — BrowseDetail enum-variant unwrap
# =============================================================================
func test_detail_payload_unwraps_kind_keyed_variant() -> void:
var a = _adapter()
var response := {
"kind": "Commodity",
"status": "Ready",
"detail": {"Commodity": {"commodity_id": "fusion_fuel", "name": "FUSION FUEL"}},
}
var payload: Dictionary = a.detail_payload(response)
assert_str(payload.get("commodity_id", "")).is_equal("fusion_fuel")
assert_str(payload.get("name", "")).is_equal("FUSION FUEL")
func test_detail_payload_non_ready_status_returns_empty_dict() -> void:
var a = _adapter()
var response := {"kind": "Commodity", "status": "NotFound", "detail": null}
assert_that(a.detail_payload(response)).is_equal({})
func test_detail_payload_detail_field_not_a_dictionary_returns_empty_dict() -> void:
var a = _adapter()
var response := {"kind": "Commodity", "status": "Ready", "detail": null}
assert_that(a.detail_payload(response)).is_equal({})
func test_detail_payload_falls_back_to_single_key_map_when_kind_key_missing() -> void:
# Defensive path: some enum-variant encodings may not key exactly on the
# response's own "kind" string — a single-key map should still unwrap.
var a = _adapter()
var response := {
"kind": "TraitTemplate",
"status": "Ready",
"detail": {"SomeOtherVariantName": {"tag": "spice_market"}},
}
var payload: Dictionary = a.detail_payload(response)
assert_str(payload.get("tag", "")).is_equal("spice_market")
# =============================================================================
# detail_view — per-kind dispatch + field mapping
# =============================================================================
func test_detail_view_star_system_maps_expected_fields() -> void:
var a = _adapter()
var payload := {
"system_id": "GJ-1",
"proper_name": "Xin Chengdu",
"star_type": "K",
"population": 1200000,
}
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
assert_str(view["name"]).is_equal("Xin Chengdu")
assert_str(view["subtitle"]).is_equal("STAR SYSTEM")
var rows: Array = view["rows"]
assert_bool(rows.size() > 0).is_true()
# Spot-check one row is present with the right value.
var found := false
for row: Dictionary in rows:
if row["label"] == "star type":
assert_str(row["value"]).is_equal("K")
found = true
assert_bool(found).override_failure_message("expected a 'star type' row").is_true()
func test_detail_view_star_system_falls_back_to_system_id_when_unnamed() -> void:
var a = _adapter()
var payload := {"system_id": "GJ-999"}
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
assert_str(view["name"]).is_equal("GJ-999")
## Regression test for a bug found via live verification (2026-07-17): a
## real Body row (an unnamed asteroid belt, GJ0-belt) has proper_name as a
## PRESENT key with a NULL value (msgpack encodes a SQL NULL column that
## way; Dictionary.get(key, fallback)'s fallback only fires when the key is
## ABSENT, not when it's present-and-null) — the naive
## str(p.get("proper_name", p.get("body_id", "—"))) rendered the literal
## string "<null>" instead of falling back to body_id. Distinct from
## test_detail_view_star_system_falls_back_to_system_id_when_unnamed above,
## which only covers the ABSENT-key case (that test alone did not catch this
## bug — it needs its own present-but-null case).
func test_detail_view_falls_back_correctly_when_name_field_is_present_but_null() -> void:
var a = _adapter()
var payload := {"body_id": "GJ0-belt", "proper_name": null, "body_type": "asteroid_belt"}
var view: Dictionary = a.detail_view(a.KIND_BODY, payload)
assert_str(view["name"]).is_equal("GJ0-belt")
## Same present-but-null regression, for the "distance" row's unit-suffix
## special case (str(p.get("dist_ly", "—")) + " ly" would render "<null> ly"
## for Sol's own row, where dist_ly is a genuine NULL — distance from Earth
## to itself is meaningless, not zero).
func test_detail_view_star_system_distance_row_handles_null_dist_ly() -> void:
var a = _adapter()
var payload := {"system_id": "GJ 0", "proper_name": "Sol", "dist_ly": null}
var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload)
var distance_value := ""
for row: Dictionary in view["rows"]:
if row["label"] == "distance":
distance_value = row["value"]
assert_str(distance_value).is_equal("")
## Same present-but-null regression, for the two array-summary text-block
## builders (_presence_summary/_production_chains_summary) — a null entry
## field must not render as "<null>" inside the summary line.
func test_detail_view_corporation_presence_entry_with_null_operation_omits_it_cleanly() -> void:
var a = _adapter()
var payload := {
"corp_id": "gate-corporation",
"presence": [
{"location_id": "GJ1b-S1", "location_type": "station", "primary_operation": null}
],
}
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
assert_str(view["text"]).contains("GJ1b-S1 (station)")
assert_str(view["text"]).not_contains("<null>")
func test_detail_view_body_maps_expected_fields() -> void:
var a = _adapter()
var payload := {
"body_id": "GJ1c",
"proper_name": "Chengdu Prime",
"body_type": "planet",
"inhabited": true,
}
var view: Dictionary = a.detail_view(a.KIND_BODY, payload)
assert_str(view["name"]).is_equal("Chengdu Prime")
assert_str(view["subtitle"]).is_equal("CELESTIAL BODY")
var inhabited_value := ""
for row: Dictionary in view["rows"]:
if row["label"] == "inhabited":
inhabited_value = row["value"]
assert_str(inhabited_value).is_equal("yes")
func test_detail_view_station_maps_expected_fields() -> void:
var a = _adapter()
var payload := {
"station_id": "GJ1b-S1",
"proper_name": "Horizon Station",
"station_type": "horizon",
}
var view: Dictionary = a.detail_view(a.KIND_STATION, payload)
assert_str(view["name"]).is_equal("Horizon Station")
assert_str(view["subtitle"]).is_equal("STATION")
func test_detail_view_corporation_maps_expected_fields() -> void:
var a = _adapter()
var payload := {
"corp_id": "gate-corporation",
"proper_name": "Gate Corporation",
"corp_type": "corporation",
"shadow_economy_access": false,
}
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
assert_str(view["name"]).is_equal("Gate Corporation")
assert_str(view["subtitle"]).is_equal("CORPORATION")
var shadow_value := ""
for row: Dictionary in view["rows"]:
if row["label"] == "shadow economy access":
shadow_value = row["value"]
assert_str(shadow_value).is_equal("no")
func test_detail_view_corporation_with_no_presence_yields_empty_text() -> void:
var a = _adapter()
var payload := {"corp_id": "gate-corporation", "presence": []}
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
assert_str(view["text"]).is_equal("")
func test_detail_view_corporation_presence_array_summarized_into_text() -> void:
# CorporationDetail.presence: Vec<CorpPresenceEntry> (Oscar's T-1131
# message, 2026-07-17) — {location_id, location_type, primary_operation}.
var a = _adapter()
var payload := {
"corp_id": "gate-corporation",
"presence": [
{
"location_id": "GJ1b-S1",
"location_type": "station",
"primary_operation": "refining",
},
{"location_id": "GJ-2", "location_type": "system", "primary_operation": ""},
],
}
var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload)
assert_str(view["text"]).contains("GJ1b-S1 (station) — refining")
assert_str(view["text"]).contains("GJ-2 (system)")
func test_detail_view_commodity_maps_expected_fields_and_description_text() -> void:
var a = _adapter()
var payload := {
"commodity_id": "fusion_fuel",
"name": "FUSION FUEL",
"tier": "intermediate",
"description": "Refined isotopic fuel for gate-rated drives.",
}
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
assert_str(view["name"]).is_equal("FUSION FUEL")
assert_str(view["subtitle"]).is_equal("COMMODITY")
assert_str(view["text"]).is_equal("Refined isotopic fuel for gate-rated drives.")
func test_detail_view_commodity_with_no_description_or_chains_yields_empty_text() -> void:
var a = _adapter()
var payload := {"commodity_id": "fusion_fuel", "description": "", "produced_by": []}
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
assert_str(view["text"]).is_equal("")
func test_detail_view_commodity_produced_by_chains_summarized_into_text() -> void:
# CommodityDetail.produced_by: Vec<ProductionChainEntry> with nested
# inputs: Vec<ChainInputEntry> (Oscar's T-1131 message, 2026-07-17).
var a = _adapter()
var payload := {
"commodity_id": "fusion_fuel",
"description": "Refined isotopic fuel.",
"produced_by": [
{
"chain_id": "fusion_refining",
"output_quantity": 1.0,
"inputs": [
{"input_commodity_id": "raw_hydrogen", "quantity": 3.0},
{"input_commodity_id": "catalyst", "quantity": 0.5},
],
}
],
}
var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload)
# str() on a float keeps its decimal form (str(1.0) == "1.0", not "1") —
# asserting against that honest formatting rather than a rounded guess.
assert_str(view["text"]).contains("Refined isotopic fuel.")
assert_str(view["text"]).contains("fusion_refining (qty 1.0)")
assert_str(view["text"]).contains("3.0x raw_hydrogen")
assert_str(view["text"]).contains("0.5x catalyst")
func test_detail_view_trait_template_maps_expected_fields_and_cultural_description() -> void:
var a = _adapter()
var payload := {
"tag": "spice_market",
"label": "Spice Market",
"corridor_pool": "heritage",
"cultural_description": "A dense stall market smelling of scorched cardamom.",
}
var view: Dictionary = a.detail_view(a.KIND_TRAIT_TEMPLATE, payload)
assert_str(view["name"]).is_equal("Spice Market")
assert_str(view["subtitle"]).is_equal("TRAIT TEMPLATE")
assert_str(view["text"]).is_equal("A dense stall market smelling of scorched cardamom.")
func test_detail_view_unknown_kind_returns_empty_shape_not_a_crash() -> void:
var a = _adapter()
var view: Dictionary = a.detail_view("Nonsense", {"some": "payload"})
assert_str(view["name"]).is_equal("")
assert_array(view["rows"]).is_empty()
func test_detail_view_missing_field_renders_as_em_dash_placeholder() -> void:
var a = _adapter()
var view: Dictionary = a.detail_view(a.KIND_STATION, {"station_id": "GJ1b-S1"})
var docking_value := ""
for row: Dictionary in view["rows"]:
if row["label"] == "docking class":
docking_value = row["value"]
assert_str(docking_value).is_equal("")
+12
View File
@@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="ImplantAppManifest" load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/implant/implant_app_manifest.gd" id="1"]
[resource]
script = ExtResource("1")
schema_version = 1
app_path = "implant/browser"
scene_path = "res://ui/implant/apps/browser/browser_app.tscn"
default_mode = "fullscreen"
default_key = 66
preserves_state = true
@@ -0,0 +1,438 @@
class_name BrowserAdapter
## Pure translation layer between the wire BrowseRequest/BrowseResponse
## contract (T-1131, Oscar, D-254 §4) and the view-models the browser's
## screens render (T-1133).
##
## WHY THIS EXISTS: Oscar's contract names its own field-naming churn risk
## up front (Body's filter field renaming mid-implementation; per-kind
## detail field names "TBD" at contract-send time). Every other browser file
## reads through this adapter's accessor functions instead of touching a
## raw response Dictionary's keys directly — so a field rename on the wire
## is a one-line change here, never a screen rewrite. This is the ONE file
## that is allowed to know the literal wire key names.
##
## Kept as a static-function-only class (no state, no scene tree) so it is
## gdUnit-testable with plain Dictionary literals standing in for decoded
## msgpack responses — no live server, no SimBridge, no protocol.gd
## round-trip required to exercise the mapping logic.
## The six entity kinds, in the exact order the kind-menu screen displays
## them (D-254 §4's v1 list order). Wire values are the bare-string unit
## variant names BrowseEntityKind serializes to (Protocol._decode_status_field
## doc: unit variants are bare strings on the wire).
const KIND_STAR_SYSTEM: String = "StarSystem"
const KIND_BODY: String = "Body"
const KIND_STATION: String = "Station"
const KIND_CORPORATION: String = "Corporation"
const KIND_COMMODITY: String = "Commodity"
const KIND_TRAIT_TEMPLATE: String = "TraitTemplate"
const KIND_ORDER: Array[String] = [
KIND_STAR_SYSTEM,
KIND_BODY,
KIND_STATION,
KIND_CORPORATION,
KIND_COMMODITY,
KIND_TRAIT_TEMPLATE,
]
## Display label per kind, for the kind-menu screen and detail/index headers.
const KIND_LABELS: Dictionary = {
KIND_STAR_SYSTEM: "STAR SYSTEMS",
KIND_BODY: "BODIES",
KIND_STATION: "STATIONS",
KIND_CORPORATION: "CORPORATIONS",
KIND_COMMODITY: "COMMODITIES",
KIND_TRAIT_TEMPLATE: "TRAIT CATALOG",
}
## True iff kind is one of the six v1 entity kinds (defends against a typo'd
## kind string reaching a request encode — better to fail loud client-side
## than send a request the server's kind enum can't decode).
static func is_valid_kind(kind: String) -> bool:
return kind in KIND_ORDER
static func kind_label(kind: String) -> String:
return KIND_LABELS.get(kind, kind)
# =============================================================================
# Index rows
# =============================================================================
## One row-view-model: {id, primary, secondary}. secondary is "" (not null)
## when the wire response carried no secondary column, so callers can always
## treat it as a plain String without a null check.
static func _row_from_raw(raw: Dictionary) -> Dictionary:
return {
"id": str(raw.get("id", "")),
"primary": str(raw.get("primary", "")),
"secondary": str(raw.get("secondary", "")) if raw.get("secondary") != null else "",
}
## Extract the index row list from a decoded BrowseResponse. Returns [] for
## any non-Ready status or a response with no "index" field (e.g. a Detail
## response) rather than erroring — callers check response status themselves
## via response_status()/response_error() before trusting an empty list as
## "genuinely no rows".
static func index_rows(response: Dictionary) -> Array:
if response.get("status", "") != "Ready":
return []
var raw_rows: Variant = response.get("index")
if not raw_rows is Array:
return []
var rows: Array = []
for raw: Dictionary in raw_rows:
rows.append(_row_from_raw(raw))
return rows
## Case-insensitive substring filter over primary/secondary/id — the index
## screen's search box. Empty query returns rows unchanged (identity, not a
## copy — callers must not mutate the result in place).
static func filter_rows(rows: Array, query: String) -> Array:
if query.is_empty():
return rows
var needle := query.to_lower()
var out: Array = []
for row: Dictionary in rows:
var haystack: String = (
str(row.get("primary", "")) + " " + str(row.get("secondary", "")) + " "
+ str(row.get("id", ""))
).to_lower()
if haystack.contains(needle):
out.append(row)
return out
# =============================================================================
# Detail — per-kind field extraction
# =============================================================================
## Status of a decoded BrowseResponse: "Ready" | "NotFound" | "Error" | "".
## Empty string means the Dictionary isn't a recognizable BrowseResponse at
## all (e.g. null, or a decode failure upstream).
static func response_status(response: Variant) -> String:
if not response is Dictionary:
return ""
return str(response.get("status", ""))
## Error message for an Error-status response; "" otherwise (including for
## Ready/NotFound, where there is nothing to show).
static func response_error(response: Dictionary) -> String:
if response.get("status", "") != "Error":
return ""
return str(response.get("error", ""))
## The BrowseDetail payload for a Ready Detail response, or {} if the
## response is any other shape. This is the ONE enum-variant-as-single-key-map
## unwrap in the adapter — BrowseDetail is an externally-tagged Rust enum
## (StarSystem(...)|Body(...)|...), so the decoded map has exactly one key
## matching the response's own "kind" field, and the value under that key is
## the per-kind detail Dictionary every _detail_rows_for_* function reads.
static func detail_payload(response: Dictionary) -> Dictionary:
if response.get("status", "") != "Ready":
return {}
var raw_detail: Variant = response.get("detail")
if not raw_detail is Dictionary:
return {}
var kind: String = str(response.get("kind", ""))
if raw_detail.has(kind) and raw_detail[kind] is Dictionary:
return raw_detail[kind]
# Defensive fallback: some server-side encodings of a single-variant enum
# collapse to the inner map directly rather than wrapping it under the
# variant name (observed with other unit-vs-struct enums in this
# codebase's wire contracts, e.g. AtlasLayerStatus's Error(String) case).
# If the "kind"-keyed lookup above misses, and the map has no OTHER
# single-key wrapper shape either, assume it's already the unwrapped
# detail and return it as-is rather than silently rendering a blank
# detail screen.
if raw_detail.size() == 1 and not raw_detail.has(kind):
return raw_detail.values()[0]
return raw_detail
## header (name, subtitle) + Array[{label, value}] rows for a detail
## payload, dispatched by kind. Kept as one dispatcher rather than one method
## per kind on the caller side, so DetailScreen only ever calls one function
## regardless of which of the six kinds it's showing.
static func detail_view(kind: String, payload: Dictionary) -> Dictionary:
match kind:
BrowserAdapter.KIND_STAR_SYSTEM:
return _detail_star_system(payload)
BrowserAdapter.KIND_BODY:
return _detail_body(payload)
BrowserAdapter.KIND_STATION:
return _detail_station(payload)
BrowserAdapter.KIND_CORPORATION:
return _detail_corporation(payload)
BrowserAdapter.KIND_COMMODITY:
return _detail_commodity(payload)
BrowserAdapter.KIND_TRAIT_TEMPLATE:
return _detail_trait_template(payload)
_:
return {"name": "", "subtitle": "", "rows": []}
static func _row(label: String, value: Variant) -> Dictionary:
return {"label": label, "value": str(value) if value != null else ""}
static func _bool_str(v: Variant) -> String:
return "yes" if bool(v) else "no"
## Dictionary.get(key, fallback)'s fallback only fires when key is ABSENT —
## a present key with a NULL value (a real, common shape here: msgpack
## encodes a SQL NULL column as null, and rusqlite's Option<T> => None keeps
## the key in the map) returns that null unchanged, and str(null) renders as
## the literal string "<null>" — a real bug that surfaced live (a Body row
## for an unnamed asteroid belt: proper_name is a present NULL column, so
## the name line rendered "<null>" instead of falling back to body_id).
## Every _detail_*'s "name" line goes through this instead of a bare
## p.get(primary_key, p.get(fallback_key, "—")) for exactly this reason —
## the per-field row values are already safe via _row()'s own null check
## above; this is the ONE other place a display string is built directly
## from a wire value without going through _row().
static func _display_or(p: Dictionary, key: String, fallback: String) -> String:
var v: Variant = p.get(key)
if v == null:
return fallback
return str(v)
## "distance" is the one row that appends a unit suffix instead of a bare
## _row(label, value) call (str(p.get("dist_ly", "—")) + " ly" would render
## "<null> ly" for a NULL column via the same bug _display_or() fixes above,
## since concatenating a suffix happens before _row()'s own null guard would
## ever see the value) — a dedicated helper keeps the "— ly" placeholder
## intact for a genuinely-NULL dist_ly (Sol's own row: distance from Earth to
## itself is meaningless, not zero, so a NULL/omitted dist_ly is expected
## real data here, not an error condition).
static func _distance_ly_str(dist_ly: Variant) -> String:
if dist_ly == null:
return ""
return str(dist_ly) + " ly"
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
## StarSystemDetail folds system_economy/system_factions/system_culture.
static func _detail_star_system(p: Dictionary) -> Dictionary:
var name: String = _display_or(p, "proper_name", _display_or(p, "system_id", ""))
var rows: Array = [
_row("system id", p.get("system_id", "")),
_row("star type", p.get("star_type", "")),
_row("spectral class", p.get("spectral_class", "")),
_row("distance", _distance_ly_str(p.get("dist_ly"))),
_row("sector", p.get("geographic_sector", "")),
_row("geographic band", p.get("geographic_band", "")),
_row("political zone", p.get("political_zone", "")),
_row("habitable planets", p.get("habitable_planet_count", "")),
_row("inhabited planets", p.get("inhabited_planet_count", "")),
_row("asteroid belt", _bool_str(p.get("asteroid_belt", false))),
_row("gas giant", _bool_str(p.get("gas_giant", false))),
_row("habitability profile", p.get("habitability_profile", "")),
_row("earth alignment", p.get("earth_alignment", "")),
_row("earth proximity", p.get("earth_proximity", "")),
_row("earth tension", p.get("earth_tension", "")),
_row("stability index", p.get("stability_index", "")),
_row("volatility", p.get("system_volatility", "")),
_row("cultural corridor", p.get("cultural_corridor", "")),
_row("currency zone", p.get("currency_zone", "")),
_row("economic tier", p.get("economic_tier", "")),
_row("population", p.get("population", "")),
_row("economic base", p.get("economic_base_primary", "")),
_row("economic base (secondary)", p.get("economic_base_secondary", "")),
_row("governance", p.get("governance_type", "")),
_row("dominant faction", p.get("dominant_faction", "")),
_row("cultural register", p.get("cultural_register", "")),
_row("atmospheric tone", p.get("atmospheric_tone", "")),
_row("primary archetype", p.get("primary_archetype", "")),
]
return {"name": name, "subtitle": "STAR SYSTEM", "rows": rows}
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
## `bodies` row, no folded child tables.
static func _detail_body(p: Dictionary) -> Dictionary:
var name: String = _display_or(p, "proper_name", _display_or(p, "body_id", ""))
var rows: Array = [
_row("body id", p.get("body_id", "")),
_row("system", p.get("system_id", "")),
_row("parent body", p.get("parent_body_id", "")),
_row("type", p.get("body_type", "")),
_row("orbit index", p.get("orbit_index", "")),
_row("mass class", p.get("mass_class", "")),
_row("atmosphere", p.get("atmosphere", "")),
_row("surface gravity (g)", p.get("surface_gravity", "")),
_row("orbital period (days)", p.get("orbital_period_days", "")),
_row("rotation period (hrs)", p.get("rotation_period_hours", "")),
_row("planet class", p.get("planet_class", "")),
_row("hydrosphere", p.get("hydrosphere", "")),
_row("biosphere class", p.get("biosphere_class", "")),
_row("inhabited", _bool_str(p.get("inhabited", false))),
_row("population", p.get("population", "")),
_row("economic role", p.get("economic_role", "")),
_row("founding age (years)", p.get("founding_age_years", "")),
_row("settlement pattern", p.get("settlement_pattern", "")),
_row("cultural corridor", p.get("cultural_corridor", "")),
_row("industrial corridor", p.get("industrial_corridor", "")),
_row("radius (km)", p.get("body_radius_km", "")),
_row("axial tilt (deg)", p.get("axial_tilt_deg", "")),
]
return {"name": name, "subtitle": "CELESTIAL BODY", "rows": rows}
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
## `stations` row, no folded child tables.
static func _detail_station(p: Dictionary) -> Dictionary:
var name: String = _display_or(p, "proper_name", _display_or(p, "station_id", ""))
var rows: Array = [
_row("station id", p.get("station_id", "")),
_row("system", p.get("system_id", "")),
_row("orbits", p.get("orbits_body_id", "")),
_row("type", p.get("station_type", "")),
_row("population", p.get("population", "")),
_row("economic role", p.get("economic_role", "")),
_row("governance", p.get("governance_type", "")),
_row("docking class", p.get("docking_class", "")),
_row("gate infrastructure", _bool_str(p.get("has_gate_infrastructure", false))),
_row("districts", p.get("district_count", "")),
]
return {"name": name, "subtitle": "STATION", "rows": rows}
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
## CorporationDetail folds corp_presence (as a presence array) + corp_financial_state.
static func _detail_corporation(p: Dictionary) -> Dictionary:
var name: String = _display_or(p, "proper_name", _display_or(p, "corp_id", ""))
var rows: Array = [
_row("corp id", p.get("corp_id", "")),
_row("type", p.get("corp_type", "")),
_row("scope", p.get("scope", "")),
_row("hq system", p.get("headquarters_system", "")),
_row("hq body", p.get("headquarters_body", "")),
_row("specialization", p.get("specialization", "")),
_row("parent corp", p.get("parent_corp", "")),
_row("notes", p.get("notes", "")),
_row("behavioral archetype", p.get("behavioral_archetype", "")),
_row("supply chain role", p.get("supply_chain_role", "")),
_row("shadow economy access", _bool_str(p.get("shadow_economy_access", false))),
_row("corp specialization", p.get("corp_specialization", "")),
_row("hq placement", p.get("hq_placement", "")),
_row("health metric", p.get("health_metric", "")),
]
return {
"name": name,
"subtitle": "CORPORATION",
"rows": rows,
"text": _presence_summary(p.get("presence", [])),
}
## presence: Array[{location_id, location_type, primary_operation}] ->
## one line per entry for the detail screen's ImplantTextBlock. Empty array
## (no presence rows) yields "" so the caller's has-text check stays a plain
## is_empty() rather than needing a special "no presence" sentinel.
static func _presence_summary(presence: Variant) -> String:
if not presence is Array or presence.is_empty():
return ""
var lines: Array = []
for entry: Dictionary in presence:
var loc: String = _display_or(entry, "location_id", "")
var loc_type: String = _display_or(entry, "location_type", "")
var op: String = _display_or(entry, "primary_operation", "")
var line: String = loc
if not loc_type.is_empty():
line += " (" + loc_type + ")"
if not op.is_empty():
line += "" + op
lines.append(line)
return "PRESENCE\n" + "\n".join(lines)
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
## CommodityDetail folds production_chains + chain_inputs (as produced_by).
static func _detail_commodity(p: Dictionary) -> Dictionary:
var name: String = _display_or(p, "name", _display_or(p, "commodity_id", ""))
var rows: Array = [
_row("commodity id", p.get("commodity_id", "")),
_row("tier", p.get("tier", "")),
_row("elasticity", p.get("elasticity", "")),
_row("base price", p.get("base_price", "")),
_row("bulk class", p.get("bulk_class", "")),
_row("unit", p.get("unit", "")),
_row("production ubiquity", p.get("production_ubiquity", "")),
_row("demand model", p.get("demand_model", "")),
_row("commission certifiable", _bool_str(p.get("commission_certifiable", false))),
_row("compact contested", _bool_str(p.get("compact_contested", false))),
_row("shadow viable", _bool_str(p.get("shadow_viable", false))),
_row("panic threshold (weeks)", p.get("panic_threshold_weeks", "")),
]
var text_parts: Array = []
var description: String = _display_or(p, "description", "")
if not description.is_empty():
text_parts.append(description)
var chains_summary: String = _production_chains_summary(p.get("produced_by", []))
if not chains_summary.is_empty():
text_parts.append(chains_summary)
return {"name": name, "subtitle": "COMMODITY", "rows": rows, "text": "\n\n".join(text_parts)}
## produced_by: Array[{chain_id, output_quantity, location_bound, description,
## inputs: [{input_commodity_id, quantity}]}] -> one summary line per chain,
## with its Leontief inputs inlined as "qty x input_id" pairs. Empty array ->
## "" (same no-sentinel convention as _presence_summary).
static func _production_chains_summary(chains: Variant) -> String:
if not chains is Array or chains.is_empty():
return ""
var lines: Array = []
for chain: Dictionary in chains:
var chain_id: String = _display_or(chain, "chain_id", "")
var qty: String = _display_or(chain, "output_quantity", "")
var raw_inputs: Variant = chain.get("inputs")
var inputs: Array = raw_inputs if raw_inputs is Array else []
var input_strs: Array = []
for entry: Dictionary in inputs:
var input_qty: String = _display_or(entry, "quantity", "")
var input_id: String = _display_or(entry, "input_commodity_id", "")
input_strs.append(input_qty + "x " + input_id)
var line: String = "%s (qty %s)" % [chain_id, qty]
if not input_strs.is_empty():
line += " <- " + ", ".join(input_strs)
lines.append(line)
return "PRODUCTION CHAINS\n" + "\n".join(lines)
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
## `trait_templates` row. weight_mods/zone_affinity/allow_tags/block_tags/
## visual_bundle are JSON-text columns the server does NOT parse (Oscar's
## contract: "opaque strings, not parsed server-side") — shown here as raw
## JSON text rows rather than silently dropped; a data browser's job is to
## expose the registry as it actually is, not to pretty-print it.
static func _detail_trait_template(p: Dictionary) -> Dictionary:
var name: String = _display_or(p, "label", _display_or(p, "tag", ""))
var rows: Array = [
_row("tag", p.get("tag", "")),
_row("corridor pool", p.get("corridor_pool", "")),
_row("geographic sector", p.get("geographic_sector", "")),
_row("bulk class gate", p.get("bulk_class_gate", "")),
_row("production ubiquity gate", p.get("production_ubiquity_gate", "")),
_row("min prosperity (bps)", p.get("min_prosperity_bps", "")),
_row("base weight (bps)", p.get("base_weight", "")),
_row("weight mods", p.get("weight_mods", "")),
_row("zone affinity", p.get("zone_affinity", "")),
_row("allow tags", p.get("allow_tags", "")),
_row("block tags", p.get("block_tags", "")),
_row("era scope", p.get("era_scope", "")),
_row("visual bundle", p.get("visual_bundle", "")),
]
var text_block: String = _display_or(p, "cultural_description", "")
return {"name": name, "subtitle": "TRAIT TEMPLATE", "rows": rows, "text": text_block}
@@ -0,0 +1,159 @@
class_name BrowserApp
extends ImplantApp
## Data browser implant app (T-1133, D-254 §4). A SEPARATE app from the Atlas
## (implant/map) — the six registry entities do not share the Atlas's
## geographic drill-down (D-254 SS4, Jeroen's IA ruling). Registered as
## "implant/browser" in FULLSCREEN mode, key B.
##
## Nav: kind menu (root) -> index (per kind, scrollable + searchable) ->
## detail (per entity). Same push/pop convention the Atlas app uses.
##
## Composed entirely from D-169 implant components via the three screen
## classes below (BrowserKindMenuScreen/BrowserIndexScreen/BrowserDetailScreen)
## — no new UI primitives.
var _kind_menu_screen = null # BrowserKindMenuScreen
var _index_screen = null # BrowserIndexScreen
var _detail_screen = null # BrowserDetailScreen
func _ready() -> void:
manifest = load("res://ui/implant/apps/browser/app.tres")
super._ready()
func on_install() -> void:
var implant_theme = load("res://ui/implant/default_implant.tres")
_kind_menu_screen = BrowserKindMenuScreen.new()
_kind_menu_screen.setup(implant_theme)
_kind_menu_screen.kind_selected.connect(_on_kind_selected)
register_screen("kind_menu", _kind_menu_screen)
_index_screen = BrowserIndexScreen.new()
_index_screen.setup(implant_theme)
_index_screen.row_selected.connect(_on_row_selected)
register_screen("index", _index_screen)
_detail_screen = BrowserDetailScreen.new()
_detail_screen.setup(implant_theme)
register_screen("detail", _detail_screen)
SimBridge.browse_response_received.connect(_on_browse_response_received)
nav.set_default("kind_menu")
func _unhandled_key_input(event: InputEvent) -> void:
if not event is InputEventKey:
return
if manifest == null or not HudGroups.is_app_active(manifest.app_path):
return
if not event.is_pressed() or event.is_echo():
return
_handle_key(event as InputEventKey)
get_viewport().set_input_as_handled()
func _handle_key(event: InputEventKey) -> void:
# Search-mode: on the index screen, printable keys type into the live
# filter instead of being interpreted as navigation. Split into its own
# function (not inlined here) — it is a genuinely separate input mode
# from the top-level app navigation below, and keeping it here would
# push this function's branch/return count well past what one function
# should hold.
if current_screen_id() == "index" and _index_screen:
_handle_index_search_key(event)
return
match event.physical_keycode:
KEY_B:
HudGroups.close_app()
KEY_ESCAPE:
_close_or_pop()
KEY_ENTER, KEY_KP_ENTER:
_handle_enter()
KEY_UP:
if current_screen_id() == "kind_menu" and _kind_menu_screen:
_kind_menu_screen.navigate(-1)
KEY_DOWN:
if current_screen_id() == "kind_menu" and _kind_menu_screen:
_kind_menu_screen.navigate(1)
## Key handling while the index screen is current. Arrows/enter/escape/
## backspace are the always-active control keys; everything else in the
## printable range (D-254 §4: "filterable/searchable by name") appends to
## the live search buffer. This mirrors the implant's existing
## keyboard-as-input-surface convention (no OS textbox anywhere in this
## component library) — there is no separate "focus the search box" step.
func _handle_index_search_key(event: InputEventKey) -> void:
match event.physical_keycode:
KEY_UP:
_index_screen.navigate(-1)
return
KEY_DOWN:
_index_screen.navigate(1)
return
KEY_ENTER, KEY_KP_ENTER:
_index_screen.trigger_enter()
return
KEY_ESCAPE:
if _index_screen.has_search_text():
_index_screen.clear_search()
else:
_close_or_pop()
return
KEY_BACKSPACE:
_index_screen.backspace_search()
return
# event.unicode carries the actual typed character (shift/caps already
# resolved by the platform), NOT the physical keycode — String.chr() is
# GDScript's codepoint-to-one-char-String conversion. Printable range
# only (32 = space .. 126 = ~): control characters (arrows, tab, etc.
# already handled above by physical_keycode) report unicode == 0 or a
# non-printable codepoint and must not leak into the search buffer.
if event.unicode >= 32 and event.unicode < 127:
_index_screen.append_search_char(String.chr(event.unicode))
func _close_or_pop() -> void:
if current_screen_id() == "kind_menu":
HudGroups.close_app()
else:
nav.pop()
func _handle_enter() -> void:
match current_screen_id():
"kind_menu":
if _kind_menu_screen:
_kind_menu_screen.trigger_enter()
# =============================================================================
# Signal handlers
# =============================================================================
func _on_kind_selected(kind: String) -> void:
nav.push("index", {"kind": kind, "filter_system_id": ""})
func _on_row_selected(entity_id: String) -> void:
var kind: String = _index_screen.current_kind() if _index_screen else ""
nav.push("detail", {"kind": kind, "entity_id": entity_id})
func _on_browse_response_received(response: Dictionary) -> void:
if response == null:
return
# Fan out to whichever screen is currently waiting on a response for this
# kind — both screens no-op via their own kind-match guard if the
# response isn't theirs (a stale one from a prior navigation, or one
# meant for the other screen type).
if _index_screen:
_index_screen.receive_response(response)
if _detail_screen:
_detail_screen.receive_response(response)
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/implant/apps/browser/browser_app.gd" id="1_browser_app"]
; T-1133: Data browser implant app — six-entity registry index/detail viewer
; (star systems, bodies, stations, corporations, commodities, trait catalog).
; FULLSCREEN app (z=20) at implant/browser per D-170. A SEPARATE app from the
; Atlas (D-254 SS4) — managed via the same ImplantApp/ImplantNavStack pattern.
; Toggle with B key (manifest.default_key). Data from the wire BrowseRequest/
; BrowseResponse proxy (T-1131).
[node name="BrowserApp" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
script = ExtResource("1_browser_app")
@@ -0,0 +1,152 @@
class_name BrowserDetailScreen
extends Control
## Detail screen for one browser entity (T-1133, D-254 §4). An ImplantPanel of
## ImplantDataRows (every column the wire response carries for that entity)
## plus an ImplantTextBlock for free-text description/cultural_description
## fields where the kind has one. One instance handles all six kinds —
## parameterized by set_entity(), not six near-duplicate screens.
signal back_requested
const COLOR_BG: Color = Color("#0d1117")
const PANEL_WIDTH: float = 420.0
const PANEL_MARGIN: float = 16.0
var _kind: String = ""
var _entity_id: String = ""
var _view: Dictionary = {} # BrowserAdapter.detail_view() result
var _loading: bool = false
var _error: String = ""
var _status: String = ""
var _panel = null # ImplantPanel
var _implant_theme = null # ImplantTheme
var _pending: ImplantPending = null
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func setup(implant_theme) -> void:
_implant_theme = implant_theme
_build_panel()
_build_pending(implant_theme)
func set_entity(kind: String, entity_id: String) -> void:
_kind = kind
_entity_id = entity_id
_view = {}
_loading = true
_error = ""
_status = ""
_rebuild_panel()
if _pending:
_pending.start("QUERYING " + BrowserAdapter.kind_label(kind))
SimBridge.request_browse_detail(kind, entity_id)
## Feed a decoded BrowseResponse into this screen. Ignored if the response
## doesn't match this screen's current kind (stale response after the player
## navigated elsewhere).
func receive_response(response: Dictionary) -> void:
if response.get("kind", "") != _kind:
return
_loading = false
if _pending:
_pending.stop()
_status = BrowserAdapter.response_status(response)
if _status == "Error":
_error = BrowserAdapter.response_error(response)
_view = {}
elif _status == "NotFound":
_error = ""
_view = {}
else:
_error = ""
var payload: Dictionary = BrowserAdapter.detail_payload(response)
_view = BrowserAdapter.detail_view(_kind, payload)
_rebuild_panel()
func enter(payload: Dictionary) -> void:
var kind: String = payload.get("kind", "")
var entity_id: String = payload.get("entity_id", "")
if kind != _kind or entity_id != _entity_id:
set_entity(kind, entity_id)
else:
_rebuild_panel()
func leave() -> void:
pass
# =============================================================================
# Panel
# =============================================================================
func _build_panel() -> void:
_panel = ImplantPanel.new()
_panel.name = "DetailPanel"
_panel.theme_resource = _implant_theme
_panel.custom_minimum_size.x = PANEL_WIDTH
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
add_child(_panel)
func _build_pending(implant_theme) -> void:
_pending = ImplantPending.new()
_pending.apply_implant_theme(implant_theme)
_pending.position = Vector2(PANEL_MARGIN + PANEL_WIDTH + 24.0, PANEL_MARGIN)
add_child(_pending)
func _rebuild_panel() -> void:
if not _panel:
return
_panel.clear()
if _loading:
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id))
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new("querying registry…"))
elif not _error.is_empty():
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id))
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new("[ERROR] " + _error))
elif _status == "NotFound":
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id))
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new("entry not found: " + _entity_id))
else:
_rebuild_ready_panel()
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new("esc back"))
func _rebuild_ready_panel() -> void:
var name_str: String = str(_view.get("name", _entity_id))
var subtitle: String = str(_view.get("subtitle", BrowserAdapter.kind_label(_kind)))
_panel.add_component(ImplantHeader.new(name_str, subtitle))
_panel.add_component(ImplantSeparator.new())
var rows: Array = _view.get("rows", [])
for row: Dictionary in rows:
var label: String = str(row.get("label", ""))
var value: String = str(row.get("value", ""))
_panel.add_component(ImplantDataRow.new("%-22s %s" % [label, value]))
var text_block: String = str(_view.get("text", ""))
if not text_block.is_empty():
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new(text_block))
@@ -0,0 +1,253 @@
class_name BrowserIndexScreen
extends Control
## Index screen for one entity kind in the data browser (T-1133, D-254 §4).
## A scrollable ImplantDataRow list (primary + secondary summary column),
## filterable by a live-typed search string. One instance handles all six
## kinds — parameterized by set_kind(), not six near-duplicate screens.
##
## Filtering (D-254 §4: "filterable/searchable by name"): typed characters
## append to the search buffer and re-filter live; Backspace removes the
## last character; the implant has no OS textbox, so this is the same
## "keyboard IS the input surface" convention the Atlas/Economics apps
## already use for navigation.
signal row_selected(entity_id: String)
signal back_requested
const COLOR_BG: Color = Color("#0d1117")
const PANEL_WIDTH: float = 460.0
const PANEL_MARGIN: float = 16.0
const LIST_MAX_HEIGHT: float = 520.0
var _kind: String = ""
var _all_rows: Array = [] # BrowserAdapter row view-models, unfiltered
var _filtered_rows: Array = []
var _selected_idx: int = 0
var _search: String = ""
var _loading: bool = false
var _error: String = ""
var _panel = null # ImplantPanel
var _scroll: ScrollContainer = null
var _list_box: VBoxContainer = null
var _implant_theme = null # ImplantTheme
var _row_labels: Array = [] # Array[ImplantDataRow], one per filtered row
var _pending: ImplantPending = null
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func setup(implant_theme) -> void:
_implant_theme = implant_theme
_build_panel()
_build_pending(implant_theme)
## Set the entity kind this screen shows and (re)issue the index request.
## filter_system_id is forwarded to SimBridge.request_browse_index — only
## meaningful for kind == BrowserAdapter.KIND_BODY.
func set_kind(kind: String, filter_system_id: String = "") -> void:
_kind = kind
_all_rows = []
_filtered_rows = []
_selected_idx = 0
_search = ""
_error = ""
_loading = true
_rebuild_list()
if _pending:
_pending.start("QUERYING " + BrowserAdapter.kind_label(kind))
SimBridge.request_browse_index(kind, filter_system_id)
## Feed a decoded BrowseResponse (SimBridge.browse_response_received handler,
## owned by BrowserApp) into this screen. Ignored if the response's kind
## doesn't match what this screen currently shows (a stale response arriving
## after the player already navigated elsewhere).
func receive_response(response: Dictionary) -> void:
if response.get("kind", "") != _kind:
return
_loading = false
if _pending:
_pending.stop()
var status: String = BrowserAdapter.response_status(response)
if status == "Error":
_error = BrowserAdapter.response_error(response)
_all_rows = []
else:
_error = ""
_all_rows = BrowserAdapter.index_rows(response)
_apply_filter()
func enter(payload: Dictionary) -> void:
var kind: String = payload.get("kind", "")
var filter_system_id: String = payload.get("filter_system_id", "")
if kind != _kind or _all_rows.is_empty():
set_kind(kind, filter_system_id)
else:
_rebuild_list()
func leave() -> void:
pass
func current_kind() -> String:
return _kind
func has_selection() -> bool:
return not _filtered_rows.is_empty()
func selected_entity_id() -> String:
if _filtered_rows.is_empty():
return ""
_selected_idx = clampi(_selected_idx, 0, _filtered_rows.size() - 1)
return str(_filtered_rows[_selected_idx].get("id", ""))
func trigger_enter() -> void:
if has_selection():
row_selected.emit(selected_entity_id())
func navigate(delta: int) -> void:
if _filtered_rows.is_empty():
return
_selected_idx = wrapi(_selected_idx + delta, 0, _filtered_rows.size())
_rebuild_list()
## Append a character to the live search filter. Called from BrowserApp's
## key handler for printable keys while this screen is current.
func append_search_char(ch: String) -> void:
_search += ch
_apply_filter()
func backspace_search() -> void:
if _search.is_empty():
return
_search = _search.substr(0, _search.length() - 1)
_apply_filter()
func clear_search() -> void:
if _search.is_empty():
return
_search = ""
_apply_filter()
func has_search_text() -> bool:
return not _search.is_empty()
func _apply_filter() -> void:
_filtered_rows = BrowserAdapter.filter_rows(_all_rows, _search)
_selected_idx = 0
_rebuild_list()
# =============================================================================
# Panel / list
# =============================================================================
func _build_panel() -> void:
_panel = ImplantPanel.new()
_panel.name = "IndexPanel"
_panel.theme_resource = _implant_theme
_panel.custom_minimum_size.x = PANEL_WIDTH
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
add_child(_panel)
func _build_pending(implant_theme) -> void:
_pending = ImplantPending.new()
_pending.apply_implant_theme(implant_theme)
_pending.position = Vector2(PANEL_MARGIN + PANEL_WIDTH + 24.0, PANEL_MARGIN)
add_child(_pending)
func _rebuild_list() -> void:
if not _panel:
return
_panel.clear()
_row_labels.clear()
var subtitle: String = "%d / %d" % [_filtered_rows.size(), _all_rows.size()]
_panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), subtitle))
_panel.add_component(ImplantSeparator.new())
var search_line: String = "search " + (_search if not _search.is_empty() else "")
_panel.add_component(ImplantDataRow.new(search_line))
_panel.add_component(ImplantSeparator.new())
if _loading:
_panel.add_component(ImplantTextBlock.new("querying registry…"))
elif not _error.is_empty():
_panel.add_component(ImplantTextBlock.new("[ERROR] " + _error))
elif _filtered_rows.is_empty():
var msg: String = "no matches" if not _search.is_empty() else "no entries"
_panel.add_component(ImplantTextBlock.new(msg))
else:
_build_scroll_list()
_panel.add_component(ImplantSeparator.new())
_panel.add_component(
ImplantTextBlock.new("↑ ↓ select enter open type search esc back")
)
func _build_scroll_list() -> void:
_scroll = ScrollContainer.new()
_scroll.custom_minimum_size = Vector2(PANEL_WIDTH, minf(LIST_MAX_HEIGHT, _list_natural_height()))
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_panel.add_component(_scroll)
_list_box = VBoxContainer.new()
_list_box.add_theme_constant_override("separation", 0)
_list_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scroll.add_child(_list_box)
for i: int in range(_filtered_rows.size()):
var row: Dictionary = _filtered_rows[i]
var primary: String = str(row.get("primary", ""))
var secondary: String = str(row.get("secondary", ""))
var text: String = primary
if not secondary.is_empty():
text += " · " + secondary
var prefix: String = "" if i == _selected_idx else " "
var label := ImplantDataRow.new(prefix + text)
if _implant_theme:
label.apply_implant_theme(_implant_theme)
_list_box.add_child(label)
_row_labels.append(label)
_scroll_to_selected()
func _list_natural_height() -> float:
var line_h: float = float(_implant_theme.line_height) if _implant_theme else 18.0
return float(_filtered_rows.size()) * line_h
func _scroll_to_selected() -> void:
if not _scroll or _row_labels.is_empty():
return
_selected_idx = clampi(_selected_idx, 0, _row_labels.size() - 1)
var target: Control = _row_labels[_selected_idx]
# ensure_control_visible needs one layout pass to have valid rects on a
# freshly-built list — defer so the ScrollContainer has sized itself.
_scroll.ensure_control_visible.call_deferred(target)
@@ -0,0 +1,87 @@
class_name BrowserKindMenuScreen
extends Control
## Root screen for the data browser app (T-1133, D-254 §4).
## Six-entity kind picker — the browser's "reach" screen equivalent. Emits
## kind_selected when the player commits to one of the six v1 entity kinds.
signal kind_selected(kind: String)
const COLOR_BG: Color = Color("#0d1117")
const PANEL_WIDTH: float = 340.0
const PANEL_MARGIN: float = 16.0
var _selected_idx: int = 0
var _panel = null # ImplantPanel
var _rows: Array = [] # Array[ImplantDataRow], one per BrowserAdapter.KIND_ORDER entry
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func setup(implant_theme) -> void:
_build_panel(implant_theme)
func enter(_payload: Dictionary) -> void:
_rebuild_panel()
func leave() -> void:
pass
func navigate(delta: int) -> void:
var count: int = BrowserAdapter.KIND_ORDER.size()
_selected_idx = wrapi(_selected_idx + delta, 0, count)
_rebuild_panel()
func trigger_enter() -> void:
kind_selected.emit(BrowserAdapter.KIND_ORDER[_selected_idx])
func current_kind() -> String:
return BrowserAdapter.KIND_ORDER[_selected_idx]
# =============================================================================
# Panel
# =============================================================================
func _build_panel(implant_theme) -> void:
_panel = ImplantPanel.new()
_panel.name = "KindMenuPanel"
_panel.theme_resource = implant_theme
_panel.custom_minimum_size.x = PANEL_WIDTH
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
add_child(_panel)
_rebuild_panel()
func _rebuild_panel() -> void:
if not _panel:
return
_panel.clear()
_rows.clear()
_panel.add_component(ImplantHeader.new("DATA BROWSER", "REGISTRY INDEX"))
_panel.add_component(ImplantSeparator.new())
for i: int in range(BrowserAdapter.KIND_ORDER.size()):
var kind: String = BrowserAdapter.KIND_ORDER[i]
var label: String = BrowserAdapter.kind_label(kind)
var prefix: String = "" if i == _selected_idx else " "
var row := ImplantDataRow.new(prefix + label)
_panel.add_component(row)
_rows.append(row)
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new("↑ ↓ select enter open esc close"))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,6 +9,8 @@ pub mod believability;
pub mod block_irregularity;
pub mod body_params_reader;
pub mod body_world_state;
pub mod browse_proxy;
pub mod browse_reader;
pub mod cascade;
pub mod chunk_context;
pub mod city_context_reader;
+34 -3
View File
@@ -19,6 +19,8 @@ use crate::atlas::atlas_data_proxy::{
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::body_params_reader::BodyParamsReaderResource;
use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
use crate::atlas::browse_proxy::handle_browse_request;
use crate::atlas::browse_reader::BrowseReaderResource;
use crate::atlas::city_context_reader::{
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
};
@@ -41,8 +43,8 @@ use crate::atlas::trait_swerve::{
build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools,
};
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer,
StarMapRequestBuffer, StarMapResponseBuffer,
AtlasRequestBuffer, AtlasResponseBuffer, BrowseRequestBuffer, BrowseResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
@@ -68,7 +70,8 @@ impl Plugin for GenerationPlugin {
.add_systems(
Update,
serve_city_names_requests.in_set(TickPhase::PreInput),
);
)
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput));
}
}
@@ -170,6 +173,34 @@ fn serve_city_names_requests(
}
}
/// Drain inbound data-browser requests and serve each through the proxy
/// (D-254 §4, T-1131): one of the six v1 registry-tier entity kinds, dispatched
/// to `BrowseReader` by `(kind, query)`.
///
/// `pub` (unlike its atlas/star-map/city-names siblings, which stay private)
/// so `server/tests/bridge_tcp.rs`'s browse integration tests can drive the
/// TRUE full pipeline (demux -> receive_bridge_inputs -> BrowseRequestBuffer
/// -> serve_browse_requests -> BrowseResponseBuffer -> send_browse_responses)
/// end-to-end via `RunSystemOnce`, rather than bypassing this system the way
/// `reader_receives_tagged_star_map_response` bypasses `serve_star_map_requests`
/// (see that test's own doc comment) because it has no way to call it.
pub fn serve_browse_requests(
mut requests: ResMut<BrowseRequestBuffer>,
mut responses: ResMut<BrowseResponseBuffer>,
browse_reader: Option<Res<BrowseReaderResource>>,
) {
if requests.0.is_empty() {
return;
}
let reader = browse_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for (conn_id, req) in pending {
responses
.0
.push((conn_id, handle_browse_request(&req, reader)));
}
}
/// Drain finished background work each tick and apply it to the cache (D-206).
///
/// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap
+11
View File
@@ -4,6 +4,7 @@
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::bridge::framing::{read_framed, write_framed};
use std::fs;
@@ -166,6 +167,16 @@ impl SimBridge for LocalBridge {
write_framed(writer.get_mut(), &payload)?;
Ok(())
}
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
write_framed(writer.get_mut(), &payload)?;
Ok(())
}
}
impl Drop for LocalBridge {
+144 -11
View File
@@ -9,6 +9,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::atlas::atlas_data_proxy::{
CityNamesRequest, CityNamesResponse, StarMapRequest, StarMapResponse,
};
use crate::atlas::browse_proxy::{BrowseRequest, BrowseResponse};
use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse};
use crate::bridge::tcp::TcpBridge;
@@ -63,9 +64,15 @@ pub enum BridgeError {
/// frames that carry more than one shape's discriminators outright (PR #176
/// review H1). `AtlasLayerRequest` itself is untouched byte-for-byte.
///
/// **Ceiling (D-225 trajectory):** four shapes is the practical limit of this
/// hand-rolled sniffing. The next new inbound shape must migrate the channel
/// to the tagged-envelope framing D-225 deferred — do not add a fifth probe.
/// **Ceiling (D-225 trajectory):** [`BrowseRequest`] (T-1131) is the FIFTH
/// map shape and, per the ceiling this doc already called at four, the last
/// one this hand-rolled scheme should ever carry — it stays at five only
/// because six entity kinds x two forms were folded into ONE new shape
/// (`browse`'s own internal `kind`/`query` enums pick the sub-behavior,
/// exactly as `AtlasLayerRequest.up_to: CascadeLayer` already does) rather
/// than added as twelve more top-level shapes. The next genuinely NEW
/// inbound shape (a sixth) must migrate the channel to the tagged-envelope
/// framing D-225 deferred — do not add a sixth probe.
#[derive(Debug)]
pub enum Inbound {
/// A batch of player inputs (the gameplay path).
@@ -76,6 +83,9 @@ pub enum Inbound {
StarMapRequest(StarMapRequest),
/// A per-body city-names request (T-949b).
CityNamesRequest(CityNamesRequest),
/// A data-browser request — one of the six D-254 §4 v1 entity kinds
/// (T-1131).
BrowseRequest(BrowseRequest),
}
/// Key-presence probe for the defensive multi-shape check in
@@ -88,12 +98,14 @@ struct ShapeProbe {
up_to: Option<serde::de::IgnoredAny>,
star_map: Option<serde::de::IgnoredAny>,
city_names: Option<serde::de::IgnoredAny>,
browse: Option<serde::de::IgnoredAny>,
}
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries,
/// in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131).
/// Tries, in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
/// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) →
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`).
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`)
/// `BrowseRequest` (map, `browse` discriminator).
///
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
@@ -101,7 +113,7 @@ struct ShapeProbe {
/// more than one shape — e.g. a buggy encoder emitting
/// `{"star_map": true, "city_names": true, ...}` — instead of silently
/// routing it to whichever shape is tried first (PR #176 review H1). A frame
/// satisfying none of the four shapes is a genuinely malformed input frame.
/// satisfying none of the five shapes is a genuinely malformed input frame.
pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
return Ok(Inbound::Inputs(inputs));
@@ -114,15 +126,20 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
let atlas = probe.body_id.is_some() && probe.up_to.is_some();
let star_map = probe.star_map.is_some();
let city_names = probe.city_names.is_some();
let shapes = usize::from(atlas) + usize::from(star_map) + usize::from(city_names);
let browse = probe.browse.is_some();
let shapes = usize::from(atlas)
+ usize::from(star_map)
+ usize::from(city_names)
+ usize::from(browse);
if shapes > 1 {
let dump_len = payload.len().min(256);
tracing::error!(
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
shapes,
atlas,
star_map,
city_names,
browse,
dump_len,
payload.len(),
&payload[..dump_len]
@@ -139,8 +156,11 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(req) = rmp_serde::from_slice::<StarMapRequest>(payload) {
return Ok(Inbound::StarMapRequest(req));
}
match rmp_serde::from_slice::<CityNamesRequest>(payload) {
Ok(req) => Ok(Inbound::CityNamesRequest(req)),
if let Ok(req) = rmp_serde::from_slice::<CityNamesRequest>(payload) {
return Ok(Inbound::CityNamesRequest(req));
}
match rmp_serde::from_slice::<BrowseRequest>(payload) {
Ok(req) => Ok(Inbound::BrowseRequest(req)),
Err(e) => {
let dump_len = payload.len().min(256);
tracing::error!(
@@ -189,6 +209,9 @@ pub trait SimBridge: Send + Sync {
/// Send a city-names response to the client (T-949b).
fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError>;
/// Send a browse response to the client (T-1131).
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError>;
}
/// Identifies one connection for response-tagging and role-lookup purposes
@@ -433,6 +456,27 @@ impl BridgeResource {
}
}
}
/// Send a browse response to exactly the connection that requested it
/// (T-1131 — same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names).
pub fn send_browse_response_to(
&self,
id: ConnectionId,
resp: &BrowseResponse,
) -> Result<(), BridgeError> {
match self.connection(id) {
Some(c) => c.bridge.send_browse_response(resp),
None => {
tracing::debug!(
"browse response for {:?} dropped — connection {:?} no longer present",
resp.kind,
id
);
Ok(())
}
}
}
}
/// Tracks whether the protocol handshake has been sent (#555).
@@ -484,6 +528,7 @@ pub fn receive_bridge_inputs(
mut atlas_requests: ResMut<AtlasRequestBuffer>,
mut star_map_requests: ResMut<StarMapRequestBuffer>,
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
mut browse_requests: ResMut<BrowseRequestBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(mut bridge) = bridge else { return };
@@ -526,6 +571,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::CityNamesRequest(req))) => {
city_names_requests.0.push((player_id, req));
}
Ok(Some(Inbound::BrowseRequest(req))) => {
browse_requests.0.push((player_id, req));
}
// No complete frame ready — the backlog is drained.
Ok(None) => break,
Err(BridgeError::Disconnected) => {
@@ -637,6 +685,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::CityNamesRequest(req))) => {
city_names_requests.0.push((reader_id, req));
}
Ok(Some(Inbound::BrowseRequest(req))) => {
browse_requests.0.push((reader_id, req));
}
Ok(None) => break,
Err(BridgeError::Disconnected) => {
tracing::info!("Reader connection {:?} disconnected", reader_id);
@@ -851,6 +902,36 @@ pub fn send_city_names_responses(
}
}
/// Inbound data-browser requests routed off the bridge (T-1131), drained by
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct BrowseRequestBuffer(pub Vec<(ConnectionId, BrowseRequest)>);
/// Outbound browse responses, filled by the proxy serve system and flushed
/// to the client in `PostSnapshot` (T-1131). Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct BrowseResponseBuffer(pub Vec<(ConnectionId, BrowseResponse)>);
/// Flush buffered browse responses to their requesting connections (T-1131 —
/// same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names). A failed send is logged but not fatal.
pub fn send_browse_responses(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<BrowseResponseBuffer>,
) {
let Some(bridge) = bridge else { return };
for (id, resp) in buffer.0.drain(..) {
if let Err(e) = bridge.send_browse_response_to(id, &resp) {
tracing::warn!(
"failed to send browse response for {:?} to {:?}: {}",
resp.kind,
id,
e
);
}
}
}
/// Holds the server's TCP listener for accepting connections AFTER the
/// first Player connection (D-254 §2, T-1130).
///
@@ -1008,6 +1089,8 @@ impl Plugin for BridgePlugin {
.init_resource::<StarMapResponseBuffer>()
.init_resource::<CityNamesRequestBuffer>()
.init_resource::<CityNamesResponseBuffer>()
.init_resource::<BrowseRequestBuffer>()
.init_resource::<BrowseResponseBuffer>()
.init_resource::<ConnectionListener>()
.init_resource::<PendingConnections>()
// Multi-connection accept-loop (D-254 §2, T-1130) — must run
@@ -1032,6 +1115,10 @@ impl Plugin for BridgePlugin {
Update,
send_city_names_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_browse_responses.in_set(TickPhase::PostSnapshot),
)
// Debug commands — Snapshot phase
.add_systems(
Update,
@@ -1229,5 +1316,51 @@ mod inbound_tests {
decode_inbound(&frame).is_err(),
"atlas+star_map union frame must be rejected"
);
// T-1131 (PR #184 review): the FIFTH shape's discriminator (`browse`)
// must participate in the same union rejection — a well-formed
// BrowseRequest smuggling another shape's discriminator alongside it
// is rejected, not routed to whichever probe wins.
#[derive(serde::Serialize)]
struct BrowseAndStar {
browse: bool,
kind: crate::atlas::browse_proxy::BrowseEntityKind,
query: crate::atlas::browse_proxy::BrowseQuery,
star_map: bool,
}
let frame = rmp_serde::to_vec_named(&BrowseAndStar {
browse: true,
kind: crate::atlas::browse_proxy::BrowseEntityKind::StarSystem,
query: crate::atlas::browse_proxy::BrowseQuery::Index {
filter_system_id: None,
},
star_map: true,
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"browse+star_map union frame must be rejected"
);
#[derive(serde::Serialize)]
struct BrowseAndCity {
browse: bool,
kind: crate::atlas::browse_proxy::BrowseEntityKind,
query: crate::atlas::browse_proxy::BrowseQuery,
city_names: bool,
body_id: String,
}
let frame = rmp_serde::to_vec_named(&BrowseAndCity {
browse: true,
kind: crate::atlas::browse_proxy::BrowseEntityKind::Body,
query: crate::atlas::browse_proxy::BrowseQuery::Detail { id: "GJ1c".into() },
city_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"browse+city_names union frame must be rejected"
);
}
}
+15
View File
@@ -5,6 +5,7 @@
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::bridge::framing::{read_framed, write_framed, FrameAccumulator};
use std::io::BufWriter;
@@ -313,6 +314,20 @@ impl SimBridge for TcpBridge {
result?;
Ok(())
}
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
let stream = writer.get_mut();
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
let result = write_framed(stream, &payload);
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
result?;
Ok(())
}
}
/// A connection that has been TCP-accepted but has not yet completed the
+282 -2
View File
@@ -180,7 +180,7 @@ fn main() {
// Initialize culture resolver (#679, D-128).
// systems.db is shipped read-only alongside the binary.
let systems_db_path = std::path::PathBuf::from("data/systems.db");
let systems_db_path = resolve_systems_db_path();
match settled_reach_server::knowledge::CultureResolver::open(&systems_db_path) {
Ok(resolver) => {
tracing::info!("Culture resolver opened: {:?}", systems_db_path);
@@ -198,7 +198,16 @@ fn main() {
// Mod-first body source resolver for the atlas layer proxy (#969, D-225).
// terrain_reference is repo-root-relative; the repo root is systems.db's
// 3rd ancestor (<repo>/server/data/systems.db).
// 3rd ancestor (<repo>/server/data/systems.db). This ancestor arithmetic
// is only correct against an ABSOLUTE path — `.canonicalize()` resolves a
// *relative* path against the CURRENT CWD, so if `systems_db_path` were
// still cwd-relative (as it was before `resolve_systems_db_path()`, T-1131
// follow-up), a wrong-cwd launch (e.g. the D-254 companion spawning from
// the repo root) would make `.canonicalize()` fail outright, falling back
// to the nonsense `".."` default below. `resolve_systems_db_path()`
// already verified `systems_db_path` exists before returning it, so
// `.canonicalize()` here always succeeds and `nth(3)` is genuinely correct
// — not "pretends to work by accident when cwd happens to be server/".
let world_root = systems_db_path
.canonicalize()
.ok()
@@ -253,6 +262,25 @@ fn main() {
),
}
// Data browser reader (D-254 §4, T-1131): read-only access to the six v1
// registry-tier entity kinds (star systems, bodies, stations,
// corporations, commodities, trait templates) for the companion app's
// browse UI. Absent -> BrowseRequests are answered with an Error status
// per request rather than a hard failure (matches every other reader's
// "unavailable, log + degrade" convention below).
match settled_reach_server::atlas::browse_reader::BrowseReader::open(&systems_db_path) {
Ok(reader) => {
tracing::info!("Browse reader opened: {:?}", systems_db_path);
app.insert_resource(
settled_reach_server::atlas::browse_reader::BrowseReaderResource(reader),
);
}
Err(e) => tracing::warn!(
"Browse reader unavailable ({}). Browse requests will error.",
e
),
}
// Body physical params reader for DistrictProfile carrier layer (T-1032, D-239 §1, D-240):
// reads hydrosphere / atmosphere / planet_class on a cache miss so the Rayon
// cascade work item stays DB-free (D-225 pattern).
@@ -465,6 +493,117 @@ fn main() {
tracing::info!("Simulation server shutting down");
}
/// Candidate `systems.db` paths, in try-order, for a given executable path
/// (T-1131 follow-up). Pure/no I/O — the ONLY thing that makes this
/// deterministic and unit-testable given `exe_path` (unlike
/// [`resolve_systems_db_path`], which additionally calls
/// `std::env::current_exe()` and stats the filesystem). Kept separate
/// specifically so the candidate ORDER and SHAPE can be tested without a
/// process-spawning harness — see `tests::` below.
///
/// 1. Exe-anchored `<exe_dir>/../../data/systems.db` (unjoined — the caller
/// canonicalizes and existence-checks; this function never touches disk).
/// Only present if `exe_path` has a parent directory.
/// 2. Cwd-relative `data/systems.db` (today's pre-fix behavior —
/// `cd server && cargo run` leaves cwd at `server/`).
/// 3. Cwd-relative `server/data/systems.db` (repo-root invocations).
fn systems_db_candidates(exe_path: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
let mut candidates = Vec::with_capacity(3);
if let Some(exe_dir) = exe_path.and_then(std::path::Path::parent) {
candidates.push(exe_dir.join("../../data/systems.db"));
}
candidates.push(std::path::PathBuf::from("data/systems.db"));
candidates.push(std::path::PathBuf::from("server/data/systems.db"));
candidates
}
/// Resolve `systems.db`'s path, ANCHORED TO THE EXECUTABLE rather than the
/// current working directory (T-1131 follow-up).
///
/// **The bug this fixes:** `PathBuf::from("data/systems.db")` is cwd-relative.
/// `make game` (`cd server && cargo run`) happens to leave cwd at `server/`,
/// so that path resolves — but the D-254 companion app spawns this binary via
/// Godot's `OS.create_process`/`OS.execute_with_pipe` (`server_process.gd`),
/// neither of which sets a working directory: the child inherits GODOT's cwd,
/// which for `make atlas`/`make game` is the REPO ROOT (the Makefile has no
/// `cd` before launching Godot itself — only before `cargo run`). From the
/// repo root, `data/systems.db` doesn't exist (it's `server/data/systems.db`),
/// so every DB-backed reader (`CultureResolver`, `CityContextReader`, and now
/// `BrowseReader`) silently fails to open in every spawned-server context.
/// This went unnoticed through T-1130's wave 1 because the star map is served
/// from `star_map_data.json` via `world_root` (itself derived from
/// `systems_db_path`, so ALSO broken — but `.exists()`-checked with a `warn`,
/// not a hard dependency any single-connection smoke test would surface) —
/// "renders 301 systems" never actually touched `systems.db`.
///
/// **The fix:** resolve relative to `std::env::current_exe()` first — in the
/// dev build layout the binary is `server/target/debug/settled-reach-server`,
/// so `exe_dir/../../data/systems.db` is `server/data/systems.db` regardless
/// of cwd. Falls through to the two cwd-relative candidates (today's
/// behavior, and the repo-root equivalent) so `cd server && cargo run` and a
/// repo-root-relative invocation both keep working without needing the
/// exe-anchoring to succeed (e.g. `current_exe()` can fail in exotic
/// sandboxed environments per its own documented caveats).
///
/// Candidate order/shape lives in [`systems_db_candidates`] (pure,
/// unit-tested); this function adds the I/O layer: canonicalize + existence
/// check per candidate, first EXISTING one wins, with an `info` log
/// recording which candidate resolved (so a future "browse reader
/// unavailable" report is diagnosable from the startup log alone).
///
/// If none exist, returns the cwd-relative `data/systems.db` default —
/// today's pre-fix behavior — so every downstream `Reader::open()` call
/// still gets a path to fail on and log its own existing
/// `warn`-and-degrade message. This function does not invent a new failure
/// mode, it just tries harder before giving up.
fn resolve_systems_db_path() -> std::path::PathBuf {
let exe_path = std::env::current_exe().ok();
let candidates = systems_db_candidates(exe_path.as_deref());
for candidate in &candidates {
let canonical = candidate.canonicalize();
if let Ok(ref resolved) = canonical {
if resolved.exists() {
tracing::info!(
"systems.db resolved: {:?} (candidate: {:?}, exe: {:?})",
resolved,
candidate,
exe_path
);
return resolved.clone();
}
} else if candidate.exists() {
// canonicalize() can fail even when the path exists (e.g. a
// component permission error) — exists() is the true signal;
// canonicalize() is just how we get an absolute path for
// world_root's ancestor arithmetic to work correctly.
tracing::info!(
"systems.db resolved (uncanonicalized): {:?} (exe: {:?})",
candidate,
exe_path
);
return candidate.clone();
}
}
// None of the candidates exist. Fall back to the cwd-relative
// `data/systems.db` default — today's pre-fix behavior — NOT the
// exe-anchored candidate (which, per systems_db_candidates' doc, is
// unjoined/uncanonicalized and only meaningful once verified to exist;
// returning it here unverified would be a worse default than the plain
// relative path every downstream Reader::open() already knows how to
// fail on cleanly).
let fallback = std::path::PathBuf::from("data/systems.db");
tracing::warn!(
"systems.db not found via any of {:?} (exe: {:?}) — falling back to {:?} \
(every DB-backed reader will report unavailable and degrade)",
candidates,
exe_path,
fallback
);
fallback
}
/// Best-effort: send a final SimError snapshot to the client on panic (#85).
///
/// Builds a minimal ObserverSnapshot with the panic error and sends it
@@ -791,3 +930,144 @@ fn setup_proof_room(app: &mut App, world_seed: u64) {
app.insert_resource(registry);
}
#[cfg(test)]
mod tests {
use super::*;
/// T-1131 follow-up: the exe-anchored candidate must resolve to
/// `server/data/systems.db` from the DEV BUILD LAYOUT exe path
/// (`server/target/debug/settled-reach-server`) — this is the whole
/// point of the fix, so pin the exact join shape, not just "some path
/// containing systems.db".
#[test]
fn exe_anchored_candidate_targets_server_data_from_dev_build_layout() {
let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server");
let candidates = systems_db_candidates(Some(exe));
assert_eq!(
candidates.len(),
3,
"exe with a parent dir must produce all three candidates"
);
assert_eq!(
candidates[0],
std::path::PathBuf::from("/repo/server/target/debug/../../data/systems.db"),
"exe-anchored candidate must be unjoined (caller canonicalizes) \
but built from exe_dir/../../data/systems.db"
);
// The whole point: once normalized (what canonicalize() does at
// runtime against a real filesystem), this lands on
// /repo/server/data/systems.db — the actual DB location — not
// /repo/data/systems.db (the pre-fix cwd-relative bug's target).
let normalized = normalize_lexically(&candidates[0]);
assert_eq!(
normalized,
std::path::PathBuf::from("/repo/server/data/systems.db")
);
}
/// The two cwd-relative fallback candidates are present regardless of
/// whether an exe path resolved, in the documented order: `data/systems.db`
/// before `server/data/systems.db` (today's pre-fix behavior stays the
/// first fallback, not silently reordered behind the new repo-root case).
#[test]
fn cwd_relative_candidates_present_and_ordered_when_exe_path_is_some() {
let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server");
let candidates = systems_db_candidates(Some(exe));
assert_eq!(candidates[1], std::path::PathBuf::from("data/systems.db"));
assert_eq!(
candidates[2],
std::path::PathBuf::from("server/data/systems.db")
);
}
/// `current_exe()` can fail (documented caveat, e.g. sandboxed
/// environments) — `None` must degrade to exactly the two cwd-relative
/// candidates, not panic or produce a malformed exe-anchored entry.
#[test]
fn no_exe_path_yields_only_the_two_cwd_relative_candidates() {
let candidates = systems_db_candidates(None);
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db"));
assert_eq!(
candidates[1],
std::path::PathBuf::from("server/data/systems.db")
);
}
/// An exe path that IS genuinely parentless (`Path::parent()` returns
/// `None` only for the empty path or filesystem root — confirmed against
/// the standard library, not assumed) must not panic and must degrade
/// the same as `exe_path: None`.
#[test]
fn genuinely_parentless_exe_path_degrades_like_no_exe_path() {
let exe = std::path::Path::new("");
assert!(
exe.parent().is_none(),
"test premise: Path::new(\"\").parent() must be None"
);
let candidates = systems_db_candidates(Some(exe));
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db"));
}
/// A bare relative filename with no directory separator (e.g. the exe
/// path Godot's `OS.create_process` might report on some platform/launch
/// combination) is NOT the parentless case above — `Path::parent()`
/// returns `Some("")` for it (an empty-but-present parent), a real
/// standard-library quirk worth pinning explicitly since it's easy to
/// assume `.parent()` is `None` whenever there's "no directory in the
/// string". The exe-anchored candidate still gets produced (joined onto
/// the empty parent), just degenerately — `../../data/systems.db`
/// relative to cwd, which is harmless: it'll fail existence-checks
/// exactly like any other wrong candidate and fall through the loop.
#[test]
fn bare_filename_exe_path_has_an_empty_but_present_parent() {
let exe = std::path::Path::new("settled-reach-server");
assert_eq!(
exe.parent(),
Some(std::path::Path::new("")),
"Path::parent() of a bare filename is Some(\"\"), not None — \
pinning this stdlib behavior since it's the reason a bare \
filename still produces 3 candidates, not 2"
);
let candidates = systems_db_candidates(Some(exe));
assert_eq!(
candidates.len(),
3,
"a present-but-empty parent still yields an exe-anchored candidate"
);
assert_eq!(
candidates[0],
std::path::PathBuf::from("../../data/systems.db"),
"joined onto an empty parent, the exe-anchored candidate is bare \
../../data/systems.db (cwd-relative in practice, but still a \
DISTINCT candidate from candidates[1]'s exact data/systems.db)"
);
}
/// Lexical `..`/`.` normalization for test assertions ONLY — a stand-in
/// for `Path::canonicalize()` (which needs a real filesystem + cwd,
/// which unit tests must not depend on per the coordinator's "don't
/// build a process-spawning/filesystem harness for this" guidance).
/// `resolve_systems_db_path` itself still uses the real
/// `canonicalize()` at runtime — this helper exists only so
/// `exe_anchored_candidate_targets_server_data_from_dev_build_layout`
/// can assert the join shape actually lands on the right final path
/// without touching disk.
fn normalize_lexically(path: &std::path::Path) -> std::path::PathBuf {
let mut out = std::path::PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
}
+499 -6
View File
@@ -1,9 +1,12 @@
//! Integration tests for TcpBridge over TCP localhost (D-030 Layer 2: IPC roundtrip).
use settled_reach_server::atlas::browse_proxy::{
BrowseEntityKind, BrowseQuery, BrowseRequest, BrowseResponse, BrowseStatus,
};
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{Inbound, SimBridge};
use settled_reach_server::bridge::{BridgeResource, Inbound, SimBridge};
use settled_reach_server::simulation::time::{DayPhase, TickRate};
use std::net::{TcpListener, TcpStream};
use std::thread;
@@ -338,8 +341,8 @@ fn single_tick_drains_all_ready_inbound_frames() {
use settled_reach_server::atlas::cascade::CascadeLayer;
use settled_reach_server::atlas::layer_proxy::AtlasLayerRequest;
use settled_reach_server::bridge::{
receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, CityNamesRequestBuffer,
HandshakeState, ServerRunning, StarMapRequestBuffer,
receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, BrowseRequestBuffer,
CityNamesRequestBuffer, HandshakeState, ServerRunning, StarMapRequestBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
@@ -395,6 +398,7 @@ fn single_tick_drains_all_ready_inbound_frames() {
world.init_resource::<AtlasRequestBuffer>();
world.init_resource::<StarMapRequestBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world.init_resource::<BrowseRequestBuffer>();
world
.run_system_once(receive_bridge_inputs)
@@ -489,9 +493,10 @@ fn drive_accept_loop_until(
/// buffers), bound to a fresh OS-assigned port.
fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr) {
use settled_reach_server::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, CityNamesRequestBuffer,
CityNamesResponseBuffer, ConnectionListener, HandshakeState, PendingConnections,
ServerRunning, SnapshotBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, BrowseRequestBuffer,
BrowseResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer, ConnectionListener,
HandshakeState, PendingConnections, ServerRunning, SnapshotBuffer, StarMapRequestBuffer,
StarMapResponseBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
@@ -515,6 +520,8 @@ fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr
world.init_resource::<StarMapResponseBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world.init_resource::<CityNamesResponseBuffer>();
world.init_resource::<BrowseRequestBuffer>();
world.init_resource::<BrowseResponseBuffer>();
world.init_resource::<SnapshotBuffer>();
(world, addr)
}
@@ -898,6 +905,492 @@ fn second_player_attempt_is_cleanly_rejected() {
);
}
// ─────────────────────────────────────────────────────────────────────────
// T-1131: data browser (D-254 §4) end-to-end over the real TCP wire.
//
// These tests exercise the FULL pipeline — demux (decode_inbound) ->
// receive_bridge_inputs (drain + connection-tag) -> BrowseRequestBuffer ->
// serve_browse_requests (the atlas-plugin proxy system) -> BrowseResponseBuffer
// -> send_browse_responses -> the socket — not just the buffer-push shortcut
// `reader_receives_tagged_star_map_response` uses above, since T-1131's own
// ticket text calls for the connection-tagging proof specifically over
// requests that actually round-trip through the demux.
// ─────────────────────────────────────────────────────────────────────────
/// Build a fixture `systems.db`-shaped file with one row in each of the six
/// v1 browse tables, wired as a `BrowseReaderResource` into `world`. A
/// self-contained minimal fixture local to THIS file — `browse_reader`'s own
/// (larger, column-exhaustive) fixture lives behind `#[cfg(test)]` in the
/// library crate, which is only compiled for `cargo test --lib`, not for a
/// separate integration-test binary linking against the built library (a
/// cross-crate `#[cfg(test)]` visibility boundary — confirmed by attempting
/// the reuse first). This file only needs to prove the WIRE plumbing (demux
/// -> serve -> send), not the SQL correctness `browse_reader`'s own unit
/// tests already verify column-by-column, so a minimal one-row-per-table
/// fixture is the right scope here, not a duplicate of the exhaustive one.
fn wire_browse_reader(world: &mut bevy_ecs::world::World) {
use settled_reach_server::atlas::browse_reader::{BrowseReader, BrowseReaderResource};
let db_path = std::env::temp_dir().join(format!(
"sr_browse_tcp_fixture_{}_{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).expect("create fixture db");
conn.execute_batch(
"CREATE TABLE star_systems (
system_id TEXT PRIMARY KEY, proper_name TEXT, system_name TEXT,
star_type TEXT, spectral_class TEXT, dist_ly REAL,
geographic_sector TEXT, geographic_band TEXT, political_zone TEXT,
habitable_planet_count INTEGER, inhabited_planet_count INTEGER,
asteroid_belt INTEGER, gas_giant INTEGER, habitability_profile TEXT,
earth_alignment TEXT, earth_proximity TEXT, earth_tension TEXT,
stability_index INTEGER, system_volatility TEXT, cultural_corridor TEXT,
currency_zone TEXT
);
CREATE TABLE system_economy (
system_id TEXT PRIMARY KEY, economic_tier INTEGER, population INTEGER,
economic_base_primary TEXT, economic_base_secondary TEXT
);
CREATE TABLE system_factions (
system_id TEXT PRIMARY KEY, governance_type TEXT, dominant_faction TEXT
);
CREATE TABLE system_culture (
system_id TEXT PRIMARY KEY, cultural_register TEXT,
atmospheric_tone TEXT, primary_archetype TEXT
);
CREATE TABLE bodies (
body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, parent_body_id TEXT,
body_type TEXT NOT NULL, orbit_index INTEGER, proper_name TEXT,
mass_class TEXT, atmosphere TEXT, surface_gravity REAL,
orbital_period_days REAL, rotation_period_hours REAL, planet_class TEXT,
hydrosphere TEXT, biosphere_class TEXT, inhabited INTEGER NOT NULL DEFAULT 0,
population INTEGER, economic_role TEXT, founding_age_years INTEGER,
settlement_pattern TEXT, cultural_corridor TEXT, industrial_corridor TEXT,
body_radius_km REAL, axial_tilt_deg REAL
);
CREATE TABLE stations (
station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT,
station_type TEXT NOT NULL, proper_name TEXT, population INTEGER,
economic_role TEXT, governance_type TEXT, docking_class TEXT,
has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER
);
CREATE TABLE corporations (
corp_id TEXT PRIMARY KEY, proper_name TEXT NOT NULL, corp_type TEXT NOT NULL,
scope TEXT, headquarters_system TEXT, headquarters_body TEXT,
specialization TEXT, parent_corp TEXT, notes TEXT, behavioral_archetype TEXT,
supply_chain_role TEXT, shadow_economy_access INTEGER DEFAULT 0,
corp_specialization TEXT, hq_placement TEXT
);
CREATE TABLE corp_presence (
corp_id TEXT NOT NULL, location_id TEXT NOT NULL, location_type TEXT NOT NULL,
primary_operation TEXT, PRIMARY KEY (corp_id, location_id)
);
CREATE TABLE corp_financial_state (corp_id TEXT PRIMARY KEY, health_metric REAL NOT NULL DEFAULT 1.0);
CREATE TABLE commodities (
commodity_id TEXT PRIMARY KEY, name TEXT NOT NULL, tier TEXT NOT NULL,
elasticity TEXT NOT NULL, base_price REAL NOT NULL, bulk_class TEXT,
unit TEXT, production_ubiquity TEXT, demand_model TEXT,
commission_certifiable INTEGER DEFAULT 0, compact_contested INTEGER DEFAULT 0,
shadow_viable INTEGER DEFAULT 0, panic_threshold_weeks INTEGER DEFAULT 0, description TEXT
);
CREATE TABLE production_chains (
chain_id TEXT PRIMARY KEY, output_commodity_id TEXT NOT NULL,
output_quantity REAL NOT NULL DEFAULT 1.0, location_bound INTEGER DEFAULT 0, description TEXT
);
CREATE TABLE chain_inputs (
chain_id TEXT NOT NULL, input_commodity_id TEXT NOT NULL, quantity REAL NOT NULL,
PRIMARY KEY (chain_id, input_commodity_id)
);
CREATE TABLE trait_templates (
tag TEXT PRIMARY KEY, label TEXT NOT NULL, cultural_description TEXT,
corridor_pool TEXT NOT NULL DEFAULT 'baseline', geographic_sector TEXT,
bulk_class_gate TEXT, production_ubiquity_gate TEXT,
min_prosperity_bps INTEGER NOT NULL DEFAULT 0, base_weight INTEGER NOT NULL DEFAULT 10000,
weight_mods TEXT, zone_affinity TEXT, allow_tags TEXT, block_tags TEXT,
era_scope TEXT, visual_bundle TEXT
);",
)
.expect("create fixture tables");
conn.execute(
"INSERT INTO star_systems (system_id, proper_name, star_type) VALUES ('GJ-1', 'Aldren', 'M')",
[],
)
.expect("insert star system");
conn.execute(
"INSERT INTO bodies (body_id, system_id, body_type, proper_name, inhabited)
VALUES ('GJ1c', 'GJ-1', 'planet', 'Aldren Prime', 1)",
[],
)
.expect("insert body");
conn.execute(
"INSERT INTO stations (station_id, system_id, station_type, proper_name)
VALUES ('GJ1c-S1', 'GJ-1', 'commercial', 'Aldren Orbital')",
[],
)
.expect("insert station");
conn.execute(
"INSERT INTO corporations (corp_id, proper_name, corp_type, headquarters_system)
VALUES ('gate-corporation', 'Gate Corporation', 'corporation', 'GJ-1')",
[],
)
.expect("insert corp");
conn.execute(
"INSERT INTO commodities (commodity_id, name, tier, elasticity, base_price)
VALUES ('fusion_fuel', 'Fusion Fuel', 'intermediate', 'inelastic', 42.5)",
[],
)
.expect("insert commodity");
conn.execute(
"INSERT INTO trait_templates (tag, label) VALUES ('frontier_utilitarian', 'Frontier Utilitarian')",
[],
)
.expect("insert trait template");
}
let reader = BrowseReader::open(&db_path).expect("open fixture browse db");
world.insert_resource(BrowseReaderResource(reader));
}
/// Drive one full server tick's worth of browse plumbing: `receive_bridge_inputs`
/// (drains the socket into `BrowseRequestBuffer`, connection-tagged),
/// `serve_browse_requests` (the atlas-plugin proxy — reads `BrowseReaderResource`,
/// fills `BrowseResponseBuffer`), and `send_browse_responses` (flushes back
/// out to the originating connection). Matches how `BridgePlugin` +
/// `GenerationPlugin` actually schedule these three systems in `PreInput`/
/// `PostSnapshot`, just run directly rather than through a full `App`.
fn drive_browse_tick(world: &mut bevy_ecs::world::World) {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::atlas::plugin::serve_browse_requests;
use settled_reach_server::bridge::{receive_bridge_inputs, send_browse_responses};
world
.run_system_once(receive_bridge_inputs)
.expect("receive_bridge_inputs failed to run");
world
.run_system_once(serve_browse_requests)
.expect("serve_browse_requests failed to run");
world
.run_system_once(send_browse_responses)
.expect("send_browse_responses failed to run");
}
/// Drive `drive_browse_tick` repeatedly until a framed `BrowseResponse`
/// arrives on `stream`, or a wall-clock deadline expires (this file's
/// established hardening pattern — see `drive_accept_loop_until`/
/// `collect_input_batches` — rather than a single tick or a fixed sleep).
///
/// A single `drive_browse_tick` call races the client thread's write
/// actually landing in the server's non-blocking socket buffer before
/// `receive_bridge_inputs` polls it — `stream` here is a genuinely blocking
/// client-side socket (only the SERVER's accepted connections are toggled
/// non-blocking, by `TcpBridge::from_connected_stream`/D-254 §2's
/// accept-loop design), so `set_read_timeout` + a bounded retry loop is the
/// correct fix, not a longer single wait.
fn drive_browse_tick_until_response(
world: &mut bevy_ecs::world::World,
stream: &mut TcpStream,
) -> BrowseResponse {
stream
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
.expect("failed to set read timeout");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
drive_browse_tick(world);
match read_framed(stream) {
Ok(Some(payload)) => {
return rmp_serde::from_slice(&payload).expect("failed to decode BrowseResponse");
}
Ok(None) => panic!("unexpected EOF reading browse response"),
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for a browse response"
);
}
Err(e) => panic!("failed to read browse response frame: {e}"),
}
}
}
fn send_browse_index_request(stream: &mut TcpStream, kind: BrowseEntityKind) {
let req = BrowseRequest {
browse: true,
kind,
query: BrowseQuery::Index {
filter_system_id: None,
},
};
let payload = rmp_serde::to_vec_named(&req).expect("failed to encode BrowseRequest");
write_framed(stream, &payload).expect("failed to write BrowseRequest");
}
fn send_browse_detail_request(stream: &mut TcpStream, kind: BrowseEntityKind, id: &str) {
let req = BrowseRequest {
browse: true,
kind,
query: BrowseQuery::Detail { id: id.to_string() },
};
let payload = rmp_serde::to_vec_named(&req).expect("failed to encode BrowseRequest");
write_framed(stream, &payload).expect("failed to write BrowseRequest");
}
/// T-1131: index + detail round-trip for all six D-254 §4 v1 entity kinds,
/// over one Reader connection, through the full demux->serve->send pipeline.
/// Parameterized-style — one test iterating all six kinds, per the ticket's
/// own suggested test shape.
#[test]
fn browse_index_and_detail_round_trip_for_all_six_kinds_over_tcp() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
let cases: &[(BrowseEntityKind, &str)] = &[
(BrowseEntityKind::StarSystem, "GJ-1"),
(BrowseEntityKind::Body, "GJ1c"),
(BrowseEntityKind::Station, "GJ1c-S1"),
(BrowseEntityKind::Corporation, "gate-corporation"),
(BrowseEntityKind::Commodity, "fusion_fuel"),
(BrowseEntityKind::TraitTemplate, "frontier_utilitarian"),
];
for &(kind, id) in cases {
send_browse_index_request(&mut stream, kind);
let index_resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(index_resp.kind, kind, "index response kind echo");
assert_eq!(index_resp.status, BrowseStatus::Ready, "index({kind:?})");
let rows = index_resp.index.expect("index populated");
assert!(
rows.iter().any(|r| r.id == id),
"index({kind:?}) should list {id}"
);
send_browse_detail_request(&mut stream, kind, id);
let detail_resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(detail_resp.kind, kind, "detail response kind echo");
assert_eq!(detail_resp.status, BrowseStatus::Ready, "detail({kind:?})");
assert!(
detail_resp.detail.is_some(),
"detail({kind:?}) should be populated"
);
}
}
/// T-1131: a Reader (not just a Player) can send `BrowseRequest` and get a
/// `Ready` response — proves the permitted-message-matrix row (D-254 §2:
/// atlas/star-map/city-names/browse all say "yes" for Reader) actually holds
/// for the new request type specifically, not just by code inspection.
#[test]
fn reader_can_browse() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_index_request(&mut stream, BrowseEntityKind::Commodity);
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::Ready);
}
/// T-1131: a Player (not just a Reader) can also browse — the permitted-
/// message-matrix row says "yes" for both roles, and this is the other half
/// of that proof.
#[test]
fn player_can_browse() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Player)
});
drive_accept_loop_until(&mut world, |w| w.resource::<BridgeResource>().has_player());
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_index_request(&mut stream, BrowseEntityKind::TraitTemplate);
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::Ready);
}
/// T-1131: two concurrently-connected Readers each get back only their OWN
/// browse response — the connection-tagging plumbing D-254 §2 established
/// for atlas/star-map/city-names must hold for browse too, proven with two
/// simultaneously-live sockets asking for DIFFERENT things so a crossed wire
/// would be immediately visible (not just "a response arrived", but "the
/// WRONG response arrived").
#[test]
fn two_readers_do_not_cross_browse_responses() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let reader_a_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (reader A)");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream_a = reader_a_handle.join().expect("reader A thread panicked");
let reader_b_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (reader B)");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 2
});
let mut stream_b = reader_b_handle.join().expect("reader B thread panicked");
// A asks about star systems, B asks about commodities — deliberately
// different kinds so a crossed response is unmistakable (not just "wrong
// data", but "wrong KIND"). Both requests are in flight before any
// response is expected, so `drive_browse_tick` is retried until BOTH
// streams have something readable (rather than draining/reading one
// stream to completion before the other's request has even arrived).
send_browse_index_request(&mut stream_a, BrowseEntityKind::StarSystem);
send_browse_index_request(&mut stream_b, BrowseEntityKind::Commodity);
stream_a
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
.expect("failed to set read timeout (A)");
stream_b
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
.expect("failed to set read timeout (B)");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut resp_a: Option<BrowseResponse> = None;
let mut resp_b: Option<BrowseResponse> = None;
while resp_a.is_none() || resp_b.is_none() {
drive_browse_tick(&mut world);
if resp_a.is_none() {
if let Ok(Some(payload)) = read_framed(&mut stream_a) {
resp_a = Some(rmp_serde::from_slice(&payload).expect("decode A"));
}
}
if resp_b.is_none() {
if let Ok(Some(payload)) = read_framed(&mut stream_b) {
resp_b = Some(rmp_serde::from_slice(&payload).expect("decode B"));
}
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for both readers' browse responses (A={}, B={})",
resp_a.is_some(),
resp_b.is_some()
);
}
let resp_a = resp_a.expect("resp_a set by loop exit condition");
let resp_b = resp_b.expect("resp_b set by loop exit condition");
assert_eq!(
resp_a.kind,
BrowseEntityKind::StarSystem,
"reader A must get back ITS OWN request's kind, not reader B's"
);
assert_eq!(
resp_b.kind,
BrowseEntityKind::Commodity,
"reader B must get back ITS OWN request's kind, not reader A's"
);
}
/// T-1131: a `Detail` request for an id that doesn't exist in that kind's
/// table comes back `NotFound` over the wire (not an error, not a hang, not
/// silently dropped).
#[test]
fn browse_detail_unknown_id_is_not_found_over_tcp() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_detail_request(&mut stream, BrowseEntityKind::Corporation, "no-such-corp");
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::NotFound);
assert!(resp.detail.is_none());
}
/// T-1131: an `Index` request against a kind with zero rows still comes back
/// `Ready` with an empty list, matching the existing city-names convention
/// (`CityNamesStatus`'s "unknown body -> Ready, empty" pattern) — over the
/// real wire, not just at the reader layer (see
/// `browse_reader::tests::index_stations_empty_table_is_empty_vec` for that
/// half of the proof).
#[test]
fn browse_index_empty_table_is_ready_with_empty_list_over_tcp() {
use settled_reach_server::atlas::browse_reader::{BrowseReader, BrowseReaderResource};
let (mut world, addr) = new_multi_connection_world();
// A fixture db with the stations table present but genuinely empty — a
// distinct db from wire_browse_reader's shared fixture (which always has
// one station), built directly here so this test controls the "empty"
// precondition explicitly rather than relying on incidental fixture state.
let db_path =
std::env::temp_dir().join(format!("sr_browse_tcp_empty_{}.db", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).expect("create empty fixture db");
conn.execute_batch(
"CREATE TABLE stations (
station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT,
station_type TEXT NOT NULL, proper_name TEXT, population INTEGER,
economic_role TEXT, governance_type TEXT, docking_class TEXT,
has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER
);",
)
.expect("create empty stations table");
}
let reader = BrowseReader::open(&db_path).expect("open empty fixture db");
world.insert_resource(BrowseReaderResource(reader));
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_index_request(&mut stream, BrowseEntityKind::Station);
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::Ready);
assert_eq!(resp.index.unwrap().len(), 0);
let _ = std::fs::remove_file(&db_path);
}
/// Minimal `ObserverSnapshot` for the tests above — same shape as
/// `snapshot_roundtrip_over_tcp`'s at the top of this file, parameterized
/// only by `tick` (the one field these tests assert on).