Merge remote-tracking branch 'origin/atlas-layer-viewer'

This commit is contained in:
2026-07-14 17:49:06 +02:00
56 changed files with 4290 additions and 113 deletions
+22
View File
@@ -45,6 +45,28 @@ var _active_app: String = "" # currently focused implant app ("" = none)
var _active_mode: Mode = Mode.GAMEPLAY
func _ready() -> void:
# T-970 (D-226 layer 1): auto-pause/auto-resume the sim when a fullscreen
# implant app occludes gameplay. AutoPause/AutoResume are distinct
# PlayerAction variants from the manual Pause/Unpause (Space bar, D-088) —
# the server tracks whether ITS OWN auto-pause caused the current pause
# (AutoPauseState) so a pre-existing manual pause or Half rate survives
# implant open/close untouched (see server/src/simulation/input.rs and
# server/src/simulation/time.rs).
gameplay_occluded.connect(_on_gameplay_occluded_auto_pause)
## Sends AutoPause/AutoResume via SimBridge's outbound queue. Uses
## send_named_action (protocol-level, not bound to an InputMapper.Action
## keybind) — the same mechanism as RequestBookmarkCatalog — since occlusion
## is a UI-state transition, not a physical input.
func _on_gameplay_occluded_auto_pause(occluded: bool) -> void:
if occluded:
SimBridge.send_named_action("AutoPause")
else:
SimBridge.send_named_action("AutoResume")
func register(node: CanvasItem, group: String) -> void:
if not _groups.has(group):
_groups[group] = []
+79 -2
View File
@@ -4,6 +4,8 @@ extends Node
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
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 handshake_complete
signal handshake_failed(reason: String)
@@ -34,6 +36,12 @@ var _connect_retries: int = 0
var _retry_timer: float = 0.0
var _handshake_start_usec: int = 0
# T-949: true once any caller has asked for the star map. The Atlas/economics
# screens can call request_star_map() before the bridge finishes its
# handshake — the request is remembered and replayed automatically the
# moment _set_state reaches CONNECTED, instead of silently going nowhere.
var _star_map_wanted: bool = false
var _test_tick: int:
get:
return harness.tick if harness else 0
@@ -93,6 +101,14 @@ func _ready() -> void:
func reset_test_state() -> void:
if harness:
harness.reset()
# T-949: don't leak a star-map request across tests (autoload state).
_star_map_wanted = false
# SystemIndex's static cache is the same cross-suite leak class (PR #176
# review H2/T1) — reset it here so the dozens of suites already calling
# SimBridge.reset_test_state() cover both. load() inline per the autoload
# parse-order rule (CLAUDE.md).
var SI := load("res://ui/implant/widgets/system_index.gd")
SI.reset_test_state()
func _test_snapshot() -> Dictionary:
@@ -112,6 +128,10 @@ func _set_state(new_state: ConnectionState) -> void:
var old_state = state
state = new_state
connection_state_changed.emit(old_state, new_state)
# T-949: fire (or re-fire, on reconnect) any star-map request that was
# asked for before we were connected.
if new_state == ConnectionState.CONNECTED and _star_map_wanted:
_send_star_map_request()
# Connect to simulation server.
@@ -406,6 +426,56 @@ func request_atlas_layers(body_id: String, up_to: String = "Topography") -> void
)
## Request the Reach-level star map / system list from the server (T-949,
## D-010 — replaces the client's direct star_map_data.json file read). Live
## mode only; the response arrives via the star_map_received signal. Safe to
## call before the handshake completes — the request is remembered
## (_star_map_wanted) and replayed automatically once CONNECTED (see
## _set_state), so callers don't need to poll or retry themselves.
func request_star_map() -> void:
_star_map_wanted = true
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
_send_star_map_request()
func _send_star_map_request() -> void:
# Same guard as request_star_map(): _star_map_wanted persists on this
# autoload across gdUnit suites, so the replay-on-CONNECTED path in
# _set_state() can fire in test mode where _bridge is null — an unguarded
# send crashed every later suite that called connect_to_sim() (8 tests,
# 2026-07-14). Live mode: _bridge exists before CONNECTED, guard passes.
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_star_map_request()
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error("SimBridge: failed to send star map request: %s" % error_string(err))
## Request one body's atlas city-name pool from the server (T-949, D-223/
## D-236). Live mode only — sends a CityNamesRequest frame; the response
## arrives via the city_names_received signal. No-op in test mode. Never
## called for Sol bodies (system GJ-0) — atlas_viewer.gd keeps the legacy
## markers.json geometry read for those (D-236, T-1073).
func request_city_names(body_id: String) -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_city_names_request(body_id)
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error(
(
"SimBridge: failed to send city names request for %s: %s"
% [body_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:
@@ -431,12 +501,19 @@ func poll_snapshot() -> Variant:
# events (monologue, dialogue) are carried forward from overwritten snapshots
# 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): atlas layer responses and
# snapshots are both msgpack maps, told apart by field.
# 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.
var inbound := Protocol.decode_inbound(bytes)
if inbound.kind == "atlas":
atlas_layers_received.emit(inbound.value)
return
if inbound.kind == "starmap":
star_map_received.emit(inbound.value)
return
if inbound.kind == "citynames":
city_names_received.emit(inbound.value)
return
if inbound.kind != "snapshot":
push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size())
return
+113 -7
View File
@@ -771,6 +771,14 @@ static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
## Build an AtlasLayerResponse from an already-decoded raw value. Returns null
## if it is not an atlas response (no "status" key).
## road_graph/settlements (T-960): passthrough fields for the L2 road/rail
## graph and L3 settlement placements, mirroring the district_grid precedent
## (T-1046) — raw decoded maps/arrays, no further client-side reshaping.
## Key names "road_graph"/"settlements" are the CONFIRMED wire contract —
## identical to server/src/atlas/layer_proxy.rs AtlasLayerResponse's field
## names (pinned 2026-07-14; round-tripped by test_atlas_overlays.gd and the
## server's msgpack round-trip tests). This remains the one client-side spot
## to touch if the contract ever changes.
static func atlas_response_from_raw(raw: Variant) -> Variant:
if not raw is Dictionary or not raw.has("status"):
return null
@@ -787,20 +795,118 @@ static func atlas_response_from_raw(raw: Variant) -> Variant:
"status": status,
"error": error,
"layer1": raw.get("layer1"),
"district_grid": raw.get("district_grid"),
"district_grid": raw.get("district_grid"),
"road_graph": raw.get("road_graph"),
"settlements": raw.get("settlements"),
}
## Decode + classify one inbound frame (#960, D-225). Returns {kind, value} with
## kind "snapshot" | "atlas" | "unknown" — both are msgpack maps, so they are
## told apart by field (a response has "status"; a snapshot has "entities").
## Lets receive_bytes decode the frame ONCE and branch, instead of double-decoding
## the 20 Hz snapshot path.
## Decode a status enum shared by StarMapStatus/CityNamesStatus/AtlasLayerStatus
## shape: a unit variant is a bare string ("Ready", "SolExcluded", …); the one
## data variant (Error(String)) is a single-key map {"Error": "message"}.
## Returns {"status": String, "error": String} (error empty unless Error).
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 StarMapRequest (T-949, D-010) for the Reach-level star-map proxy.
## `star_map: true` is the mandatory discriminator field the server's demux
## matches on (dudley-atlas-server contract, 2026-07-14) — always send it,
## never omit it, or the frame can't be routed.
static func encode_star_map_request() -> PackedByteArray:
var msg := {"star_map": true}
var result = _mp().encode(msg)
if result.status != null:
push_error("Protocol: encode_star_map_request failed: %s" % result.status)
return PackedByteArray()
return result.value
## Build a StarMapResponse from an already-decoded raw value. Returns null if
## it is not a star-map response (no "status" key). `data` is a verbatim
## MessagePack re-encoding of star_map_data.json's own top-level shape
## (`_meta`/`nodes`/`edges`) — unwrapped here so callers (SystemIndex) see the
## same {"nodes": [...]} shape they'd have gotten from the raw file, and never
## need to know about the status/data envelope.
static func star_map_response_from_raw(raw: Variant) -> Variant:
if not raw is Dictionary or not raw.has("status"):
return null
var decoded_status := _decode_status_field(raw.get("status"))
var nodes: Array = []
if decoded_status["status"] == "Ready":
var data: Variant = raw.get("data")
if data is Dictionary:
nodes = data.get("nodes", [])
return {"status": decoded_status["status"], "error": decoded_status["error"], "nodes": nodes}
## Decode a StarMapResponse from MessagePack bytes. See star_map_response_from_raw.
static func decode_star_map_response(bytes: PackedByteArray) -> Variant:
return star_map_response_from_raw(decode_raw(bytes))
## Encode a CityNamesRequest (T-949, D-223/D-236) for one body's atlas
## city-name pool. `city_names: true` is the mandatory discriminator field
## (same contract as StarMapRequest) — without it the request is structurally
## ambiguous with a malformed AtlasLayerRequest (missing `up_to`). Sent for
## every body INCLUDING Sol — the server itself reports SolExcluded for those
## (D-236) as a defensive backstop; atlas_viewer.gd's own guard is expected to
## make that path rare, not load-bearing on its own.
static func encode_city_names_request(body_id: String) -> PackedByteArray:
var msg := {"city_names": true, "body_id": body_id}
var result = _mp().encode(msg)
if result.status != null:
push_error("Protocol: encode_city_names_request failed: %s" % result.status)
return PackedByteArray()
return result.value
## Build a CityNamesResponse from an already-decoded raw value. Returns null
## unless it carries both "body_id" and "status". `cities` is a flat array of
## {city_id, name, is_capital} — no position (that comes from SettlementLayer,
## T-960's gen_l3_settlements). status is one of "Ready" | "SolExcluded" |
## "Error" (see _decode_status_field) — SolExcluded means the caller must fall
## back to the legacy markers.json read for that body (D-236).
static func city_names_response_from_raw(raw: Variant) -> Variant:
if not raw is Dictionary or not raw.has("body_id") or not raw.has("status"):
return null
var decoded_status := _decode_status_field(raw.get("status"))
return {
"body_id": str(raw.get("body_id", "")),
"status": decoded_status["status"],
"error": decoded_status["error"],
"cities": raw.get("cities", []),
}
## Decode a CityNamesResponse from MessagePack bytes. See city_names_response_from_raw.
static func decode_city_names_response(bytes: PackedByteArray) -> Variant:
return city_names_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.
static func decode_inbound(bytes: PackedByteArray) -> Dictionary:
var raw = decode_raw(bytes)
if not raw is Dictionary:
return {"kind": "unknown", "value": null}
if raw.has("status") and not raw.has("entities"):
if raw.has("entities"):
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}
if raw.has("data") and not raw.has("body_id"):
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("status"):
return {"kind": "atlas", "value": atlas_response_from_raw(raw)}
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}
@@ -1 +1 @@
ƒ§body_id¥ghost¦status¨NotFound¦layer1À
§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ
@@ -1 +1 @@
body_id二J1c存tatus判ending奸ayer1
body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+181
View File
@@ -0,0 +1,181 @@
## T-949 tests: atlas_viewer.gd's _load_markers Sol/non-Sol split and the
## CityNamesResponse handler.
##
## Covers the D-236 Sol guard (legacy synchronous markers.json read, kept
## byte-for-byte) and the async non-Sol path (a CityNamesRequest is queued
## instead of a direct file read; _markers only populates once the response
## arrives). Response shape pinned to dudley-atlas-server's contract
## (2026-07-14): {body_id, status, cities: [{city_id, name, is_capital}]},
## with a SolExcluded status as the server-side Sol backstop.
class_name TestAtlasCityNames
extends GdUnitTestSuite
const SOL_BODY_ID := "GJ0d"
const SOL_SYSTEM_ID := "GJ-0"
const NON_SOL_BODY_ID := "GJ903b"
const NON_SOL_SYSTEM_ID := "GJ-903"
## A real Sol heightmap.png reference, resolved with the SAME formula
## _load_sol_markers_legacy() uses for a relative terrain_reference — computed
## once here (as an absolute path) so the test doesn't depend on hardcoding
## this checkout's location, and doesn't re-derive/guess the resolution logic
## separately from production.
func _sol_heightmap_ref() -> String:
var project_root: String = (
ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
)
return project_root + "/wiki/star-systems/GJ-0/bodies/GJ0d/heightmap.png"
## add_child fires _ready() synchronously (matches test_implant_app_lifecycle.gd's
## established pattern) — builds _city_panel/_overlay_node/etc. so show_body()
## doesn't null-deref.
func _make_viewer() -> AtlasViewer:
var v := AtlasViewer.new()
add_child(v)
return v
func test_sol_body_uses_legacy_synchronous_markers_read() -> void:
var v := _make_viewer()
var body := {"body_id": SOL_BODY_ID, "terrain_reference": _sol_heightmap_ref()}
var system := {"system_id": SOL_SYSTEM_ID}
v.show_body(body, system)
# The legacy path is synchronous — cities/rivers/etc. are populated
# immediately, no bridge round-trip needed.
var markers: Dictionary = v.get_markers()
assert_bool(markers.has("cities")).override_failure_message(
"Sol body must keep the legacy full-geometry markers.json read (D-236)"
).is_true()
assert_int((markers.get("cities", []) as Array).size()).override_failure_message(
"Sol's real markers.json must yield at least one city"
).is_greater(0)
assert_bool(v.has_pending_city_names_request()).override_failure_message(
"Sol bodies must never queue a CityNamesRequest (D-236/T-1073 exception)"
).is_false()
v.queue_free()
func test_non_sol_body_does_not_synchronously_populate_markers() -> void:
var v := _make_viewer()
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
# T-949: no more direct file read — markers stay empty until the async
# CityNamesResponse arrives (never, in test mode — SimBridge has no server).
assert_that(v.get_markers()).override_failure_message(
"non-Sol bodies must not synchronously populate markers from a file read"
).is_equal({})
assert_bool(v.has_pending_city_names_request()).override_failure_message(
"non-Sol bodies must queue a CityNamesRequest for their own body"
).is_true()
v.queue_free()
func test_city_names_received_ready_stores_under_dedicated_key() -> void:
var v := _make_viewer()
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
v._on_city_names_received(
{
"body_id": NON_SOL_BODY_ID,
"status": "Ready",
"cities": [{"city_id": 1, "name": "Ridgeback", "is_capital": false}],
}
)
# Stored under "city_names", NOT the legacy top-level "cities" key —
# CityNameEntry has no position, so merging it into "cities" would make
# _draw_cities()/_find_city_at() plot every entry at Vector2.ZERO.
var markers: Dictionary = v.get_markers()
assert_that(markers.get("cities", [])).override_failure_message(
"non-Sol markers must NOT expose position-less entries under the top-level 'cities' key"
).is_equal([])
assert_int((markers.get("city_names", []) as Array).size()).is_equal(1)
assert_str((markers.get("city_names", [])[0] as Dictionary).get("name")).is_equal("Ridgeback")
assert_bool(v.has_pending_city_names_request()).is_false()
v.queue_free()
func test_city_names_received_sol_excluded_falls_back_to_legacy_read() -> void:
var v := _make_viewer()
# Body claims to be non-Sol at request time, but the server's own D-236
# backstop says otherwise — the defensive fallback must still work even
# though this shouldn't happen given atlas_viewer.gd's own SOL_SYSTEM_ID
# guard (belt-and-suspenders per dudley-atlas-server's contract note).
v.show_body(
{"body_id": SOL_BODY_ID, "terrain_reference": _sol_heightmap_ref()},
{"system_id": NON_SOL_SYSTEM_ID}
)
assert_bool(v.has_pending_city_names_request()).is_true()
v._on_city_names_received({"body_id": SOL_BODY_ID, "status": "SolExcluded", "cities": []})
var markers: Dictionary = v.get_markers()
assert_bool(markers.has("cities")).override_failure_message(
"SolExcluded must fall back to the legacy full-geometry markers.json read"
).is_true()
assert_int((markers.get("cities", []) as Array).size()).is_greater(0)
assert_bool(v.has_pending_city_names_request()).is_false()
v.queue_free()
func test_city_names_received_ignores_stale_body_response() -> void:
var v := _make_viewer()
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
# A response for a DIFFERENT body (the viewer navigated away while the
# request was in flight) must not clobber state.
v._on_city_names_received(
{"body_id": "some_other_body", "status": "Ready", "cities": [{"name": "Nope"}]}
)
assert_that(v.get_markers()).override_failure_message(
"a stale-body CityNamesResponse must be ignored"
).is_equal({})
assert_bool(v.has_pending_city_names_request()).override_failure_message(
"a stale-body response must not clear the real pending request"
).is_true()
v.queue_free()
func test_city_names_received_error_status_leaves_markers_empty() -> void:
var v := _make_viewer()
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
# Normalized string form — production handlers only ever see the output of
# Protocol.city_names_response_from_raw, which reduces {"Error": msg} to
# "Error" (review H5: the raw wire shape only passed by str() coincidence).
v._on_city_names_received(
{"body_id": NON_SOL_BODY_ID, "status": "Error", "cities": []}
)
assert_that(v.get_markers()).override_failure_message(
"an Error status must leave markers empty (#960/D-191's empty-markers case)"
).is_equal({})
assert_bool(v.has_pending_city_names_request()).is_false()
v.queue_free()
func test_connection_state_change_does_not_crash_or_clear_pending_marker() -> void:
var v := _make_viewer()
v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID})
assert_bool(v.has_pending_city_names_request()).is_true()
# SimBridge is in test_mode (no live connection) so request_city_names()
# no-ops either way — this proves the handler doesn't crash, and that only
# a real CityNamesResponse (not the mere state transition) clears the
# pending marker.
v._on_connection_state_changed(
SimBridge.ConnectionState.CONNECTING, SimBridge.ConnectionState.CONNECTED
)
assert_bool(v.has_pending_city_names_request()).is_true()
v.queue_free()
+287
View File
@@ -0,0 +1,287 @@
## T-949 tests: Atlas data delivery migration — StarMapRequest/StarMapResponse
## and CityNamesRequest/CityNamesResponse (protocol.gd encode/decode,
## decode_inbound's frame classification, and SimBridge signal routing).
##
## Wire shapes pinned to dudley-atlas-server's contract (2026-07-14):
## AtlasLayerResponse gains road_graph/settlements (Option, "None until ready"
## like district_grid); StarMapResponse is {status, data} (data = a verbatim
## MessagePack re-encoding of star_map_data.json, unwrapped by
## star_map_response_from_raw so callers see {"nodes": [...]} directly);
## CityNamesResponse is {body_id, status, cities: [{city_id, name, is_capital}]}
## with a SolExcluded status as the server-side Sol backstop (D-236).
class_name TestAtlasDataDelivery
extends GdUnitTestSuite
# =============================================================================
# Encode/decode round-trips
# =============================================================================
func test_encode_star_map_request_carries_discriminator() -> void:
var bytes := Protocol.encode_star_map_request()
assert_int(bytes.size()).is_greater(0)
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status == null).is_true()
assert_that(decoded.value.get("star_map")).is_equal(true)
func test_star_map_response_from_raw_unwraps_data_to_nodes() -> void:
var nodes := [
{"system_id": "GJ 71", "proper_name": "Tau Ceti"},
{"system_id": "GJ 0", "proper_name": "Sol"},
]
var raw := {"status": "Ready", "data": {"_meta": {"system_count": 2}, "nodes": nodes}}
var response: Variant = Protocol.star_map_response_from_raw(raw)
assert_that(response).is_not_null()
assert_str((response as Dictionary).get("status")).is_equal("Ready")
assert_that((response as Dictionary).get("nodes")).is_equal(nodes)
func test_star_map_response_from_raw_error_status_yields_no_nodes() -> void:
var raw := {"status": {"Error": "db unavailable"}, "data": null}
var response: Variant = Protocol.star_map_response_from_raw(raw)
assert_that(response).is_not_null()
assert_str((response as Dictionary).get("status")).is_equal("Error")
assert_str((response as Dictionary).get("error")).is_equal("db unavailable")
assert_that((response as Dictionary).get("nodes")).is_equal([])
func test_star_map_response_from_raw_rejects_shape_without_status() -> void:
assert_that(Protocol.star_map_response_from_raw({"data": {}})).is_null()
assert_that(Protocol.star_map_response_from_raw("not a dict")).is_null()
func test_decode_star_map_response_bytes_round_trip() -> void:
var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71"}]}}
var encoded = Messagepack.encode(raw)
var response: Variant = Protocol.decode_star_map_response(encoded.value)
assert_that(response).is_not_null()
assert_that((response as Dictionary).get("nodes")).is_equal(raw["data"]["nodes"])
func test_encode_city_names_request_carries_discriminator_and_body_id() -> void:
var bytes := Protocol.encode_city_names_request("GJ903b")
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status == null).is_true()
assert_that(decoded.value.get("city_names")).is_equal(true)
assert_that(decoded.value.get("body_id")).is_equal("GJ903b")
func test_city_names_response_from_raw_round_trips_ready() -> void:
var cities := [{"city_id": 1, "name": "Ridgeback", "is_capital": false}]
var raw := {"body_id": "GJ903b", "status": "Ready", "cities": cities}
var response: Variant = Protocol.city_names_response_from_raw(raw)
assert_that(response).is_not_null()
assert_str((response as Dictionary).get("body_id")).is_equal("GJ903b")
assert_str((response as Dictionary).get("status")).is_equal("Ready")
assert_that((response as Dictionary).get("cities")).is_equal(cities)
func test_city_names_response_from_raw_sol_excluded() -> void:
var raw := {"body_id": "GJ0d", "status": "SolExcluded", "cities": []}
var response: Variant = Protocol.city_names_response_from_raw(raw)
assert_that(response).is_not_null()
assert_str((response as Dictionary).get("status")).is_equal("SolExcluded")
func test_city_names_response_from_raw_requires_body_id_and_status() -> void:
assert_that(Protocol.city_names_response_from_raw({"body_id": "GJ903b"})).is_null()
assert_that(Protocol.city_names_response_from_raw({"status": "Ready"})).is_null()
# =============================================================================
# decode_inbound classification — snapshot / atlas / starmap / citynames
# =============================================================================
func test_decode_inbound_classifies_snapshot() -> void:
var raw := {"tick": 1, "entities": []}
var encoded = Messagepack.encode(raw)
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
assert_str(inbound.kind).is_equal("snapshot")
func test_decode_inbound_classifies_atlas() -> void:
var raw := {"body_id": "GJ1c", "status": "Ready", "layer1": null, "district_grid": null}
var encoded = Messagepack.encode(raw)
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
assert_str(inbound.kind).is_equal("atlas")
func test_decode_inbound_classifies_starmap() -> void:
# StarMapResponse is the only kind with "data" and no "body_id".
var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71"}]}}
var encoded = Messagepack.encode(raw)
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
assert_str(inbound.kind).is_equal("starmap")
assert_that((inbound.value as Dictionary).get("nodes")).is_equal(raw["data"]["nodes"])
func test_decode_inbound_classifies_citynames() -> void:
# CityNamesResponse is the only kind with "cities" — must classify as
# citynames, NOT atlas, even though both carry status + body_id.
var raw := {"body_id": "GJ903b", "status": "Ready", "cities": [{"city_id": 1, "name": "Ridgeback"}]}
var encoded = Messagepack.encode(raw)
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
assert_str(inbound.kind).is_equal("citynames")
assert_that((inbound.value as Dictionary).get("body_id")).is_equal("GJ903b")
func test_atlas_response_road_graph_and_settlements_passthrough() -> void:
var road_graph := {
"nodes": [{"position": [1, 2], "kind": "Settlement", "city_id": 5}],
"edges":
[
{
"from": 0,
"to": 1,
"path": [[1, 2], [3, 4]],
"maintenance": "Trade",
"is_rail": false,
"named_route_id": null,
}
],
}
var settlements := {
"settlements":
[
{
"city_id": 5,
"name": "Ridgeback",
"position": [1, 2],
"size_class": "Minor",
"is_capital": false,
"is_port": false,
}
]
}
var raw := {
"body_id": "GJ1c",
"status": "Ready",
"road_graph": road_graph,
"settlements": settlements,
}
var decoded: Variant = Protocol.atlas_response_from_raw(raw)
assert_that(decoded).is_not_null()
assert_that((decoded as Dictionary).get("road_graph")).is_equal(road_graph)
assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements)
func test_atlas_response_road_graph_and_settlements_default_null() -> void:
# A Ready response before road_graph/settlements land on a given server
# build must decode cleanly with both fields absent, not error.
var decoded: Variant = Protocol.atlas_response_from_raw({"body_id": "GJ1c", "status": "Ready"})
assert_that(decoded).is_not_null()
assert_that((decoded as Dictionary).get("road_graph")).is_null()
assert_that((decoded as Dictionary).get("settlements")).is_null()
# =============================================================================
# SimBridge routing — receive_bytes emits the right signal with the right value
# =============================================================================
func test_sim_bridge_routes_star_map_response_to_signal() -> void:
var received: Array = []
var handler := func(response: Dictionary) -> void: received.append(response)
SimBridge.star_map_received.connect(handler)
var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71", "proper_name": "Tau Ceti"}]}}
var encoded = Messagepack.encode(raw)
SimBridge.receive_bytes(encoded.value)
SimBridge.star_map_received.disconnect(handler)
assert_int(received.size()).is_equal(1)
assert_that(received[0].get("nodes")).is_equal(raw["data"]["nodes"])
func test_sim_bridge_routes_city_names_response_to_signal() -> void:
var received: Array = []
var handler := func(response: Dictionary) -> void: received.append(response)
SimBridge.city_names_received.connect(handler)
var raw := {
"body_id": "GJ903b",
"status": "Ready",
"cities": [{"city_id": 1, "name": "Ridgeback", "is_capital": false}],
}
var encoded = Messagepack.encode(raw)
SimBridge.receive_bytes(encoded.value)
SimBridge.city_names_received.disconnect(handler)
assert_int(received.size()).is_equal(1)
assert_that(received[0].get("body_id")).is_equal("GJ903b")
func test_sim_bridge_still_routes_snapshots_after_starmap_additions() -> void:
# Regression guard: adding the starmap/citynames branches must not steal
# ordinary snapshot frames (the 20 Hz hot path).
var raw := {"tick": 5, "entities": []}
var encoded = Messagepack.encode(raw)
SimBridge.receive_bytes(encoded.value)
assert_that(SimBridge._last_snapshot).is_not_null()
assert_int(SimBridge._last_snapshot.get("tick")).is_equal(5)
func test_request_star_map_is_noop_in_test_mode() -> void:
# SimBridge defaults to test_mode = true (SR_LIVE unset) — request_star_map
# must not error even with no live bridge/server, but it MUST still record
# the wish so replay-on-connect can fire later (review H3: this flag's
# unreset leak was the cross-suite crash fixed in this same PR).
SimBridge.reset_test_state()
SimBridge.request_star_map()
assert_bool(SimBridge._star_map_wanted).override_failure_message(
"test-mode request_star_map must still set the replay flag"
).is_true()
SimBridge.reset_test_state()
func test_request_city_names_is_noop_in_test_mode() -> void:
# Must not error — and must NOT touch the star-map replay flag (guards a
# future copy-paste wiring city-names into the wrong flag).
SimBridge.reset_test_state()
SimBridge.request_city_names("GJ903b")
assert_bool(SimBridge._star_map_wanted).is_false()
class _CaptureBridge:
var sent: Array = []
func send_message(bytes: PackedByteArray) -> int:
sent.append(bytes)
return OK
func test_replay_on_connect_sends_star_map_request_live() -> void:
# Review H7: the replay-on-CONNECTED path (_set_state →
# _send_star_map_request) is the exact mechanism behind the cross-suite
# crash fixed in this PR, and every other test runs test_mode=true where
# the guard short-circuits before touching _bridge — proving only
# "doesn't crash", never "actually replays". Exercise the positive case
# per the test_local_bridge.gd test-mode-flip precedent.
var original_test_mode: bool = SimBridge.test_mode
var original_state: SimBridge.ConnectionState = SimBridge.state
var original_bridge = SimBridge._bridge
SimBridge.reset_test_state()
var stub := _CaptureBridge.new()
SimBridge.test_mode = false
SimBridge._bridge = stub
SimBridge.state = SimBridge.ConnectionState.CONNECTING
SimBridge._star_map_wanted = true
SimBridge._set_state(SimBridge.ConnectionState.CONNECTED)
assert_int(stub.sent.size()).override_failure_message(
"reaching CONNECTED with _star_map_wanted set must replay the request"
).is_equal(1)
var decoded = Messagepack.decode(stub.sent[0])
assert_that(decoded.value).is_equal({"star_map": true})
# Restore autoload state for later suites.
SimBridge.test_mode = original_test_mode
SimBridge._bridge = original_bridge
SimBridge.state = original_state
SimBridge.reset_test_state()
+192
View File
@@ -4,6 +4,11 @@
class_name TestAtlasOverlays
extends GdUnitTestSuite
# atlas_legend_panel.gd has no class_name (matches atlas_overlay_bar.gd, review
# #8), so its GENERATION_LEGEND spec table is read off the preloaded Script
# resource rather than a global identifier.
const LegendPanelScript := preload("res://ui/implant/apps/atlas/atlas_legend_panel.gd")
func test_generation_overlays_registered() -> void:
var ids: Array = []
@@ -53,3 +58,190 @@ func test_implant_pending_start_stop() -> void:
assert_bool(p.visible).is_true()
p.stop()
assert_bool(p.visible).is_false()
# =============================================================================
# T-960: gen_l2_roads / gen_l3_settlements overlays
# =============================================================================
func test_l2_l3_overlays_registered() -> void:
var ids: Array = []
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
ids.append(d["id"])
assert_that(ids).contains(["gen_l2_roads", "gen_l3_settlements"])
## RoadGraphLayer shape pinned to dudley-atlas-server's contract (2026-07-14):
## nodes carry NO `degree` (trimmed as server-internal bookkeeping — the
## overlay derives it from edge endpoints instead, see _draw_gen_roads).
func test_road_graph_round_trips() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_road_graph()).is_null()
var road_graph := {
"nodes":
[
{"city_id": 1, "position": [10, 20], "kind": "Settlement"},
{"city_id": null, "position": [12, 22], "kind": "Waypoint"},
],
"edges":
[
{
"from": 0,
"to": 1,
"path": [[10, 20], [12, 22]],
"maintenance": "Administrative",
"is_rail": false,
"named_route_id": null,
},
],
}
v.set_generation_road_graph(road_graph)
assert_that(v.get_generation_road_graph()).is_equal(road_graph)
# Round-trips through the protocol decode too (T-1046 district_grid precedent).
var decoded: Variant = Protocol.atlas_response_from_raw(
{"body_id": "GJ1c", "status": "Ready", "road_graph": road_graph}
)
assert_that((decoded as Dictionary).get("road_graph")).is_equal(road_graph)
## SettlementLayer shape pinned to dudley-atlas-server's contract
## (2026-07-14): wrapped under "settlements" (not "cities"); size_class is
## categorical (Major/Standard/Minor), and is_capital is authored, not
## population-derived.
func test_settlements_round_trip() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_settlements()).is_null()
var settlements := {
"settlements":
[
{
"city_id": 1,
"name": "Ridgeback",
"position": [30, 40],
"size_class": "Minor",
"is_capital": false,
"is_port": false,
},
{
"city_id": 2,
"name": "Capital City",
"position": [35, 45],
"size_class": "Major",
"is_capital": true,
"is_port": true,
},
],
}
v.set_generation_settlements(settlements)
assert_that(v.get_generation_settlements()).is_equal(settlements)
var decoded: Variant = Protocol.atlas_response_from_raw(
{"body_id": "GJ1c", "status": "Ready", "settlements": settlements}
)
assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements)
## Tier-2 replay: a REAL server-generated msgpack blob (server/tests/gen_fixtures.rs
## generate_atlas_layer_response_fixtures, regenerated 2026-07-14 with T-960's
## road_graph/settlements populated) decoded through the actual client path —
## the strongest check that protocol.gd's decode matches the server's wire
## encoding, not just a hand-authored Dictionary the client wrote itself.
func test_atlas_response_ready_fixture_decodes_road_graph_and_settlements() -> void:
var path := "res://tests/fixtures/msgpack/atlas_response_ready.msgpack"
var f := FileAccess.open(path, FileAccess.READ)
assert_that(f).is_not_null()
var bytes := f.get_buffer(f.get_length())
f.close()
var decoded: Variant = Protocol.decode_atlas_layer_response(bytes)
assert_that(decoded).is_not_null()
var response: Dictionary = decoded
assert_str(response.get("body_id")).is_equal("GJ1c")
assert_str(response.get("status")).is_equal("Ready")
var road_graph: Dictionary = response.get("road_graph")
assert_that(road_graph).is_not_null()
assert_int((road_graph.get("nodes", []) as Array).size()).is_equal(2)
var edges: Array = road_graph.get("edges", [])
assert_int(edges.size()).is_equal(1)
assert_str((edges[0] as Dictionary).get("maintenance")).is_equal("Administrative")
assert_bool((edges[0] as Dictionary).get("is_rail")).is_false()
var settlements: Dictionary = response.get("settlements")
assert_that(settlements).is_not_null()
var entries: Array = settlements.get("settlements", [])
assert_int(entries.size()).is_equal(2)
var capital: Dictionary = entries[0]
assert_str(capital.get("name")).is_equal("Port Aldren")
assert_str(capital.get("size_class")).is_equal("Major")
assert_bool(capital.get("is_capital")).is_true()
var minor: Dictionary = entries[1]
assert_str(minor.get("name")).is_equal("Farmstead Rell")
assert_str(minor.get("size_class")).is_equal("Minor")
assert_bool(minor.get("is_capital")).is_false()
# =============================================================================
# D-226 item 3: generation legend panel
# =============================================================================
## Data-driven claim check: every legend entry must reference a real overlay
## id, so a typo or a stale entry can't silently produce a dead legend
## section that never appears.
func test_generation_legend_entries_reference_real_overlay_ids() -> void:
var overlay_ids: Array = []
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
overlay_ids.append(d["id"])
for spec: Dictionary in LegendPanelScript.GENERATION_LEGEND:
assert_that(overlay_ids).contains([spec.get("overlay_id", "")])
assert_bool((spec.get("rows", []) as Array).is_empty()).override_failure_message(
"legend entry '%s' has no rows" % spec.get("title", "?")
).is_false()
## Every gen_* toggleable overlay should have at least one legend entry so
## toggling it on never shows silent, unexplained map content.
func test_every_gen_overlay_has_a_legend_entry() -> void:
var legend_overlay_ids: Array = []
for spec: Dictionary in LegendPanelScript.GENERATION_LEGEND:
legend_overlay_ids.append(spec.get("overlay_id", ""))
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
var overlay_id: String = d["id"]
if overlay_id.begins_with("gen_"):
assert_that(legend_overlay_ids).override_failure_message(
"overlay '%s' has no GENERATION_LEGEND entry" % overlay_id
).contains([overlay_id])
## Full-lifecycle test (add_child fires _ready(), which builds the legend
## panel) — invisible at rest, since no generation overlay starts active.
func test_legend_panel_hidden_by_default() -> void:
var v: AtlasViewer = AtlasViewer.new()
add_child(v)
assert_bool(v._legend_panel.visible).override_failure_message(
"legend panel must be invisible when no generation overlay is active"
).is_false()
v.queue_free()
## Toggling a gen_* overlay on must reveal the legend and populate its rows;
## toggling it back off must hide it again (invisible-when-not-needed, D-226).
func test_legend_panel_shows_active_overlay_and_hides_when_toggled_off() -> void:
var v: AtlasViewer = AtlasViewer.new()
add_child(v)
v.set_overlay_visible("gen_l3_settlements", true)
assert_bool(v._legend_panel.visible).override_failure_message(
"legend panel must show once a generation overlay is toggled on"
).is_true()
assert_int(v._legend_panel.get_implant_children().size()).override_failure_message(
"legend panel must have content rows once populated"
).is_greater(0)
v.set_overlay_visible("gen_l3_settlements", false)
assert_bool(v._legend_panel.visible).override_failure_message(
"legend panel must hide again once its only active overlay is toggled off"
).is_false()
v.queue_free()
@@ -0,0 +1,84 @@
class_name TestHudGroupsAutoPause
extends GdUnitTestSuite
## T-970 (D-226 layer 1): HudGroups.gameplay_occluded must auto-pause/auto-resume
## the sim via SimBridge.send_named_action("AutoPause"/"AutoResume").
##
## AutoPause/AutoResume are protocol-level requests, not InputMapper keybinds —
## same mechanism as RequestBookmarkCatalog (send_named_action bypasses
## action_enum_to_wire entirely, so there is nothing to map there). This suite
## only covers the signal -> outbound-action wiring in hud_groups.gd; the
## server-side pause reconciliation (Option A) is covered by Rust tests in
## server/src/simulation/input.rs.
const TEST_APP_PATH := "implant/test_auto_pause"
const OTHER_APP_PATH := "implant/test_auto_pause_other"
func before_test() -> void:
SimBridge.connect_to_sim() # test mode: immediately CONNECTED
SimBridge._outbound_buffer.clear()
HudGroups._active_app = ""
HudGroups._active_mode = HudGroups.Mode.GAMEPLAY
func after_test() -> void:
HudGroups._active_app = ""
HudGroups._active_mode = HudGroups.Mode.GAMEPLAY
HudGroups._groups.erase(TEST_APP_PATH)
HudGroups._groups.erase(OTHER_APP_PATH)
SimBridge.disconnect_from_sim()
SimBridge._outbound_buffer.clear()
func _outbound_has_action(action_name: String) -> bool:
for entry in SimBridge._outbound_buffer:
if entry.get("action_name") == action_name:
return true
return false
func test_opening_fullscreen_app_sends_auto_pause() -> void:
HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
assert_bool(_outbound_has_action("AutoPause")).override_failure_message(
"Entering a fullscreen implant app must send AutoPause"
).is_true()
func test_closing_fullscreen_app_sends_auto_resume() -> void:
HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
SimBridge._outbound_buffer.clear()
HudGroups.close_app()
assert_bool(_outbound_has_action("AutoResume")).override_failure_message(
"Closing a fullscreen implant app must send AutoResume"
).is_true()
func test_insert_mode_does_not_send_auto_pause() -> void:
# INSERT mode does not occlude gameplay (D-170) — gameplay_occluded never
# fires, so no AutoPause should be sent.
HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.INSERT)
assert_bool(_outbound_has_action("AutoPause")).override_failure_message(
"INSERT mode must not trigger AutoPause — gameplay is not occluded"
).is_false()
func test_switching_between_fullscreen_apps_does_not_resend_auto_pause() -> void:
# D-170: switching from one fullscreen app to another must not re-emit
# gameplay_occluded (occlusion state does not change) — no duplicate AutoPause.
HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
SimBridge._outbound_buffer.clear()
HudGroups.open_app(OTHER_APP_PATH, HudGroups.Mode.FULLSCREEN)
assert_bool(_outbound_has_action("AutoPause")).override_failure_message(
"Switching between two fullscreen apps must not re-send AutoPause"
).is_false()
func test_toggle_app_closed_from_fullscreen_sends_auto_resume() -> void:
# toggle_app(app) when already open calls close_app() internally —
# exercises the same gameplay_occluded(false) path via a different entry point.
HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN)
SimBridge._outbound_buffer.clear()
HudGroups.toggle_app(TEST_APP_PATH)
assert_bool(_outbound_has_action("AutoResume")).override_failure_message(
"toggle_app() closing a fullscreen app must send AutoResume"
).is_true()
+126
View File
@@ -0,0 +1,126 @@
## T-949 tests: SystemIndex — the cached StarMapResponse reader that replaces
## the direct star_map_data.json FileAccess read (atlas_app.gd's Reach screen,
## economics OverviewScreen).
##
## Fixtures use {"status": "Ready", "nodes": [...]} — the shape
## protocol.gd's star_map_response_from_raw hands to ingest() AFTER unwrapping
## the real StarMapResponse's status/data envelope (dudley-atlas-server
## contract, 2026-07-14); SystemIndex itself never sees "data" directly.
##
## SystemIndex's cache is process-global (static var — there is no per-call
## instance), so these tests are written to be order-independent: every test
## calls ingest() with its own "Ready" fixture first, which fully OVERWRITES
## the cache rather than appending, so prior test pollution can't leak into
## an assertion (see system_index.gd's ingest()).
class_name TestSystemIndex
extends GdUnitTestSuite
func test_ingest_sorts_by_proper_name() -> void:
SystemIndex.ingest(
{
"status": "Ready",
"nodes":
[
{"system_id": "GJ 71", "proper_name": "Tau Ceti"},
{"system_id": "GJ 0", "proper_name": "Sol"},
{"system_id": "GJ 903", "proper_name": "Aldrin"},
]
}
)
var sorted: Array = SystemIndex.get_sorted_systems()
assert_int(sorted.size()).is_equal(3)
assert_str(sorted[0].get("proper_name")).is_equal("Aldrin")
assert_str(sorted[1].get("proper_name")).is_equal("Sol")
assert_str(sorted[2].get("proper_name")).is_equal("Tau Ceti")
func test_ingest_falls_back_to_system_id_when_proper_name_missing() -> void:
SystemIndex.ingest(
{
"status": "Ready",
"nodes":
[
{"system_id": "GJ 999"},
{"system_id": "GJ 001"},
]
}
)
var sorted: Array = SystemIndex.get_sorted_systems()
assert_int(sorted.size()).is_equal(2)
assert_str(sorted[0].get("system_id")).is_equal("GJ 001")
assert_str(sorted[1].get("system_id")).is_equal("GJ 999")
func test_ingest_drops_nodes_with_empty_system_id() -> void:
SystemIndex.ingest(
{
"status": "Ready",
"nodes":
[
{"system_id": "GJ 71", "proper_name": "Tau Ceti"},
{"proper_name": "No id"},
{"system_id": "", "proper_name": "Empty id"},
]
}
)
var sorted: Array = SystemIndex.get_sorted_systems()
assert_int(sorted.size()).is_equal(1)
assert_str(sorted[0].get("system_id")).is_equal("GJ 71")
func test_ingest_marks_loaded() -> void:
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 71"}]})
assert_bool(SystemIndex.is_loaded()).is_true()
func test_ingest_overwrites_not_appends() -> void:
SystemIndex.ingest(
{"status": "Ready", "nodes": [{"system_id": "GJ 1"}, {"system_id": "GJ 2"}]}
)
assert_int(SystemIndex.get_sorted_systems().size()).is_equal(2)
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 3"}]})
# A second StarMapResponse (e.g. a reconnect) replaces the cache wholesale
# rather than accumulating duplicates.
assert_int(SystemIndex.get_sorted_systems().size()).is_equal(1)
assert_str(SystemIndex.get_sorted_systems()[0].get("system_id")).is_equal("GJ 3")
func test_ingest_ignores_non_ready_status() -> void:
# Seed a known-good cache first...
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 1"}]})
# ...an Error response (protocol.gd already reduces it to nodes=[]) must
# NOT wipe the cache down to empty — the last good data survives.
SystemIndex.ingest({"status": "Error", "error": "db unavailable", "nodes": []})
assert_int(SystemIndex.get_sorted_systems().size()).override_failure_message(
"a non-Ready StarMapResponse must not overwrite the cache"
).is_equal(1)
assert_str(SystemIndex.get_sorted_systems()[0].get("system_id")).is_equal("GJ 1")
func test_request_refresh_retries_after_a_failed_ingest() -> void:
# The retry semantic itself (review H4): a failed ingest must clear the
# in-flight flag so a later request_refresh() actually asks again — not
# silently no-op forever because a prior request was "in flight".
SystemIndex.reset_test_state()
SystemIndex.request_refresh()
assert_bool(SystemIndex.has_pending_request()).is_true()
assert_bool(SystemIndex.is_loaded()).is_false()
# A failed fetch arrives: stays unloaded, and the in-flight flag clears...
SystemIndex.ingest({"status": "Error", "error": "db unavailable", "nodes": []})
assert_bool(SystemIndex.has_pending_request()).override_failure_message(
"a failed ingest must clear _requested so a later refresh can retry"
).is_false()
assert_bool(SystemIndex.is_loaded()).is_false()
# ...so a retry genuinely re-requests instead of no-opping.
SystemIndex.request_refresh()
assert_bool(SystemIndex.has_pending_request()).is_true()
SystemIndex.reset_test_state()
func test_request_refresh_does_not_crash_when_already_loaded() -> void:
SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 71"}]})
# Idempotent no-op once loaded — must not error (SimBridge is in
# test_mode, so request_star_map() would no-op regardless).
SystemIndex.request_refresh()
assert_bool(SystemIndex.is_loaded()).is_true()
+23 -1
View File
@@ -22,6 +22,10 @@ func _ready() -> void:
func on_install() -> void:
_load_system_data()
# T-949: the star map now arrives over the bridge, possibly after this app
# is already installed (or before the handshake completes at all) — refresh
# once it lands instead of assuming _load_system_data's first call has data.
SimBridge.star_map_received.connect(_on_star_map_received)
var implant_theme = load("res://ui/implant/default_implant.tres")
_reach_screen = ReachScreen.new()
@@ -72,7 +76,10 @@ func _handle_key(event: InputEventKey) -> void:
KEY_M:
HudGroups.close_app()
KEY_ESCAPE:
if current_screen_id() == "system" and _system_screen and (_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()):
var has_open_panel: bool = _system_screen and (
_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()
)
if current_screen_id() == "system" and has_open_panel:
_system_screen.close_panels()
elif current_screen_id() == "reach":
HudGroups.close_app()
@@ -134,6 +141,19 @@ func _forward_economics_link(system_id: String) -> void:
economics_link_requested.emit(system_id)
## T-949: the star map arrived — cache it, rebuild the local system
## list/lookup, and push it into whichever screens already exist. set_systems()
## is safe to call again after initial setup (ReachScreen/SystemScreen both
## just recompute their layout from the new data).
func _on_star_map_received(response: Dictionary) -> void:
SystemIndex.ingest(response)
_load_system_data()
if _reach_screen:
_reach_screen.set_systems(_systems, _system_lookup)
if _system_screen:
_system_screen.set_systems(_systems)
# =============================================================================
# Helpers
# =============================================================================
@@ -147,6 +167,8 @@ func _system_idx_by_id(system_id: String) -> int:
func _load_system_data() -> void:
SystemIndex.request_refresh()
_systems = SystemIndex.get_sorted_systems()
_system_lookup.clear()
for node: Dictionary in _systems:
_system_lookup[node.get("system_id", "")] = node
@@ -0,0 +1,161 @@
extends ImplantPanel
## Left-side generation-overlay legend (D-226 item 3, T-960) — the shape/color
## key for the generation overlay group (attractor types, sub-biome colors,
## district morphology, road/rail authority + style, settlement size/capital).
##
## This script has no `class_name` on purpose, mirroring atlas_overlay_bar.gd
## (review #8 there): the owner (AtlasViewer) passes the viewer reference to
## _init(), and a `class_name` + required-arg _init() combo is a Godot editor
## footgun. Instance it via
## load("res://ui/implant/apps/atlas/atlas_legend_panel.gd").new(self).
##
## Data-driven (GENERATION_LEGEND below): one spec entry per generation
## overlay id — multiple entries may share an id (e.g. gen_l1_attractors has
## both a shape key and a color key, D-226's shape-vs-color separation taught
## the same way here as it is drawn on the map). Adding a future layer's
## legend is a new table row, never a layout change. refresh() shows only the
## sections whose overlay is currently toggled on, and hides the whole panel
## when none are active (invisible when not needed).
const PANEL_MARGIN: float = 16.0
const LEGEND_PANEL_WIDTH: float = 260.0
# Generation overlay colors (T-960) — kept in sync with the actual render
# values in atlas_marker_overlay.gd; duplicated rather than cross-referenced,
# matching the existing COLOR_ROAD/COLOR_RAIL/COLOR_CITY precedent shared
# between atlas_viewer.gd and atlas_marker_overlay.gd (each file owns its own
# reading of the palette: this one for the legend, the marker overlay for the
# draw calls).
const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85)
const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85)
const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85)
const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
const COLOR_SETTLEMENT_GEN: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_SETTLEMENT_CAPITAL_GEN: Color = Color(1.0, 0.92, 0.55, 1.0)
## One entry per generation-overlay id. "color": Color.TRANSPARENT means
## "shape/style carries the meaning here, let the theme's dim text color
## apply" — used for every row where color is NOT the encoded axis.
const GENERATION_LEGEND: Array = [
{
"overlay_id": "gen_l1_rivers",
"title": "RIVERS — L1",
"rows": [
{"glyph": "", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "channel / confluence"},
{"glyph": "", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "sea mouth"},
],
},
{
"overlay_id": "gen_l1_basins",
"title": "DRAINAGE BASINS — L1",
"rows": [
{"glyph": "", "color": Color(0.45, 0.65, 0.85, 0.70), "label": "basin fill + boundary"},
],
},
{
"overlay_id": "gen_l1_attractors",
"title": "ATTRACTORS — L1 (shape = type)",
"rows": [
{"glyph": "", "color": Color.TRANSPARENT, "label": "river mouth"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "coastal access"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "river crossing"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "valley floor"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "pass entrance"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "lake shore"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "plain center"},
],
},
{
"overlay_id": "gen_l1_attractors",
"title": "SUB-BIOME — L1 (color)",
"rows": [
{"glyph": "", "color": Color(0.25, 0.72, 0.65, 0.90), "label": "tropical / coastal"},
{"glyph": "", "color": Color(0.45, 0.68, 0.45, 0.90), "label": "temperate"},
{"glyph": "", "color": Color(0.78, 0.62, 0.35, 0.90), "label": "arid"},
{"glyph": "", "color": Color(0.52, 0.58, 0.72, 0.90), "label": "alpine"},
{"glyph": "", "color": Color(0.40, 0.60, 0.52, 0.90), "label": "wetland"},
{"glyph": "", "color": Color(0.55, 0.68, 0.82, 0.90), "label": "cold"},
],
},
{
"overlay_id": "gen_district",
"title": "DISTRICT MORPHOLOGY — coarse",
"rows": [
{"glyph": "", "color": Color.TRANSPARENT, "label": "terrain-colored fill (17 zones)"},
],
},
{
"overlay_id": "gen_l2_roads",
"title": "ROADS/RAIL — L2 (color = authority)",
"rows": [
{"glyph": "", "color": COLOR_ROAD_ADMINISTRATIVE, "label": "administrative"},
{"glyph": "", "color": COLOR_ROAD_CORPORATE, "label": "corporate"},
{"glyph": "", "color": COLOR_ROAD_COMMUNAL, "label": "communal"},
{"glyph": "", "color": COLOR_ROAD_TRADE, "label": "trade"},
{"glyph": "", "color": COLOR_ROAD_ABANDONED, "label": "abandoned"},
],
},
{
"overlay_id": "gen_l2_roads",
"title": "LINE STYLE (road vs rail)",
"rows": [
{"glyph": "", "color": Color.TRANSPARENT, "label": "road (solid)"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "rail (dashed)"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "junction (3+ ways)"},
],
},
{
"overlay_id": "gen_l3_settlements",
"title": "SETTLEMENTS — L3 (size = population)",
"rows": [
{"glyph": "", "color": COLOR_SETTLEMENT_GEN, "label": "settlement"},
{"glyph": "", "color": COLOR_SETTLEMENT_CAPITAL_GEN, "label": "capital / major hub"},
],
},
]
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
func _init(viewer_ref = null) -> void:
_viewer = viewer_ref
custom_minimum_size.x = LEGEND_PANEL_WIDTH
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
func reposition() -> void:
position = Vector2(PANEL_MARGIN, 60.0)
## Rebuilds from GENERATION_LEGEND, showing only the sections whose overlay is
## currently toggled on — invisible when no generation overlay is active,
## updates every time AtlasViewer.set_overlay_visible() runs.
func refresh() -> void:
if _viewer == null:
return
var active_specs: Array = []
for spec: Dictionary in GENERATION_LEGEND:
if _viewer.is_overlay_visible(str(spec.get("overlay_id", ""))):
active_specs.append(spec)
clear()
visible = not active_specs.is_empty()
if active_specs.is_empty():
return
add_component(
ImplantHeader.new("GENERATION LEGEND", "%d layer(s) active" % active_specs.size())
)
add_component(ImplantSeparator.new())
for i: int in range(active_specs.size()):
var spec: Dictionary = active_specs[i]
add_component(ImplantTextBlock.new(str(spec.get("title", ""))))
for row: Dictionary in spec.get("rows", []):
var text: String = "%s %s" % [str(row.get("glyph", "-")), str(row.get("label", ""))]
add_component(ImplantDataRow.new(text, row.get("color", Color.TRANSPARENT)))
if i < active_specs.size() - 1:
add_component(ImplantSeparator.new())
reposition()
@@ -16,6 +16,8 @@ extends Node2D
## corp_presence (toggleable) Tier 1 corp dots placeholder
## stockpile_weeks (locked) gated by corporate contact
## production_vs_baseline (locked) gated by insider access
## gen_l2_roads (toggleable) T-960 — road/rail graph, color = authority
## gen_l3_settlements (toggleable) T-960 — settlement placements, size = population
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_POLITICAL: Color = Color(0.25, 0.50, 0.75, 0.14)
@@ -41,6 +43,75 @@ const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06)
const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45)
const GEN_ATTRACTOR_MIN_STRENGTH: float = 0.15
## Sub-biome → marker color (Araminta's palette, D-226). Grouped pairs share a
## color since they read the same on the map; see _sub_biome_color.
const SUB_BIOME_COLORS: Dictionary = {
"TropicalWet": Color(0.25, 0.72, 0.65, 0.90), # teal — coastal/tropical
"CoastalLowland": Color(0.25, 0.72, 0.65, 0.90),
"TemperateForest": Color(0.45, 0.68, 0.45, 0.90), # sage — temperate
"TemperateGrassland": Color(0.45, 0.68, 0.45, 0.90),
"Desert": Color(0.78, 0.62, 0.35, 0.90), # sand — arid
"Savanna": Color(0.78, 0.62, 0.35, 0.90),
"Alpine": Color(0.52, 0.58, 0.72, 0.90), # slate — alpine
"Wetland": Color(0.40, 0.60, 0.52, 0.90), # muted teal — wetland
"Tundra": Color(0.55, 0.68, 0.82, 0.90), # cool grey-blue — cold
"BorealForest": Color(0.55, 0.68, 0.82, 0.90),
}
const COLOR_SUB_BIOME_DEFAULT: Color = Color(0.72, 0.72, 0.76, 0.90) # default grey
## MorphologyZone discriminant → overlay colour (D-239 §6 order, T-1046).
## ~0.55 alpha so the heightmap shows through: water blues, plains greens,
## uplands greys/browns, volcanic dark red.
const MORPHOLOGY_COLORS: Array = [
Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean
Color(0.20, 0.40, 0.65, 0.55), # 1 Lake
Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat
Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand
Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast
Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord
Color(0.40, 0.65, 0.60, 0.55), # 6 Delta
Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine
Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain
Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank
Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach
Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain
Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor
Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass
Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine
Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic
Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland
]
# Province boundaries (D-205, #927)
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
const PROVINCE_BORDER_WIDTH: float = 1.2
# T-960 L2 — road/rail graph (D-211, T-1038). MaintenanceAuthority colors —
# Corporate reuses COLOR_CORP's amber and Trade reuses the base D-191
# COLOR_ROAD tan (long-haul trade routes ARE the base "road" concept, now
# split out by authority) so the new encoding stays visually consistent with
# the existing overlay palette instead of introducing an unrelated hue set.
const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85)
const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85)
const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85)
const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
const COLOR_ROAD_JUNCTION: Color = Color(0.85, 0.85, 0.90, 0.80)
const ROAD_JUNCTION_MIN_DEGREE: int = 3 # mirrors server's JUNCTION_DEGREE (road_graph.rs)
# T-960 L3 — settlement placements (D-211, T-955's CityPlacement). Regular
# settlements reuse the legacy COLOR_CITY gold (same concept — "this is a
# city" reads consistently everywhere on the Atlas); capitals get a brighter
# variant AND a distinct star shape (D-226 shape-encodes-identity).
const COLOR_SETTLEMENT: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_SETTLEMENT_CAPITAL: Color = Color(1.0, 0.92, 0.55, 1.0)
# SettlementEntry.size_class (dudley-atlas-server contract, 2026-07-14) — the
# same D-211 Tier A/B/C cutoffs already used for placement, now a render size.
const SETTLEMENT_RADII: Dictionary = {"Major": 6.0, "Standard": 4.0, "Minor": 2.5}
const SETTLEMENT_RADIUS_DEFAULT: float = 2.5
const SETTLEMENT_LABEL_MIN_ZOOM: float = 2.0
const RAIL_DASH_ON: float = 6.0
const RAIL_DASH_OFF: float = 4.0
@@ -133,6 +204,17 @@ func _draw() -> void:
_draw_gen_rivers(layer1)
if viewer.is_overlay_visible("gen_l1_attractors"):
_draw_gen_attractors(layer1)
# L2 roads / L3 settlements share the same working grid (T-960) —
# both are positioned from the same cascade run as Layer1, so they
# reuse the _gen_grid_*/_gen_tex_* mapping set up above.
if viewer.is_overlay_visible("gen_l2_roads"):
var road_graph: Variant = viewer.get_generation_road_graph()
if road_graph is Dictionary:
_draw_gen_roads(road_graph)
if viewer.is_overlay_visible("gen_l3_settlements"):
var settlements: Variant = viewer.get_generation_settlements()
if settlements != null:
_draw_gen_settlements(settlements)
# POIs (non-gate first, then gates on top if enabled)
_draw_pois(markers)
@@ -284,10 +366,6 @@ static func _city_key(city: Dictionary) -> String:
# Province boundaries (D-205, #927)
# =============================================================================
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
const PROVINCE_BORDER_WIDTH: float = 1.2
func _draw_province_boundaries(markers: Dictionary) -> void:
var provinces: Array = markers.get("provinces", [])
@@ -416,30 +494,6 @@ func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void:
draw_rect(Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5), _morphology_color(int(morphology[i])))
## MorphologyZone discriminant → overlay colour (D-239 §6 order). ~0.55 alpha so
## the heightmap shows through: water blues, plains greens, uplands greys/browns,
## volcanic dark red.
const MORPHOLOGY_COLORS: Array = [
Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean
Color(0.20, 0.40, 0.65, 0.55), # 1 Lake
Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat
Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand
Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast
Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord
Color(0.40, 0.65, 0.60, 0.55), # 6 Delta
Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine
Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain
Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank
Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach
Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain
Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor
Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass
Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine
Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic
Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland
]
func _morphology_color(zone: int) -> Color:
if zone >= 0 and zone < MORPHOLOGY_COLORS.size():
return MORPHOLOGY_COLORS[zone]
@@ -499,23 +553,11 @@ func _draw_gen_attractors(layer1: Dictionary) -> void:
## Sub-biome → marker color (Araminta's palette). Color is additive info; shape
## carries the attractor-type identity (survives monochrome capture).
## carries the attractor-type identity (survives monochrome capture). Table
## lookup (SUB_BIOME_COLORS) rather than a multi-return match — same pattern
## as MORPHOLOGY_COLORS/_morphology_color below.
func _sub_biome_color(sub_biome: String) -> Color:
match sub_biome:
"TropicalWet", "CoastalLowland":
return Color(0.25, 0.72, 0.65, 0.90) # teal — coastal/tropical
"TemperateForest", "TemperateGrassland":
return Color(0.45, 0.68, 0.45, 0.90) # sage — temperate
"Desert", "Savanna":
return Color(0.78, 0.62, 0.35, 0.90) # sand — arid
"Alpine":
return Color(0.52, 0.58, 0.72, 0.90) # slate — alpine
"Wetland":
return Color(0.40, 0.60, 0.52, 0.90) # muted teal — wetland
"Tundra", "BorealForest":
return Color(0.55, 0.68, 0.82, 0.90) # cool grey-blue — cold
_:
return Color(0.72, 0.72, 0.76, 0.90) # default grey
return SUB_BIOME_COLORS.get(sub_biome, COLOR_SUB_BIOME_DEFAULT)
## Attractor type → marker shape (Araminta's vocabulary, 7 types).
@@ -552,6 +594,154 @@ func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) -
draw_colored_polygon(pts, color)
# =============================================================================
# Generation-cascade overlays — L2 roads / L3 settlements (T-960, D-225)
# =============================================================================
## MaintenanceAuthority (D-211/D-212) → line color. The one color-coded axis
## on this overlay — road vs rail is told apart by line style/width instead
## (_draw_gen_roads), so authority stays legible on its own.
func _road_authority_color(maintenance: String) -> Color:
match maintenance:
"Administrative":
return COLOR_ROAD_ADMINISTRATIVE
"Corporate":
return COLOR_ROAD_CORPORATE
"Communal":
return COLOR_ROAD_COMMUNAL
"Trade":
return COLOR_ROAD_TRADE
"Abandoned":
return COLOR_ROAD_ABANDONED
_:
return COLOR_ROAD_TRADE
## Inter-settlement road/rail graph (RoadGraphLayer — D-211, T-1038). Edges
## colored by MaintenanceAuthority; rail vs road told apart by line style/
## width (dashed + thin = rail, solid + wider = road) rather than a second
## color axis. Junction markers sit at Settlement nodes with 3+ incident
## edges — RoadGraphLayer trims `degree`/`length_cells` as server-internal
## bookkeeping (dudley-atlas-server contract, 2026-07-14), so degree is
## derived here from the edge endpoints instead of read off the node.
func _draw_gen_roads(road_graph: Dictionary) -> void:
var edges: Array = road_graph.get("edges", [])
var degree_by_index: Dictionary = {}
for e: Variant in edges:
if not e is Dictionary:
continue
var from_i: int = int(e.get("from", -1))
var to_i: int = int(e.get("to", -1))
degree_by_index[from_i] = int(degree_by_index.get(from_i, 0)) + 1
degree_by_index[to_i] = int(degree_by_index.get(to_i, 0)) + 1
var path: Array = e.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
points.append(_gen_pos(pt))
if points.size() < 2:
continue
var color: Color = _road_authority_color(str(e.get("maintenance", "")))
if bool(e.get("is_rail", false)):
_draw_dashed_polyline(points, color, 1.1)
else:
draw_polyline(points, color, 1.6, true)
var nodes: Array = road_graph.get("nodes", [])
for i: int in range(nodes.size()):
var n: Variant = nodes[i]
if not n is Dictionary:
continue
# Mirrors the server's own RoadGraph::high_connectivity_junctions()
# filter (Settlement kind only — a Waypoint sits mid-edge and
# structurally can't exceed degree 2).
if str(n.get("kind", "")) != "Settlement":
continue
if int(degree_by_index.get(i, 0)) < ROAD_JUNCTION_MIN_DEGREE:
continue
var pos_rc: Variant = n.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
_draw_junction_marker(_gen_pos(pos_rc), 3.5)
## Small hollow diamond — distinct from both the filled attractor diamond
## (RiverCrossing, larger + filled) and the filled settlement dot.
func _draw_junction_marker(pos: Vector2, size: float) -> void:
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0, -size),
pos + Vector2(size, 0),
pos + Vector2(0, size),
pos + Vector2(-size, 0),
pos + Vector2(0, -size),
]
)
draw_polyline(pts, COLOR_ROAD_JUNCTION, 1.0, true)
## Settlement placements (SettlementLayer — D-211 Layer 3, T-955's
## CityPlacement, wired through the proxy for the first time on generated
## bodies). `is_capital` is authored (atlas_city_names.kind == 'capital'),
## not population-derived — capitals get a distinct STAR shape (D-226
## shape-encodes-identity); everything else is a circle. Size scales with
## `size_class` (Major/Standard/Minor — the same D-211 Tier A/B cutoffs
## already used for placement). `settlements` tolerates a bare Array too,
## defensively, though the confirmed shape is always {"settlements": [...]}.
func _draw_gen_settlements(settlements: Variant) -> void:
var entries: Array = []
if settlements is Array:
entries = settlements
elif settlements is Dictionary:
entries = settlements.get("settlements", [])
if entries.is_empty():
return
var font := ThemeDB.fallback_font
var show_all_labels: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM
for s: Variant in entries:
if not s is Dictionary:
continue
var pos_rc: Variant = s.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
var pos: Vector2 = _gen_pos(pos_rc)
var size_class: String = str(s.get("size_class", "Minor"))
var is_capital: bool = bool(s.get("is_capital", false))
var radius: float = float(SETTLEMENT_RADII.get(size_class, SETTLEMENT_RADIUS_DEFAULT))
if is_capital:
_draw_star(pos, radius + 2.0, COLOR_SETTLEMENT_CAPITAL)
else:
draw_circle(pos, radius + 1.0, Color(0.0, 0.0, 0.0, 0.55))
draw_circle(pos, radius, COLOR_SETTLEMENT)
var name_str: String = str(s.get("name", ""))
if name_str.is_empty():
continue
if is_capital or size_class == "Major" or show_all_labels:
draw_string(
font,
pos + Vector2(radius + 3.0, radius * 0.4),
name_str,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
8,
COLOR_FEATURE_LABEL
)
func _draw_star(pos: Vector2, size: float, color: Color) -> void:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(10):
var angle: float = -PI / 2.0 + i * PI / 5.0
var r: float = size if i % 2 == 0 else size * 0.42
pts.append(pos + Vector2(cos(angle), sin(angle)) * r)
draw_colored_polygon(pts, color)
func _path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
@@ -55,6 +55,12 @@ const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
const COLOR_EMPTY_NOTICE: Color = Color("#445566")
# D-236: Sol (system GJ-0) is permanently out of the deterministic generation
# cascade. Its markers.json keeps the legacy full-geometry FileAccess read
# (T-1073 exception) — guard _load_markers by this id, not by enumerating
# Sol's four bodies (GJ0d, GJ0d-1, GJ0e, GJ0f-2).
const SOL_SYSTEM_ID: String = "GJ-0"
# ── Overlay definitions (single source of truth, review #7) ─────────────────
## Overlay catalogue consumed by both AtlasMarkerOverlay (renders) and
## AtlasOverlayBar (exposes as toggle buttons). Groups map to the D-181 signal
@@ -145,6 +151,19 @@ const OVERLAY_DEFS: Array = [
"group": "toggle",
"tooltip": "District morphology zones (generation overlay, T-1046/D-226)."
},
{
"id": "gen_l2_roads",
"label": "RDS",
"group": "toggle",
"tooltip":
"Layer 2 — inter-settlement road/rail graph, colored by maintenance authority (generation overlay, T-960)."
},
{
"id": "gen_l3_settlements",
"label": "STL",
"group": "toggle",
"tooltip": "Layer 3 — settlement placements, sized by population (generation overlay, T-960)."
},
]
# ── Context (set by show_body) ─────────────────────────────────────────────────
@@ -157,6 +176,8 @@ var _heightmap_texture: Texture2D = null
var _markers: Dictionary = {}
var _generation_layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
var _generation_district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226)
var _generation_road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225)
var _generation_settlements: Variant = null # T-960: settlement placements from the proxy (D-225)
var _gen_pending: bool = false # #960: awaiting a Layer1 response (re-polls on Pending)
var _gen_retries: int = 0
var _grid_w: float = 512.0
@@ -164,6 +185,13 @@ var _grid_h: float = 256.0
var _tex_w: float = 1024.0
var _tex_h: float = 512.0
# T-949: body_id of an in-flight CityNamesRequest, "" if none. Non-Sol only —
# Sol keeps the synchronous legacy markers.json read (D-236). Used both to
# gate _on_city_names_received against a stale response (the viewer moved to
# a different body while a request was in flight) and to replay the request
# if the bridge wasn't connected yet when _load_markers first asked.
var _city_names_pending_body: String = ""
# ── Pan/zoom state ────────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _view_zoom: float = 1.0
@@ -190,6 +218,7 @@ var _empty_notice = null # ImplantPanel shown when heightmap missing
var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8)
var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition)
var _gen_pending_indicator = null # ImplantPending — loaded by path, not a class_name dep
var _legend_panel = null # ImplantPanel — D-226 item 3, left-side generation-overlay legend
func _ready() -> void:
@@ -222,10 +251,16 @@ func _ready() -> void:
_build_city_panel()
_build_empty_notice()
_build_overlay_bar()
_build_legend_panel()
# #960: receive proxied Layer-1 generation output (D-225).
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
# T-949: receive the per-body atlas city-name pool (non-Sol _load_markers
# path) and know when a reconnect should replay a pending request.
SimBridge.city_names_received.connect(_on_city_names_received)
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
# #960: diegetic "generating" indicator, shown only while the proxy is Pending.
# Loaded by path (not `ImplantPending.new()`) so a stale global-class cache —
# e.g. a running session that hasn't re-imported after this class was added —
@@ -241,6 +276,10 @@ func _ready() -> void:
func _exit_tree() -> void:
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
if SimBridge.city_names_received.is_connected(_on_city_names_received):
SimBridge.city_names_received.disconnect(_on_city_names_received)
if SimBridge.connection_state_changed.is_connected(_on_connection_state_changed):
SimBridge.connection_state_changed.disconnect(_on_connection_state_changed)
## Called by RegionalScreen.enter() when entering the viewer for a specific body.
@@ -275,6 +314,7 @@ func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
return
_overlay_visibility[overlay_id] = visible_state
_overlay_node.queue_redraw()
_legend_panel.refresh()
func is_overlay_visible(overlay_id: String) -> bool:
@@ -293,6 +333,13 @@ func get_markers() -> Dictionary:
return _markers
## Current pan/zoom scale — exposed so AtlasMarkerOverlay can gate zoom-
## dependent behavior (e.g. T-960 settlement name labels "at sensible zoom")
## without reaching into the viewer's private state.
func get_view_zoom() -> float:
return _view_zoom
## #960: store the Layer-1 generation output (from atlas_layers_received) and
## redraw the overlay. The marker overlay reads it via get_generation_layer1().
func set_generation_layer1(layer1: Variant) -> void:
@@ -317,6 +364,30 @@ func get_generation_district_grid() -> Variant:
return _generation_district_grid
## T-960: store the L2 road/rail graph (RoadGraphLayer) from the proxy and
## redraw. The marker overlay reads it via get_generation_road_graph().
func set_generation_road_graph(graph: Variant) -> void:
_generation_road_graph = graph
if _overlay_node:
_overlay_node.queue_redraw()
func get_generation_road_graph() -> Variant:
return _generation_road_graph
## T-960: store the L3 settlement placements (SettlementLayer) from the proxy
## and redraw. The marker overlay reads it via get_generation_settlements().
func set_generation_settlements(settlements: Variant) -> void:
_generation_settlements = settlements
if _overlay_node:
_overlay_node.queue_redraw()
func get_generation_settlements() -> Variant:
return _generation_settlements
## #960: request the body's Layer-1 cascade output from the server proxy.
## No-op in test mode (SimBridge has no server connection) — the overlays
## simply stay empty, which is the correct serverless behavior.
@@ -342,6 +413,8 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
_set_gen_indicator(false)
set_generation_layer1(response.get("layer1"))
set_generation_district_grid(response.get("district_grid"))
set_generation_road_graph(response.get("road_graph"))
set_generation_settlements(response.get("settlements"))
"Pending":
if _gen_retries < GEN_MAX_RETRIES:
_gen_retries += 1
@@ -433,13 +506,36 @@ func _load_heightmap() -> void:
_tex_h = float(_heightmap_texture.get_height())
## T-949: for Sol (D-236) this is still the synchronous legacy file read; for
## every other body it fires an async CityNamesRequest and _markers populates
## later, in _on_city_names_received.
func _load_markers() -> void:
_markers = {}
# Reset grid dims alongside _tex_* in _load_heightmap so we start from a
# known baseline regardless of which body ran previously.
_grid_w = _tex_w
_grid_h = _tex_h
_city_names_pending_body = ""
var system_id: String = _dict_str(_system, "system_id", "")
if system_id == SOL_SYSTEM_ID:
_load_sol_markers_legacy()
return
var body_id: String = _dict_str(_body, "body_id", "")
if body_id.is_empty():
return
_city_names_pending_body = body_id
SimBridge.request_city_names(body_id)
## D-236/T-1073 SOL EXCEPTION (load-bearing — do not "clean up"): Sol bodies
## (GJ0d, GJ0d-1, GJ0e, GJ0f-2, system GJ-0) are permanently excluded from the
## deterministic generation cascade (D-236), so there is no server-side
## atlas_city_names/geometry source to request instead — Sol's markers.json
## is the one client file read T-949 does NOT migrate, pending T-1073
## (gated on Q-107). This is byte-for-byte the pre-T-949 _load_markers body.
func _load_sol_markers_legacy() -> void:
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
@@ -467,6 +563,50 @@ func _load_markers() -> void:
_grid_h = float(grid.get("h", _tex_h))
## T-949: CityNamesResponse handler for non-Sol bodies (dudley-atlas-server
## contract, 2026-07-14). Ignores responses for a body the viewer has since
## navigated away from. `cities` is a flat [{city_id, name, is_capital}] array
## — NOT the legacy markers.json shape (no rivers/oceans/mountain_ranges pool,
## no position; positions arrive separately via the gen_l3_settlements
## overlay/T-960's SettlementLayer) — stored under a dedicated "city_names"
## key so it can never collide with the top-level "cities" key the legacy
## Sol reader/_draw_cities()/_find_city_at() expect (those entries have no
## pos/center/lat+lon, so they'd all plot at Vector2.ZERO if merged in).
## SolExcluded is a defensive backstop (D-236): the server refuses Sol bodies
## even though atlas_viewer.gd's own SOL_SYSTEM_ID guard should make this
## path rare — falls back to the legacy synchronous read either way.
func _on_city_names_received(response: Dictionary) -> void:
var body_id: String = _dict_str(_body, "body_id", "")
if str(response.get("body_id", "")) != body_id:
return
_city_names_pending_body = ""
match str(response.get("status", "")):
"Ready":
_markers = {"city_names": response.get("cities", [])}
"SolExcluded":
_load_sol_markers_legacy()
_:
pass # Error — leave markers empty (#960/D-191's empty-markers case)
queue_redraw()
_overlay_node.queue_redraw()
## T-949: the Atlas can be opened before the bridge finishes its handshake —
## replay the in-flight CityNamesRequest once we actually reach CONNECTED
## instead of leaving it stranded (request_city_names() no-ops silently while
## disconnected, and unlike the Layer1 proxy there is no Pending status to
## trigger a retry timer).
func _on_connection_state_changed(_old_state: int, new_state: int) -> void:
if new_state == SimBridge.ConnectionState.CONNECTED and not _city_names_pending_body.is_empty():
SimBridge.request_city_names(_city_names_pending_body)
## True while a non-Sol body's CityNamesRequest is in flight (T-949). Sol
## bodies never set this — they use the synchronous legacy read (D-236).
func has_pending_city_names_request() -> bool:
return not _city_names_pending_body.is_empty()
# =============================================================================
# View transform
# =============================================================================
@@ -825,6 +965,25 @@ func _position_overlay_bar() -> void:
_overlay_bar.position = Vector2(sz.x - bar_w - PANEL_MARGIN, PANEL_MARGIN)
# =============================================================================
# Generation legend panel (D-226 item 3)
# =============================================================================
## Loaded by path (matching atlas_overlay_bar.gd, review #8): the panel needs
## the viewer reference at construction time, and a class_name + required-arg
## _init() combo is a Godot editor footgun. GENERATION_LEGEND (the spec table)
## and the road/settlement legend colors live on this script, not here — see
## atlas_legend_panel.gd.
func _build_legend_panel() -> void:
var LegendScript := load("res://ui/implant/apps/atlas/atlas_legend_panel.gd")
_legend_panel = LegendScript.new(self)
_legend_panel.name = "GenerationLegend"
_legend_panel.theme_resource = _implant_theme
add_child(_legend_panel)
_legend_panel.refresh()
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
if _city_panel:
@@ -833,3 +992,5 @@ func _notification(what: int) -> void:
_position_empty_notice()
if _overlay_bar:
_position_overlay_bar()
if _legend_panel:
_legend_panel.reposition()
@@ -46,6 +46,9 @@ func _ready() -> void:
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
_load_system_list()
# T-949: the star map now arrives over the bridge, possibly after this
# screen is already built (or before the handshake completes at all).
SimBridge.star_map_received.connect(_on_star_map_received)
_build_panel()
economy_data_updated.connect(_on_economy_data_updated)
@@ -111,11 +114,23 @@ func navigate(delta: int) -> void:
func _load_system_list() -> void:
SystemIndex.request_refresh()
_systems = SystemIndex.get_sorted_systems()
if not _systems.is_empty():
# Only auto-select on the FIRST populated load — a late-arriving refresh
# (T-949: the star map is now fetched over the bridge) must not stomp a
# selection the player already navigated to via navigate().
if selected_system.is_empty() and not _systems.is_empty():
selected_system = _systems[0].get("system_id", "")
## T-949: the star map arrived — cache it and rebuild the panel with real data
## (it may have been showing the "—" loading placeholders until now).
func _on_star_map_received(response: Dictionary) -> void:
SystemIndex.ingest(response)
_load_system_list()
_rebuild_panel()
# =============================================================================
# Visual panel — D-169 ImplantPanel composition
# =============================================================================
+74 -19
View File
@@ -1,31 +1,86 @@
class_name SystemIndex
## Shared system data loader for implant apps (#844).
## Static helper — call as SystemIndex.get_sorted_systems().
## Shared, cached system data loader for implant apps (#844, T-949).
##
## T-949: replaces the direct star_map_data.json FileAccess read with a
## StarMapRequest over the bridge (D-010 — the client never reads game data
## files directly). A static cache so every caller (AtlasApp's Reach screen,
## the economics OverviewScreen) shares one fetch instead of each re-asking.
##
## Usage per caller:
## 1. Call request_refresh() once (e.g. in _ready()/on_install()) — idempotent,
## no-op once loaded or already in flight.
## 2. Connect to SimBridge.star_map_received and, in the handler, re-pull
## get_sorted_systems() to refresh with the now-populated list.
## The Atlas can open before the bridge finishes its handshake —
## SimBridge.request_star_map() remembers the request and fires it
## automatically the instant the connection reaches CONNECTED, so callers
## never need to poll or retry themselves.
const DATA_PATH := "res://data/star_map_data.json"
static var _cache: Array = []
static var _loaded: bool = false
static var _requested: bool = false
## Sorted node list (by proper_name, falling back to system_id), or [] if the
## star map has not arrived yet. Callers should re-pull this after
## SimBridge.star_map_received fires.
static func get_sorted_systems() -> Array:
if not FileAccess.file_exists(DATA_PATH):
push_warning("SystemIndex: %s not found" % DATA_PATH)
return []
var file := FileAccess.open(DATA_PATH, FileAccess.READ)
if file == null:
push_warning("SystemIndex: could not open %s" % DATA_PATH)
return []
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return []
var nodes: Array = []
for node: Dictionary in parsed.get("nodes", []):
return _cache
static func is_loaded() -> bool:
return _loaded
## True while a StarMapRequest is considered in flight — the test-observable
## counterpart of the retry bookkeeping (see ingest()'s Error path).
static func has_pending_request() -> bool:
return _requested
## Test-only: clear the process-global static cache. Statics leak across
## gdUnit suites in one process — the same class as the
## SimBridge._star_map_wanted leak fixed in this PR (review H2/T1).
## SimBridge.reset_test_state() calls this, so every suite already using it
## gets both resets.
static func reset_test_state() -> void:
_cache = []
_loaded = false
_requested = false
## Ask the bridge for the star map if it hasn't been fetched yet. Safe to call
## from every screen's _ready()/on_install() — idempotent once loaded or a
## request is already in flight.
static func request_refresh() -> void:
if _loaded or _requested:
return
_requested = true
SimBridge.request_star_map()
## Feed a decoded StarMapResponse (from a SimBridge.star_map_received handler)
## into the cache. Sorts once here so every caller gets the same order for free.
## Only a "Ready" status is trusted — an Error response (protocol.gd's
## star_map_response_from_raw already reduces it to status/error/nodes=[])
## must NOT mark the cache _loaded, and must clear _requested so a later
## request_refresh() retries instead of treating the failed fetch as
## permanently done.
static func ingest(response: Dictionary) -> void:
if str(response.get("status", "")) != "Ready":
_requested = false
return
var nodes: Array = response.get("nodes", [])
var sorted: Array = []
for node: Dictionary in nodes:
var sid: String = node.get("system_id", "")
if not sid.is_empty():
nodes.append(node)
nodes.sort_custom(
sorted.append(node)
sorted.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
var na: String = a.get("proper_name", a.get("system_id", ""))
var nb: String = b.get("proper_name", b.get("system_id", ""))
return na < nb
)
return nodes
_cache = sorted
_loaded = true
+451
View File
@@ -0,0 +1,451 @@
//! Atlas data-delivery proxy (T-949) — two thin, independent endpoints
//! alongside the per-body layer-stream proxy ([`crate::atlas::layer_proxy`]):
//!
//! - **Star map** ([`StarMapRequest`]/[`StarMapResponse`]): a session-scoped,
//! non-per-body proxy over the static `client/data/star_map_data.json`
//! dataset (produced by `tooling/generate-star-map-data.py`). Reads the file
//! fresh on every request — no caching, no staleness handling — because the
//! client only asks once per session when the Atlas star-map view opens.
//! The parsed JSON is passed through as an opaque `serde_json::Value` rather
//! than a hand-mirrored Rust struct: the server has no reason to understand
//! `_meta`/`nodes`/`edges`, so this stays a genuinely thin proxy that never
//! needs a code change when the generator's JSON shape evolves.
//!
//! - **City names** ([`CityNamesRequest`]/[`CityNamesResponse`]): a per-body
//! names-only settlement list read straight from `atlas_city_names`
//! (replaces the client's legacy `markers.json` names-only read, D-223, for
//! every body except Sol). Available immediately — it does not wait on the
//! generation cascade the way `AtlasLayerResponse.settlements` does.
//!
//! **Sol exclusion (D-236):** Sol (system `GJ-0`) is permanently out of the
//! generation cascade. `handle_city_names_request` checks
//! [`CityContextReader::is_sol_body`] first and returns
//! [`CityNamesStatus::SolExcluded`] with an empty city list — the client keeps
//! its legacy authored `markers.json` read for Sol; this proxy never runs Sol
//! through any cascade or DB-derived path.
//!
//! **Inbound demux (D-225 extension):** the existing array-vs-map trick
//! (`Vec<PlayerInput>` vs. `AtlasLayerRequest`) is preserved byte-for-byte —
//! neither request type gained fields. The two new map shapes each carry a
//! **mandatory boolean discriminator field** (`star_map` / `city_names`)
//! instead of relying on "which optional field is missing" trial-order
//! fragility: `CityNamesRequest{city_names: bool, body_id: String}` and
//! `AtlasLayerRequest{body_id: String, up_to: CascadeLayer}` both key on
//! `body_id`, and serde's derived `Deserialize` silently ignores unknown
//! fields by default — so a payload carrying every field either shape wants
//! would ambiguously satisfy both if disambiguation relied on "does this
//! parse at all". Requiring a field the *other* shapes don't have at all
//! (missing required field ⇒ hard deserialize failure, not silent ignore)
//! keeps every shape mutually exclusive without adding `#[serde(deny_unknown_fields)]`
//! to `AtlasLayerRequest` (which would risk breaking any already-deployed
//! client encoder that harmlessly sends extra fields). See
//! `crate::bridge::decode_inbound` for the trial order + full rationale.
use std::path::{Path, PathBuf};
use bevy_ecs::prelude::Resource;
use serde::{Deserialize, Serialize};
use crate::atlas::city_context_reader::CityContextReader;
// ---------------------------------------------------------------------------
// Star map proxy
// ---------------------------------------------------------------------------
/// A client request for the star-map dataset (T-949a). Non-per-body — the
/// map contains every system in the Reach.
///
/// `star_map` is the Inbound discriminator (see module doc): always `true`.
/// Its presence, not its value, is what disambiguates this map shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StarMapRequest {
pub star_map: bool,
}
/// Status of a [`StarMapResponse`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StarMapStatus {
/// The dataset was read and parsed (`data` is populated).
Ready,
/// IO or JSON-parse failure (message for the client log). The client
/// should keep using whatever it already has (or its own bundled copy)
/// rather than treat this as fatal.
Error(String),
}
/// A star-map response: the parsed `star_map_data.json` contents verbatim, or
/// an error status (T-949a).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StarMapResponse {
pub status: StarMapStatus,
/// Verbatim parsed JSON (`_meta`, `nodes`, `edges` — see
/// `tooling/generate-star-map-data.py`), re-serialized as MessagePack.
/// `None` unless `status == Ready`.
pub data: Option<serde_json::Value>,
}
/// Resolved path to `client/data/star_map_data.json` (T-949a). A thin resource
/// holding just the path — the file is re-read fresh on every request (no
/// caching, per the ticket: this is a one-shot-per-session read from the
/// client, so staleness handling would be pure complexity for no benefit).
#[derive(Resource, Debug, Clone)]
pub struct StarMapDataPath(pub PathBuf);
/// Serve one star-map request (T-949a): read + parse the file fresh.
pub fn handle_star_map_request(_req: &StarMapRequest, path: &Path) -> StarMapResponse {
match std::fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
Ok(data) => StarMapResponse {
status: StarMapStatus::Ready,
data: Some(data),
},
Err(e) => StarMapResponse {
status: StarMapStatus::Error(format!("star_map_data.json parse error: {e}")),
data: None,
},
},
Err(e) => StarMapResponse {
status: StarMapStatus::Error(format!(
"star_map_data.json read error ({}): {e}",
path.display()
)),
data: None,
},
}
}
// ---------------------------------------------------------------------------
// City names proxy
// ---------------------------------------------------------------------------
/// A client request for a body's authored settlement names (T-949b).
///
/// `city_names` is the Inbound discriminator (see module doc): always `true`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CityNamesRequest {
pub city_names: bool,
pub body_id: String,
}
/// Status of a [`CityNamesResponse`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CityNamesStatus {
/// Names are ready (`cities` is populated; legitimately empty for a body
/// with no authored settlements).
Ready,
/// D-236: `body_id` is a Sol body. Sol is permanently out of the
/// generation cascade and every DB-derived Atlas path — the client must
/// keep its legacy authored `markers.json` read for Sol. `cities` is empty.
SolExcluded,
/// DB/IO failure reading `atlas_city_names` (message for the client log).
Error(String),
}
/// One settlement name entry (T-949b) — see
/// [`crate::atlas::city_context_reader::CityNameRow`] for the reader-side row
/// this is built from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CityNameEntry {
pub city_id: u64,
pub name: String,
pub is_capital: bool,
}
/// A city-names response: the body's authored settlements, or a non-ready
/// status (T-949b).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CityNamesResponse {
pub body_id: String,
pub status: CityNamesStatus,
pub cities: Vec<CityNameEntry>,
}
/// Serve one city-names request (T-949b): D-236 Sol check first, then the
/// names-only `atlas_city_names` read. `city_reader` absent (no DB opened at
/// startup) is reported as `Error`, matching `layer_proxy`'s "no body source
/// resolver" convention.
pub fn handle_city_names_request(
req: &CityNamesRequest,
city_reader: Option<&CityContextReader>,
) -> CityNamesResponse {
let Some(reader) = city_reader else {
return CityNamesResponse {
body_id: req.body_id.clone(),
status: CityNamesStatus::Error("city context reader unavailable".to_string()),
cities: Vec::new(),
};
};
match reader.is_sol_body(&req.body_id) {
Ok(true) => {
return CityNamesResponse {
body_id: req.body_id.clone(),
status: CityNamesStatus::SolExcluded,
cities: Vec::new(),
};
}
Ok(false) => {}
Err(e) => {
return CityNamesResponse {
body_id: req.body_id.clone(),
status: CityNamesStatus::Error(e.to_string()),
cities: Vec::new(),
};
}
}
match reader.read_body_city_names(&req.body_id) {
Ok(rows) => CityNamesResponse {
body_id: req.body_id.clone(),
status: CityNamesStatus::Ready,
cities: rows
.into_iter()
.map(|r| CityNameEntry {
city_id: r.city_id,
name: r.name,
is_capital: r.is_capital,
})
.collect(),
},
Err(e) => CityNamesResponse {
body_id: req.body_id.clone(),
status: CityNamesStatus::Error(e.to_string()),
cities: Vec::new(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
// ─── StarMapRequest/Response ─────────────────────────────────────────────
#[test]
fn star_map_request_round_trips_msgpack() {
let req = StarMapRequest { star_map: true };
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
let decoded: StarMapRequest = rmp_serde::from_slice(&bytes).expect("decode");
assert!(decoded.star_map);
}
#[test]
fn handle_star_map_request_reads_and_parses_file() {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_starmap_{}_{n}.json", std::process::id()));
std::fs::write(
&path,
r#"{"_meta": {"v": 1}, "nodes": [1, 2], "edges": []}"#,
)
.expect("write fixture json");
let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path);
assert_eq!(resp.status, StarMapStatus::Ready);
let data = resp.data.as_ref().expect("data present on Ready");
assert_eq!(data["nodes"][0], 1);
// Round trip the whole response through MessagePack too (the actual
// wire path).
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
let decoded: StarMapResponse = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(decoded.status, StarMapStatus::Ready);
assert_eq!(decoded.data.unwrap()["nodes"][1], 2);
let _ = std::fs::remove_file(&path);
}
#[test]
fn handle_star_map_request_missing_file_is_error_not_panic() {
let path = PathBuf::from("/nonexistent/sr-test/star_map_data.json");
let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path);
assert!(matches!(resp.status, StarMapStatus::Error(_)));
assert!(resp.data.is_none());
}
#[test]
fn handle_star_map_request_malformed_json_is_error() {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("sr_starmap_bad_{}_{n}.json", std::process::id()));
std::fs::write(&path, "{ not valid json").expect("write fixture json");
let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path);
assert!(matches!(resp.status, StarMapStatus::Error(_)));
assert!(resp.data.is_none());
let _ = std::fs::remove_file(&path);
}
// ─── CityNamesRequest/Response ───────────────────────────────────────────
#[test]
fn city_names_request_round_trips_msgpack() {
let req = CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
};
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
let decoded: CityNamesRequest = rmp_serde::from_slice(&bytes).expect("decode");
assert!(decoded.city_names);
assert_eq!(decoded.body_id, "GJ1c");
}
#[test]
fn city_names_response_round_trips_msgpack() {
let resp = CityNamesResponse {
body_id: "GJ1c".into(),
status: CityNamesStatus::Ready,
cities: vec![CityNameEntry {
city_id: 1,
name: "Port Aldren".into(),
is_capital: true,
}],
};
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
let decoded: CityNamesResponse = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(decoded.status, CityNamesStatus::Ready);
assert_eq!(decoded.cities[0].name, "Port Aldren");
assert!(decoded.cities[0].is_capital);
}
/// Minimal db mirroring what `is_sol_body` + `read_body_city_names` need:
/// `bodies`, `system_history`, `atlas_city_names`.
fn make_db(body_id: &str, system_id: &str, settlement_wave: Option<&str>) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_adp_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE bodies (body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL);
CREATE TABLE system_history (
system_id TEXT PRIMARY KEY,
settlement_wave TEXT
);
CREATE TABLE atlas_city_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'city'
);",
)
.expect("create tables");
conn.execute(
"INSERT INTO bodies (body_id, system_id) VALUES (?1, ?2)",
rusqlite::params![body_id, system_id],
)
.expect("insert body");
if let Some(wave) = settlement_wave {
conn.execute(
"INSERT INTO system_history (system_id, settlement_wave) VALUES (?1, ?2)",
rusqlite::params![system_id, wave],
)
.expect("insert system_history");
}
drop(conn);
path
}
fn insert_city(db: &Path, body_id: &str, name: &str, kind: &str) {
let conn = Connection::open(db).expect("reopen");
conn.execute(
"INSERT INTO atlas_city_names (body_id, name, kind) VALUES (?1, ?2, ?3)",
rusqlite::params![body_id, name, kind],
)
.expect("insert city");
}
#[test]
fn handle_city_names_request_no_reader_is_error() {
let resp = handle_city_names_request(
&CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
},
None,
);
assert!(matches!(resp.status, CityNamesStatus::Error(_)));
assert!(resp.cities.is_empty());
}
#[test]
fn handle_city_names_request_sol_body_is_excluded() {
// D-236: system_id = GJ-0 → SolExcluded, no DB row read for cities.
let db = make_db("Earth", "GJ-0", None);
insert_city(&db, "Earth", "London", "capital");
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_city_names_request(
&CityNamesRequest {
city_names: true,
body_id: "Earth".into(),
},
Some(&reader),
);
assert_eq!(resp.status, CityNamesStatus::SolExcluded);
assert!(
resp.cities.is_empty(),
"Sol-excluded response must carry no cities even though the row exists"
);
}
#[test]
fn handle_city_names_request_sol_body_via_settlement_wave_is_excluded() {
// D-236's second signal: settlement_wave = 'origin' excludes even a
// non-GJ-0 system_id.
let db = make_db("Weirdbody", "GJ-999", Some("origin"));
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_city_names_request(
&CityNamesRequest {
city_names: true,
body_id: "Weirdbody".into(),
},
Some(&reader),
);
assert_eq!(resp.status, CityNamesStatus::SolExcluded);
}
#[test]
fn handle_city_names_request_ordinary_body_returns_names() {
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
insert_city(&db, "GJ1c", "Port Aldren", "capital");
insert_city(&db, "GJ1c", "Millbrook", "city");
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_city_names_request(
&CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
},
Some(&reader),
);
assert_eq!(resp.status, CityNamesStatus::Ready);
assert_eq!(resp.cities.len(), 2);
assert_eq!(resp.cities[0].name, "Port Aldren");
assert!(resp.cities[0].is_capital);
assert_eq!(resp.cities[1].name, "Millbrook");
assert!(!resp.cities[1].is_capital);
}
#[test]
fn handle_city_names_request_unknown_body_is_ready_with_empty_list() {
// Matches read_body_settlements' existing convention: unknown body →
// empty list under Ready, not a distinct NotFound status.
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_city_names_request(
&CityNamesRequest {
city_names: true,
body_id: "ghost".into(),
},
Some(&reader),
);
assert_eq!(resp.status, CityNamesStatus::Ready);
assert!(resp.cities.is_empty());
}
}
+74
View File
@@ -37,6 +37,13 @@ pub struct CityRecord {
/// One of: manufacturing, financial, agricultural, extraction,
/// service_mixed, institutional, transit_hub, research, military, residential.
pub economic_role: String,
/// `atlas_city_names.kind == 'capital'` (authored, not derived from
/// population). Threaded onto [`CityPlacement`] for the Atlas
/// [`SettlementLayer`](crate::atlas::layer_proxy::SettlementLayer) (T-960 §2).
/// Every current reader (`city_context_reader` and the believability
/// harness's own settlement read) selects `COALESCE(kind,'city')`, so an
/// un-authored `kind` yields `false` — there is no kind-less source left.
pub is_capital: bool,
}
// ---------------------------------------------------------------------------
@@ -48,6 +55,9 @@ pub struct CityRecord {
#[derive(Debug, Clone)]
pub struct CityPlacement {
pub city_id: u64,
/// Carried straight from the matched [`CityRecord`] (T-960 §2 — the Atlas
/// `SettlementLayer` needs a display name without a cache-hit DB read).
pub name: String,
pub position: (u16, u16),
pub attractor_type: AttractorType,
/// Integer match score (D-010). See [`cell_score`].
@@ -61,6 +71,11 @@ pub struct CityPlacement {
/// Primary street-grid axis (D-213). Derived from the anchoring attractor
/// type; pioneer/open-terrain bearings are seed-varied.
pub founding_orientation: FoundingOrientation,
/// Carried straight from [`CityRecord::population`] (T-960 §2 — the Atlas
/// `SettlementLayer` derives its coarse size class from this).
pub population: i64,
/// Carried straight from [`CityRecord::is_capital`] (T-960 §2).
pub is_capital: bool,
}
// ---------------------------------------------------------------------------
@@ -371,6 +386,7 @@ pub fn match_cities(
);
placements.push(CityPlacement {
city_id: cities[ci].city_id,
name: cities[ci].name.clone(),
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type,
score,
@@ -378,6 +394,8 @@ pub fn match_cities(
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
population: cities[ci].population,
is_capital: cities[ci].is_capital,
});
}
}
@@ -438,6 +456,7 @@ pub fn match_cities(
);
placements.push(CityPlacement {
city_id: cities[ci].city_id,
name: cities[ci].name.clone(),
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type,
score,
@@ -445,6 +464,8 @@ pub fn match_cities(
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
population: cities[ci].population,
is_capital: cities[ci].is_capital,
});
}
}
@@ -472,6 +493,7 @@ pub fn match_cities(
);
placements.push(CityPlacement {
city_id: city.city_id,
name: city.name.clone(),
position: synthetic.position,
attractor_type: AttractorType::PlainCenter,
score,
@@ -479,6 +501,8 @@ pub fn match_cities(
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
population: city.population,
is_capital: city.is_capital,
});
}
@@ -682,6 +706,7 @@ mod tests {
settlement_class: class,
population: pop,
economic_role: "manufacturing".to_string(),
is_capital: false,
}
}
@@ -706,6 +731,53 @@ mod tests {
assert!(!placements[0].synthetic);
}
/// T-960 §2: `name`/`population`/`is_capital` are carried straight from the
/// matched `CityRecord` onto every `CityPlacement`, across all three
/// placement phases (Tier A greedy, Hungarian, synthetic overflow) — the
/// Atlas `SettlementLayer` reads these from the cache with no DB access.
#[test]
fn city_record_fields_propagate_to_placement_in_every_phase() {
let mut capital = make_city(1, SettlementClass::NameLocked, 2_000_000);
capital.is_capital = true;
let tier_bc = make_city(2, SettlementClass::PopulationBudget, 80_000);
let overflow = make_city(3, SettlementClass::PopulationBudget, 10_000);
let cities = vec![capital, tier_bc, overflow];
// Two real attractors (enough for Tier A + Tier B); city 3 overflows to
// a synthetic attractor (phase 4).
let attractors = vec![
make_attractor(5, 5, AttractorType::RiverMouth, 90),
make_attractor(10, 10, AttractorType::ValleyFloor, 60),
];
let matrix = uniform_matrix();
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
512,
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
);
assert_eq!(placements.len(), 3);
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
assert_eq!(p1.name, "City1");
assert_eq!(p1.population, 2_000_000);
assert!(p1.is_capital, "capital flag must survive Tier A placement");
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
assert_eq!(p2.name, "City2");
assert_eq!(p2.population, 80_000);
assert!(!p2.is_capital);
let p3 = placements.iter().find(|p| p.city_id == 3).unwrap();
assert_eq!(p3.name, "City3");
assert_eq!(p3.population, 10_000);
assert!(!p3.is_capital);
assert!(p3.synthetic, "the third city must overflow to phase 4");
}
#[test]
fn tier_a_gets_priority() {
// NameLocked city should get the best attractor (high strength).
@@ -794,6 +866,7 @@ mod tests {
settlement_class: SettlementClass::PopulationBudget,
population: 60_000,
economic_role: "agricultural".to_string(),
is_capital: false,
},
CityRecord {
city_id: 2,
@@ -801,6 +874,7 @@ mod tests {
settlement_class: SettlementClass::PopulationBudget,
population: 80_000,
economic_role: "transit_hub".to_string(),
is_capital: false,
},
];
let attractors = vec![
+4 -1
View File
@@ -541,18 +541,21 @@ fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0)
"SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0),
COALESCE(kind,'city')
FROM atlas_city_names WHERE body_id = ?1 ORDER BY id",
)
.map_err(|e| format!("prepare city query: {e}"))?;
let rows = stmt
.query_map([body_id], |r| {
let kind: String = r.get(4)?;
Ok(CityRecord {
city_id: r.get::<_, i64>(0)? as u64,
name: r.get(1)?,
settlement_class: SettlementClass::PopulationBudget,
economic_role: r.get(2)?,
population: r.get(3)?,
is_capital: kind == "capital",
})
})
.map_err(|e| format!("city query: {e}"))?
+3
View File
@@ -526,6 +526,7 @@ mod tests {
settlement_class: SettlementClass::NameLocked,
population: 2_000_000,
economic_role: "financial".into(),
is_capital: true,
},
CityRecord {
city_id: 2,
@@ -533,6 +534,7 @@ mod tests {
settlement_class: SettlementClass::OrganicGrowth,
population: 120_000,
economic_role: "agricultural".into(),
is_capital: false,
},
];
let run = || {
@@ -618,6 +620,7 @@ mod tests {
settlement_class: SettlementClass::PopulationBudget,
population: *pop,
economic_role: "manufacturing".into(),
is_capital: false,
})
.collect();
+208 -2
View File
@@ -275,7 +275,8 @@ impl CityContextReader {
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, name, economic_role, population, settlement_class
"SELECT id, name, economic_role, population, settlement_class,
COALESCE(kind, 'city')
FROM atlas_city_names
WHERE body_id = ?1
ORDER BY id",
@@ -289,13 +290,14 @@ impl CityContextReader {
row.get::<_, Option<String>>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, String>(5)?,
))
})
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
let (id, name, role, population, sclass) =
let (id, name, role, population, sclass, kind) =
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let city_id = id as u64;
let settlement_class = match sclass.as_deref() {
@@ -315,6 +317,7 @@ impl CityContextReader {
settlement_class,
population,
economic_role: role.unwrap_or_else(|| "residential".to_string()),
is_capital: kind == "capital",
});
}
Ok(out)
@@ -349,6 +352,99 @@ impl CityContextReader {
Err(e) => Err(CityContextReadError::Db(e.to_string())),
}
}
/// D-236 Sol-exclusion gate: `true` if `body_id`'s system is Sol.
///
/// Sol (system `GJ-0`) is permanently out of the generation cascade — the
/// two signals D-236 names as equivalent gate flags are checked directly:
/// `bodies.system_id = 'GJ-0'` *or* the joined
/// `system_history.settlement_wave = 'origin'`. Either one alone is
/// sufficient (defence in depth; in practice they always agree — `'origin'`
/// is a one-off wave value only ever assigned to GJ-0).
///
/// An unknown `body_id` is **not** treated as Sol (`Ok(false)`) — that's a
/// distinct "no such body" outcome the caller's own not-found handling
/// covers (mirrors [`read_body_dominant_faction`](Self::read_body_dominant_faction)'s
/// convention). Only a DB/mutex error fails.
pub fn is_sol_body(&self, body_id: &str) -> Result<bool, CityContextReadError> {
let conn = self
.conn
.lock()
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
let result = conn.query_row(
"SELECT b.system_id, sh.settlement_wave
FROM bodies AS b
LEFT JOIN system_history AS sh ON sh.system_id = b.system_id
WHERE b.body_id = ?1",
[body_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
);
match result {
Ok((system_id, settlement_wave)) => {
Ok(system_id == "GJ-0" || settlement_wave.as_deref() == Some("origin"))
}
// Body not present → not (specifically) Sol; the caller's own
// not-found handling applies to the "unknown body" case.
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(CityContextReadError::Db(e.to_string())),
}
}
/// Read every authored settlement **name** on `body_id` from
/// `atlas_city_names` (T-949 — replaces the client's names-only
/// `markers.json` read for non-Sol bodies, D-223/D-236). Unlike
/// [`read_body_settlements`](Self::read_body_settlements) this returns only
/// the id/name/capital-flag triple — no economic/placement fields — and is
/// available immediately (it doesn't require the generation cascade to have
/// placed anything). Ordered by `id`. An unknown body yields an empty list
/// (matches `read_body_settlements`'s convention); only a DB/mutex error
/// fails.
pub fn read_body_city_names(
&self,
body_id: &str,
) -> Result<Vec<CityNameRow>, CityContextReadError> {
let conn = self
.conn
.lock()
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, name, COALESCE(kind, 'city')
FROM atlas_city_names
WHERE body_id = ?1
ORDER BY id",
)
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let rows = stmt
.query_map([body_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
let (id, name, kind) = r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
out.push(CityNameRow {
city_id: id as u64,
name,
is_capital: kind == "capital",
});
}
Ok(out)
}
}
/// One row of the T-949 names-only read (see
/// [`CityContextReader::read_body_city_names`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CityNameRow {
pub city_id: u64,
pub name: String,
pub is_capital: bool,
}
// ---------------------------------------------------------------------------
@@ -1057,4 +1153,114 @@ mod tests {
"unrecognized class defaults to PopulationBudget"
);
}
// ─── read_body_city_names (T-949) ────────────────────────────────────────
#[test]
fn read_body_city_names_returns_id_name_capital() {
let db = make_settlements_db(&[
("Capital", "financial", 2_000_000, Some("NameLocked")),
("Outpost", "extraction", 5_000, None),
]);
let conn = Connection::open(&db).expect("reopen");
conn.execute(
"UPDATE atlas_city_names SET kind = 'capital' WHERE name = 'Capital'",
[],
)
.expect("set capital");
drop(conn);
let reader = CityContextReader::open(&db).expect("open");
let names = reader.read_body_city_names("PlanetX").expect("read");
assert_eq!(names.len(), 2);
// Ordered by id == insertion order.
assert_eq!(names[0].name, "Capital");
assert!(names[0].is_capital);
assert_eq!(names[1].name, "Outpost");
assert!(
!names[1].is_capital,
"the default 'city' kind must not read as capital"
);
}
#[test]
fn read_body_city_names_unknown_body_is_empty() {
let db = make_settlements_db(&[("Solo", "residential", 10_000, None)]);
let reader = CityContextReader::open(&db).expect("open");
assert!(
reader
.read_body_city_names("Ghost")
.expect("read")
.is_empty(),
"unknown body yields no names, matching read_body_settlements' convention"
);
}
// ─── is_sol_body (T-949, D-236) ──────────────────────────────────────────
/// Minimal db for `is_sol_body`: one `bodies` row + an optional
/// `system_history` row carrying `settlement_wave`.
fn make_sol_db(body_id: &str, system_id: &str, settlement_wave: Option<&str>) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_ctxsol_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE bodies (body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL);
CREATE TABLE system_history (
system_id TEXT PRIMARY KEY,
settlement_wave TEXT
);",
)
.expect("create tables");
conn.execute(
"INSERT INTO bodies (body_id, system_id) VALUES (?1, ?2)",
rusqlite::params![body_id, system_id],
)
.expect("insert body");
if let Some(wave) = settlement_wave {
conn.execute(
"INSERT INTO system_history (system_id, settlement_wave) VALUES (?1, ?2)",
rusqlite::params![system_id, wave],
)
.expect("insert system_history");
}
drop(conn);
path
}
#[test]
fn is_sol_body_true_for_gj0_system_id() {
let db = make_sol_db("Earth", "GJ-0", None);
let reader = CityContextReader::open(&db).expect("open");
assert!(reader.is_sol_body("Earth").expect("query"));
}
#[test]
fn is_sol_body_true_for_origin_settlement_wave() {
// D-236 names `system_id = 'GJ-0'` and `settlement_wave = 'origin'` as
// equivalent gate signals — a body whose system carries the 'origin'
// wave (even under a hypothetically different system_id) must also be
// excluded, not just a literal "GJ-0" string match.
let db = make_sol_db("Weirdbody", "GJ-999", Some("origin"));
let reader = CityContextReader::open(&db).expect("open");
assert!(reader.is_sol_body("Weirdbody").expect("query"));
}
#[test]
fn is_sol_body_false_for_ordinary_body() {
let db = make_sol_db("GJ1c", "GJ-1", Some("first_wave"));
let reader = CityContextReader::open(&db).expect("open");
assert!(!reader.is_sol_body("GJ1c").expect("query"));
}
#[test]
fn is_sol_body_false_for_unknown_body() {
let db = make_sol_db("GJ1c", "GJ-1", None);
let reader = CityContextReader::open(&db).expect("open");
assert!(
!reader.is_sol_body("ghost").expect("query"),
"an unknown body is not (specifically) Sol-excluded"
);
}
}
+435 -5
View File
@@ -14,13 +14,15 @@
use serde::{Deserialize, Serialize};
use crate::atlas::body_params_reader::BodyParamsReader;
use crate::atlas::body_world_state::{BodyWorldStateCache, SimTick};
use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, SimTick};
use crate::atlas::cascade::CascadeLayer;
use crate::atlas::city_context_reader::CityContextReader;
use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer1::Layer1Output;
use crate::atlas::road_graph::RoadNodeKind;
use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError};
use crate::seed::SeedChain;
use crate::simulation::generator::{AttractorType, MaintenanceAuthority};
/// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk
/// (the loader prefers the chunk; this is only the floor).
@@ -29,9 +31,10 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3;
/// A client request for a body's generation layers (D-225).
///
/// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item`
/// currently runs the cascade through `CascadeLayer::Settlement` unconditionally,
/// ignoring this field. Wiring per-request depth (and the partial caching it
/// implies) is deferred to #1021.
/// (`gen_queue.rs`) currently runs the cascade through `CascadeLayer::RoadGraph`
/// (the terminal layer, T-1038) unconditionally on every request, ignoring this
/// field. Wiring per-request depth (and the partial caching it implies) is
/// deferred to #1021.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerRequest {
pub body_id: String,
@@ -67,7 +70,13 @@ pub struct DistrictGridLayer {
}
/// A layer response: the computed `Layer1Output` + the coarse district grid
/// (D-225, T-1046), or a non-ready status.
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2), or
/// a non-ready status.
///
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
/// pattern tops out around six fields — D-226's 2026-07-13 amendment (d)
/// rules out any L5/tile Atlas layer ever, leaving T-1112 (quarter
/// footprints) and T-1113 (region climate) as the only remaining candidates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerResponse {
pub body_id: String,
@@ -76,6 +85,15 @@ pub struct AtlasLayerResponse {
/// The coarse district/morphology grid for the Atlas overlay (T-1046).
/// `Some` on a cache hit once the DistrictProfile layer has run; `None` otherwise.
pub district_grid: Option<DistrictGridLayer>,
/// The inter-settlement road/rail graph overlay (T-960 §1, T-1038).
/// `Some` on a cache hit once the RoadGraph layer has run; `None` otherwise
/// (including a body with zero placed settlements — an empty graph has no
/// nodes to draw, so it collapses to `None` the same way `district_grid`
/// does for an unrun layer).
pub road_graph: Option<RoadGraphLayer>,
/// The settlement-placement overlay (T-960 §2, #955). `Some` on a cache hit
/// once the Settlement layer has placed at least one city; `None` otherwise.
pub settlements: Option<SettlementLayer>,
}
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
@@ -111,6 +129,173 @@ pub fn build_district_grid(
})
}
// ---------------------------------------------------------------------------
// RoadGraphLayer (T-960 §1, T-1038)
// ---------------------------------------------------------------------------
/// One node in the [`RoadGraphLayer`] overlay — a settlement junction or a
/// waypoint. Trimmed from the internal [`crate::atlas::road_graph::RoadNode`]:
/// `degree` and `parent_edge` are internal bookkeeping a planetary-map overlay
/// doesn't need (degree is trivially re-derivable client-side by counting
/// edges per node index if a renderer wants junction highlighting).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadGraphNode {
/// Position in working-heightmap-grid coordinates `(row, col)` — the same
/// space as `Layer1Output` attractors/rivers and `SettlementLayer` positions.
pub position: (u16, u16),
pub kind: RoadNodeKind,
/// The settlement's `city_id` (cross-references `SettlementLayer`), or
/// `None` for a waypoint.
pub city_id: Option<u64>,
}
/// One edge in the [`RoadGraphLayer`] overlay — a routed road or rail segment.
/// Trimmed from [`crate::atlas::road_graph::RoadEdge`]: `length_cells` is an
/// internal A* routing-grid measure with no meaning outside that grid's scale
/// (the polyline `path` is what an overlay actually draws).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadGraphEdge {
/// `nodes` indices of the endpoint settlements (`from < to`).
pub from: usize,
pub to: usize,
/// Routed polyline in working-heightmap-grid coordinates `(row, col)`.
pub path: Vec<(u16, u16)>,
pub maintenance: MaintenanceAuthority,
/// `true` if this edge is a railroad; `false` is a road.
pub is_rail: bool,
/// Joined `systems.db` named-route id, if any (empty pool today — D-223).
pub named_route_id: Option<String>,
}
/// The inter-settlement road/rail graph, trimmed for the Atlas planetary-map
/// overlay (T-960 §1, D-211, T-1038). See [`RoadGraphNode`]/[`RoadGraphEdge`]
/// for what was dropped from the internal `RoadGraph`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadGraphLayer {
pub nodes: Vec<RoadGraphNode>,
pub edges: Vec<RoadGraphEdge>,
}
/// Build the [`RoadGraphLayer`] from a body's cached state (T-960 §1).
/// Returns `None` when the RoadGraph layer has not run, which coincides
/// exactly with "no settlements placed" (`build_road_graph` returns an empty
/// graph for zero placements, and every placement yields at least one node).
pub fn build_road_graph_layer(state: &BodyWorldState) -> Option<RoadGraphLayer> {
if state.road_graph.nodes.is_empty() {
return None;
}
let nodes = state
.road_graph
.nodes
.iter()
.map(|n| RoadGraphNode {
position: n.position,
kind: n.kind,
city_id: n.city_id,
})
.collect();
let edges = state
.road_graph
.edges
.iter()
.map(|e| RoadGraphEdge {
from: e.from,
to: e.to,
path: e.path.clone(),
maintenance: e.maintenance,
is_rail: e.is_rail,
named_route_id: e.named_route_id.clone(),
})
.collect();
Some(RoadGraphLayer { nodes, edges })
}
// ---------------------------------------------------------------------------
// SettlementLayer (T-960 §2, #955)
// ---------------------------------------------------------------------------
/// Coarse settlement size class for the Atlas overlay (T-960 §2), derived from
/// raw population using the same Tier A/B population cutoffs the D-211
/// placement pipeline already uses (`attractor_matching::match_cities`):
/// Tier A (≥ 1,000,000 or `NameLocked`) settlements are `Major`, Tier B
/// (50,000999,999) are `Standard`, and everything else (Tier C / synthetic
/// overflow) is `Minor`. A display bucket, not new simulation truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettlementSizeClass {
Major,
Standard,
Minor,
}
impl SettlementSizeClass {
/// Bucket a raw population using the D-211 Tier A/B cutoffs.
pub fn from_population(population: i64) -> Self {
if population >= 1_000_000 {
SettlementSizeClass::Major
} else if population >= 50_000 {
SettlementSizeClass::Standard
} else {
SettlementSizeClass::Minor
}
}
}
/// One placed settlement in the [`SettlementLayer`] overlay (T-960 §2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettlementEntry {
pub city_id: u64,
pub name: String,
/// Position in working-heightmap-grid coordinates `(row, col)` — the same
/// space as `Layer1Output` attractors/rivers (T-960 §2: match the
/// coordinate convention layer1 features already use so the client
/// transforms identically).
pub position: (u16, u16),
pub size_class: SettlementSizeClass,
/// Authored `atlas_city_names.kind == 'capital'` (not population-derived).
pub is_capital: bool,
/// Cheap derived flag: `true` if the settlement's anchoring attractor is
/// water-adjacent (`CoastalAccess` / `RiverMouth` / `LakeShore`).
pub is_port: bool,
}
/// The settlement-placement overlay for one body (T-960 §2, #955, D-211).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettlementLayer {
pub settlements: Vec<SettlementEntry>,
}
/// `true` for the water-adjacent attractor types a settlement counts as a
/// "port" for cheaply (T-960 §2). No "foothold" flag: unlike `is_port`, there
/// is no existing concept in the placement data this could derive from
/// without inventing new business logic — left out (see the T-960 report).
fn is_port_attractor(at: AttractorType) -> bool {
matches!(
at,
AttractorType::CoastalAccess | AttractorType::RiverMouth | AttractorType::LakeShore
)
}
/// Build the [`SettlementLayer`] from a body's cached state (T-960 §2).
/// Returns `None` when the Settlement layer has not placed any city yet.
pub fn build_settlement_layer(state: &BodyWorldState) -> Option<SettlementLayer> {
if state.placements.is_empty() {
return None;
}
let settlements = state
.placements
.iter()
.map(|p| SettlementEntry {
city_id: p.city_id,
name: p.name.clone(),
position: p.position,
size_class: SettlementSizeClass::from_population(p.population),
is_capital: p.is_capital,
is_port: is_port_attractor(p.attractor_type),
})
.collect();
Some(SettlementLayer { settlements })
}
/// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit;
/// `world_seed` derives the body's `SeedChain` for the enqueued analysis.
///
@@ -154,11 +339,15 @@ pub fn handle_atlas_request(
district_basin_dirs: std::collections::BTreeMap::new(),
};
let district_grid = build_district_grid(state);
let road_graph = build_road_graph_layer(state);
let settlements = build_settlement_layer(state);
return AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Ready,
layer1: Some(layer1),
district_grid,
road_graph,
settlements,
};
}
@@ -229,6 +418,8 @@ pub fn handle_atlas_request(
status: AtlasLayerStatus::Pending,
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
}
}
// Unknown / no terrain → re-requesting won't help.
@@ -238,12 +429,16 @@ pub fn handle_atlas_request(
status: AtlasLayerStatus::NotFound,
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
},
Err(e) => AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Error(e.to_string()),
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
},
}
}
@@ -317,6 +512,241 @@ mod tests {
assert!(build_district_grid(&state).is_none());
}
/// A blank `BodyWorldState` for tests that only care about one field —
/// callers overwrite `placements`/`road_graph`/etc. as needed.
fn blank_state(body_id: &str) -> BodyWorldState {
BodyWorldState {
body_id: body_id.into(),
heightmap: vec![],
heightmap_width: 16,
heightmap_height: 8,
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
districts: std::collections::BTreeMap::new(),
last_accessed: 0,
}
}
/// T-960 §1: `build_road_graph_layer` trims the internal `RoadGraph` (drops
/// `degree`/`parent_edge`/`length_cells`) while keeping everything a
/// planetary-map overlay needs (positions, kind, polyline, maintenance,
/// rail flag, named-route id).
#[test]
fn road_graph_layer_built_from_cached_state() {
use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode};
use crate::simulation::generator::MaintenanceAuthority;
let mut state = blank_state("GJ1c");
state.road_graph = RoadGraph {
nodes: vec![
RoadNode {
city_id: Some(1),
position: (10, 20),
kind: RoadNodeKind::Settlement,
degree: 1,
parent_edge: None,
},
RoadNode {
city_id: None,
position: (15, 25),
kind: RoadNodeKind::Waypoint,
degree: 0,
parent_edge: Some(0),
},
],
edges: vec![RoadEdge {
from: 0,
to: 1,
path: vec![(10, 20), (15, 25)],
length_cells: 20, // internal routing-grid measure — dropped
maintenance: MaintenanceAuthority::Trade,
named_route_id: Some("split/hwy-1".into()),
is_rail: true,
}],
};
let layer = build_road_graph_layer(&state).expect("populated road_graph → Some");
assert_eq!(layer.nodes.len(), 2);
assert_eq!(layer.nodes[0].position, (10, 20));
assert_eq!(layer.nodes[0].kind, RoadNodeKind::Settlement);
assert_eq!(layer.nodes[0].city_id, Some(1));
assert_eq!(layer.nodes[1].kind, RoadNodeKind::Waypoint);
assert_eq!(layer.nodes[1].city_id, None);
assert_eq!(layer.edges.len(), 1);
assert_eq!(layer.edges[0].path, vec![(10, 20), (15, 25)]);
assert_eq!(layer.edges[0].maintenance, MaintenanceAuthority::Trade);
assert!(layer.edges[0].is_rail);
assert_eq!(
layer.edges[0].named_route_id.as_deref(),
Some("split/hwy-1")
);
// Layer hasn't run (or zero settlements) → None, mirroring district_grid.
let unrun = blank_state("GJ1c");
assert!(build_road_graph_layer(&unrun).is_none());
}
/// T-960 §2: `build_settlement_layer` derives `size_class` from population
/// using the D-211 Tier A/B cutoffs, threads `is_capital` straight through,
/// and derives `is_port` cheaply from the anchoring attractor type.
#[test]
fn settlement_layer_built_from_cached_placements() {
use crate::atlas::attractor_matching::CityPlacement;
use crate::simulation::generator::{
ArrangementPattern, FoundingOrientation, PoliticalArchetype,
};
let mk = |city_id: u64,
name: &str,
pos: (u16, u16),
population: i64,
is_capital: bool,
attractor_type: AttractorType| CityPlacement {
city_id,
name: name.to_string(),
position: pos,
attractor_type,
score: 1000,
synthetic: false,
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: FoundingOrientation::Cardinal,
population,
is_capital,
};
let mut state = blank_state("GJ1c");
state.placements = vec![
mk(
1,
"Port Aldren",
(12, 58),
2_000_000,
true,
AttractorType::CoastalAccess,
),
mk(
2,
"Millbrook",
(30, 40),
200_000,
false,
AttractorType::ValleyFloor,
),
mk(
3,
"Farmstead Rell",
(50, 60),
8_000,
false,
AttractorType::PlainCenter,
),
];
let layer = build_settlement_layer(&state).expect("populated placements → Some");
assert_eq!(layer.settlements.len(), 3);
let capital = layer.settlements.iter().find(|s| s.city_id == 1).unwrap();
assert_eq!(capital.name, "Port Aldren");
assert_eq!(capital.position, (12, 58));
assert_eq!(capital.size_class, SettlementSizeClass::Major);
assert!(capital.is_capital);
assert!(capital.is_port, "CoastalAccess must read as a port");
let mid = layer.settlements.iter().find(|s| s.city_id == 2).unwrap();
assert_eq!(mid.size_class, SettlementSizeClass::Standard);
assert!(!mid.is_capital);
assert!(!mid.is_port, "ValleyFloor is not a port attractor");
let small = layer.settlements.iter().find(|s| s.city_id == 3).unwrap();
assert_eq!(small.size_class, SettlementSizeClass::Minor);
assert!(!small.is_port);
// No placements → None.
let unrun = blank_state("GJ1c");
assert!(build_settlement_layer(&unrun).is_none());
}
/// T-960: the new layers survive a MessagePack round trip inside
/// `AtlasLayerResponse` — the same wire path the bridge uses
/// (`rmp_serde::to_vec_named` / `from_slice`, matching `layer1`/
/// `district_grid`'s existing serialization).
#[test]
fn atlas_layer_response_with_new_layers_round_trips_msgpack() {
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode};
use crate::simulation::generator::{
ArrangementPattern, FoundingOrientation, MaintenanceAuthority, PoliticalArchetype,
};
let mut state = blank_state("GJ1c");
state.placements = vec![CityPlacement {
city_id: 1,
name: "Port Aldren".into(),
position: (12, 58),
attractor_type: AttractorType::CoastalAccess,
score: 1000,
synthetic: false,
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: FoundingOrientation::Cardinal,
population: 2_000_000,
is_capital: true,
}];
state.road_graph = RoadGraph {
nodes: vec![RoadNode {
city_id: Some(1),
position: (12, 58),
kind: RoadNodeKind::Settlement,
degree: 0,
parent_edge: None,
}],
edges: vec![RoadEdge {
from: 0,
to: 0,
path: vec![(12, 58)],
length_cells: 0,
maintenance: MaintenanceAuthority::Administrative,
named_route_id: None,
is_rail: false,
}],
};
let resp = AtlasLayerResponse {
body_id: "GJ1c".into(),
status: AtlasLayerStatus::Ready,
layer1: None,
district_grid: None,
road_graph: build_road_graph_layer(&state),
settlements: build_settlement_layer(&state),
};
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
let decoded: AtlasLayerResponse = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(decoded.body_id, "GJ1c");
let rg = decoded.road_graph.expect("road_graph survives round trip");
assert_eq!(rg.nodes[0].position, (12, 58));
assert_eq!(
rg.edges[0].maintenance,
MaintenanceAuthority::Administrative
);
let settlements = decoded
.settlements
.expect("settlements survives round trip");
assert_eq!(settlements.settlements[0].name, "Port Aldren");
assert_eq!(
settlements.settlements[0].size_class,
SettlementSizeClass::Major
);
assert!(settlements.settlements[0].is_capital);
assert!(settlements.settlements[0].is_port);
}
fn req(body_id: &str) -> AtlasLayerRequest {
AtlasLayerRequest {
body_id: body_id.to_string(),
+1
View File
@@ -3,6 +3,7 @@
//! These loaders are used by the runtime-background tier (D-200, D-206) when
//! populating BodyWorldState (D-203). They are never called on the main tick thread.
pub mod atlas_data_proxy;
pub mod attractor_matching;
pub mod believability;
pub mod block_irregularity;
+150 -2
View File
@@ -13,6 +13,9 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
use std::collections::BTreeMap;
use crate::atlas::atlas_data_proxy::{
handle_city_names_request, handle_star_map_request, StarMapDataPath,
};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::body_params_reader::BodyParamsReaderResource;
use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
@@ -37,7 +40,10 @@ use crate::atlas::trait_draw::{
use crate::atlas::trait_swerve::{
build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools,
};
use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer};
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer,
StarMapRequestBuffer, StarMapResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier,
@@ -57,7 +63,12 @@ impl Plugin for GenerationPlugin {
Update,
drain_generation_completions.in_set(TickPhase::PreInput),
)
.add_systems(Update, serve_atlas_requests.in_set(TickPhase::PreInput));
.add_systems(Update, serve_atlas_requests.in_set(TickPhase::PreInput))
.add_systems(Update, serve_star_map_requests.in_set(TickPhase::PreInput))
.add_systems(
Update,
serve_city_names_requests.in_set(TickPhase::PreInput),
);
}
}
@@ -100,12 +111,58 @@ fn serve_atlas_requests(
status: AtlasLayerStatus::Error("no body source resolver".to_string()),
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
},
};
responses.0.push(resp);
}
}
/// Drain inbound star-map requests and serve each through the proxy (T-949a).
/// A thin read-the-file-fresh proxy — see `atlas_data_proxy` module doc for
/// why there's no caching. Absent `StarMapDataPath` (not wired at startup,
/// e.g. unit tests) reports an error per request rather than panicking.
fn serve_star_map_requests(
mut requests: ResMut<StarMapRequestBuffer>,
mut responses: ResMut<StarMapResponseBuffer>,
path: Option<Res<StarMapDataPath>>,
) {
if requests.0.is_empty() {
return;
}
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
let resp = match path.as_ref() {
Some(p) => handle_star_map_request(&req, &p.0),
None => crate::atlas::atlas_data_proxy::StarMapResponse {
status: crate::atlas::atlas_data_proxy::StarMapStatus::Error(
"star map data path unavailable".to_string(),
),
data: None,
},
};
responses.0.push(resp);
}
}
/// Drain inbound city-names requests and serve each through the proxy
/// (T-949b): D-236 Sol check, then the names-only `atlas_city_names` read.
fn serve_city_names_requests(
mut requests: ResMut<CityNamesRequestBuffer>,
mut responses: ResMut<CityNamesResponseBuffer>,
city_reader: Option<Res<CityContextReaderResource>>,
) {
if requests.0.is_empty() {
return;
}
let reader = city_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
responses.0.push(handle_city_names_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
@@ -798,6 +855,85 @@ mod tests {
assert!(world.resource::<AtlasRequestBuffer>().0.is_empty());
}
/// T-949a: the star-map serve system reads the wired `StarMapDataPath`
/// through to a `Ready` response end-to-end.
#[test]
fn serve_star_map_drains_requests_into_responses() {
use crate::atlas::atlas_data_proxy::{StarMapDataPath, StarMapRequest, StarMapStatus};
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("sr_plugin_starmap_{}_{n}.json", std::process::id()));
std::fs::write(&path, r#"{"_meta": {}, "nodes": [], "edges": []}"#).unwrap();
let mut world = World::new();
world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapResponseBuffer::default());
world.insert_resource(StarMapDataPath(path.clone()));
let mut sched = Schedule::default();
sched.add_systems(serve_star_map_requests);
sched.run(&mut world);
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].status, StarMapStatus::Ready);
assert!(world.resource::<StarMapRequestBuffer>().0.is_empty());
let _ = std::fs::remove_file(&path);
}
/// Without `StarMapDataPath` wired (e.g. a stripped-down test world), the
/// serve system reports `Error` per request rather than panicking.
#[test]
fn serve_star_map_without_path_resource_is_error() {
use crate::atlas::atlas_data_proxy::{StarMapRequest, StarMapStatus};
let mut world = World::new();
world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapResponseBuffer::default());
// No StarMapDataPath resource.
let mut sched = Schedule::default();
sched.add_systems(serve_star_map_requests);
sched.run(&mut world);
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert!(matches!(responses.0[0].status, StarMapStatus::Error(_)));
}
/// T-949b: without `CityContextReaderResource` wired, the serve system
/// reports `Error` per request (mirrors the atlas-request "no resolver"
/// convention) rather than panicking.
#[test]
fn serve_city_names_without_reader_is_error() {
use crate::atlas::atlas_data_proxy::{CityNamesRequest, CityNamesStatus};
let mut world = World::new();
world.insert_resource(CityNamesRequestBuffer(vec![CityNamesRequest {
city_names: true,
body_id: "GJ1c".to_string(),
}]));
world.insert_resource(CityNamesResponseBuffer::default());
// No CityContextReaderResource.
let mut sched = Schedule::default();
sched.add_systems(serve_city_names_requests);
sched.run(&mut world);
let responses = world.resource::<CityNamesResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].body_id, "GJ1c");
assert!(matches!(responses.0[0].status, CityNamesStatus::Error(_)));
assert!(world.resource::<CityNamesRequestBuffer>().0.is_empty());
}
fn sample_read_set() -> CityEconomicReadSet {
use crate::simulation::generator::SettlementClass;
CityEconomicReadSet {
@@ -838,6 +974,7 @@ mod tests {
fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement {
CityPlacement {
city_id,
name: format!("City{city_id}"),
position: (10, 20),
attractor_type: AttractorType::CoastalAccess,
score: 100,
@@ -845,6 +982,8 @@ mod tests {
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: orientation,
population: 100_000,
is_capital: false,
}
}
@@ -856,6 +995,7 @@ mod tests {
) -> CityPlacement {
CityPlacement {
city_id,
name: format!("City{city_id}"),
position: (10, 20),
attractor_type: AttractorType::CoastalAccess,
score: 100,
@@ -863,6 +1003,8 @@ mod tests {
political_archetype: archetype,
arrangement_pattern: arrangement,
founding_orientation: orientation,
population: 100_000,
is_capital: false,
}
}
@@ -1308,6 +1450,7 @@ mod tests {
// what attractor_matching::match_cities would have stored at L3.
let l3_placement = CityPlacement {
city_id: 1,
name: "City1".into(),
position: (0, 0),
attractor_type: AttractorType::PlainCenter,
score: 100,
@@ -1315,6 +1458,8 @@ mod tests {
political_archetype: *archetype,
arrangement_pattern: *expected_pattern,
founding_orientation: FoundingOrientation::Cardinal,
population: 100_000,
is_capital: false,
};
assert_eq!(
@@ -1633,6 +1778,7 @@ mod tests {
let placement = CityPlacement {
city_id: 5,
name: "City5".into(),
position: city_pos,
attractor_type: AttractorType::CoastalAccess,
score: 100,
@@ -1640,6 +1786,8 @@ mod tests {
political_archetype: PoliticalArchetype::Commission,
arrangement_pattern: ArrangementPattern::RadialCore,
founding_orientation: FoundingOrientation::Cardinal,
population: 100_000,
is_capital: false,
};
let GenWorkItem::GenerateSkeleton {
+3
View File
@@ -739,6 +739,7 @@ mod tests {
fn placement(city_id: u64, pos: (u16, u16), archetype: PoliticalArchetype) -> CityPlacement {
CityPlacement {
city_id,
name: format!("City{city_id}"),
position: pos,
attractor_type: AttractorType::PlainCenter,
score: 1000,
@@ -746,6 +747,8 @@ mod tests {
political_archetype: archetype,
arrangement_pattern: ArrangementPattern::RibbonDevelopment,
founding_orientation: FoundingOrientation::Cardinal,
population: 100_000,
is_capital: false,
}
}
+21
View File
@@ -3,6 +3,7 @@
// Deterministic client-server communication via Unix domain sockets
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::bridge::framing::{read_framed, write_framed};
use std::fs;
@@ -145,6 +146,26 @@ impl SimBridge for LocalBridge {
write_framed(writer.get_mut(), &payload)?;
Ok(())
}
fn send_star_map_response(&self, resp: &StarMapResponse) -> 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(())
}
fn send_city_names_response(&self, resp: &CityNamesResponse) -> 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 {
+304 -10
View File
@@ -6,6 +6,9 @@ use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::atlas::atlas_data_proxy::{
CityNamesRequest, CityNamesResponse, StarMapRequest, StarMapResponse,
};
use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse};
pub mod debug;
@@ -36,30 +39,111 @@ pub enum BridgeError {
}
/// One decoded inbound message. The client→server stream is a single demuxed
/// channel (D-225): a `Vec<PlayerInput>` frame is a MessagePack *array* and an
/// `AtlasLayerRequest` frame is a *map*, so they are distinguishable without a
/// wire-level type tag (existing frames are byte-unchanged — additive).
/// channel (D-225): a `Vec<PlayerInput>` frame is a MessagePack *array* and
/// every request type below is a *map*, so array vs. map alone separates
/// inputs from everything else without a wire-level type tag (existing frames
/// are byte-unchanged — additive).
///
/// **Disambiguating the three map shapes (D-225 extension, T-949):**
/// `AtlasLayerRequest{body_id, up_to}` was the only map shape until T-949
/// added `StarMapRequest`/`CityNamesRequest` alongside it. serde's derived
/// `Deserialize` silently ignores unknown fields by default, so "does this
/// struct parse at all" is not a safe discriminator once more than one map
/// shape can share a field name (`CityNamesRequest` and `AtlasLayerRequest`
/// both key on `body_id`) — a payload carrying every field either shape wants
/// would ambiguously satisfy both. Rather than retrofit
/// `#[serde(deny_unknown_fields)]` onto the existing `AtlasLayerRequest` (risking
/// breakage if any already-deployed client encoder harmlessly sends extra
/// fields), the two *new* map shapes each carry a mandatory boolean
/// discriminator field the others don't have at all (`star_map` /
/// `city_names`): a missing required field is a hard deserialize failure, not
/// a silent ignore, so no *minimal well-formed* instance of one shape
/// satisfies another — and [`decode_inbound`] additionally REJECTS union
/// 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.
#[derive(Debug)]
pub enum Inbound {
/// A batch of player inputs (the gameplay path).
Inputs(Vec<PlayerInput>),
/// An atlas layer-stream request (#969, D-225).
AtlasRequest(AtlasLayerRequest),
/// A star-map dataset request (T-949a).
StarMapRequest(StarMapRequest),
/// A per-body city-names request (T-949b).
CityNamesRequest(CityNamesRequest),
}
/// Demux a received frame payload into an [`Inbound`] (D-225). Tries
/// `Vec<PlayerInput>` (array), then `AtlasLayerRequest` (map); a frame that is
/// neither is a genuinely malformed input frame.
/// Key-presence probe for the defensive multi-shape check in
/// [`decode_inbound`]: `Option<IgnoredAny>` records whether a key exists
/// without caring about its value or type, so a union frame is detected even
/// when the individual values wouldn't parse as their target types.
#[derive(serde::Deserialize)]
struct ShapeProbe {
body_id: Option<serde::de::IgnoredAny>,
up_to: Option<serde::de::IgnoredAny>,
star_map: Option<serde::de::IgnoredAny>,
city_names: Option<serde::de::IgnoredAny>,
}
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries,
/// in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
/// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) →
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`).
///
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
/// defensive pre-check rejects any map frame carrying the discriminators of
/// 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.
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));
}
match rmp_serde::from_slice::<AtlasLayerRequest>(payload) {
Ok(req) => Ok(Inbound::AtlasRequest(req)),
// Defensive multi-shape rejection: serde ignores unknown fields, so a
// union frame would otherwise route silently by try-order. Unreachable
// from the shipped client encoders (each sends one minimal shape) — this
// guards buggy or adversarial frames.
if let Ok(probe) = rmp_serde::from_slice::<ShapeProbe>(payload) {
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);
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?}",
shapes,
atlas,
star_map,
city_names,
dump_len,
payload.len(),
&payload[..dump_len]
);
return Err(BridgeError::DeserializationWithDump(format!(
"ambiguous inbound frame matches {shapes} request shapes (payload {} bytes)",
payload.len()
)));
}
}
if let Ok(req) = rmp_serde::from_slice::<AtlasLayerRequest>(payload) {
return Ok(Inbound::AtlasRequest(req));
}
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)),
Err(e) => {
let dump_len = payload.len().min(256);
tracing::error!(
"inbound decode failed (neither inputs nor atlas request): {}. Raw ({} of {} bytes): {:02x?}",
"inbound decode failed (matches no known frame shape): {}. Raw ({} of {} bytes): {:02x?}",
e,
dump_len,
payload.len(),
@@ -98,6 +182,12 @@ pub trait SimBridge: Send + Sync {
/// Send an atlas layer-stream response to the client (#969, D-225).
fn send_atlas_response(&self, resp: &AtlasLayerResponse) -> Result<(), BridgeError>;
/// Send a star-map response to the client (T-949a).
fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError>;
/// Send a city-names response to the client (T-949b).
fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError>;
}
/// BridgeResource: Bevy Resource wrapper for SimBridge trait object
@@ -132,6 +222,14 @@ impl BridgeResource {
pub fn send_atlas_response(&self, resp: &AtlasLayerResponse) -> Result<(), BridgeError> {
self.inner.send_atlas_response(resp)
}
pub fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError> {
self.inner.send_star_map_response(resp)
}
pub fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError> {
self.inner.send_city_names_response(resp)
}
}
/// Tracks whether the protocol handshake has been sent (#555).
@@ -165,6 +263,8 @@ pub fn receive_bridge_inputs(
handshake: Res<HandshakeState>,
mut error_buffer: ResMut<SimErrorBuffer>,
mut atlas_requests: ResMut<AtlasRequestBuffer>,
mut star_map_requests: ResMut<StarMapRequestBuffer>,
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(bridge) = bridge else { return };
@@ -193,6 +293,12 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::AtlasRequest(req))) => {
atlas_requests.0.push(req);
}
Ok(Some(Inbound::StarMapRequest(req))) => {
star_map_requests.0.push(req);
}
Ok(Some(Inbound::CityNamesRequest(req))) => {
city_names_requests.0.push(req);
}
// No complete frame ready — the backlog is drained.
Ok(None) => break,
Err(BridgeError::Disconnected) => {
@@ -308,12 +414,65 @@ pub fn send_atlas_responses(
}
}
/// Inbound star-map requests routed off the bridge (T-949a), drained by the
/// proxy serve system in `PreInput`.
#[derive(Resource, Default)]
pub struct StarMapRequestBuffer(pub Vec<StarMapRequest>);
/// Outbound star-map responses, filled by the proxy serve system and flushed
/// to the client in `PostSnapshot` (T-949a).
#[derive(Resource, Default)]
pub struct StarMapResponseBuffer(pub Vec<StarMapResponse>);
/// Flush buffered star-map responses to the client (T-949a). A failed send is
/// logged but not fatal.
pub fn send_star_map_responses(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<StarMapResponseBuffer>,
) {
let Some(bridge) = bridge else { return };
for resp in buffer.0.drain(..) {
if let Err(e) = bridge.send_star_map_response(&resp) {
tracing::warn!("failed to send star map response: {}", e);
}
}
}
/// Inbound city-names requests routed off the bridge (T-949b), drained by the
/// proxy serve system in `PreInput`.
#[derive(Resource, Default)]
pub struct CityNamesRequestBuffer(pub Vec<CityNamesRequest>);
/// Outbound city-names responses, filled by the proxy serve system and
/// flushed to the client in `PostSnapshot` (T-949b).
#[derive(Resource, Default)]
pub struct CityNamesResponseBuffer(pub Vec<CityNamesResponse>);
/// Flush buffered city-names responses to the client (T-949b). A failed send
/// is logged but not fatal.
pub fn send_city_names_responses(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<CityNamesResponseBuffer>,
) {
let Some(bridge) = bridge else { return };
for resp in buffer.0.drain(..) {
if let Err(e) = bridge.send_city_names_response(&resp) {
tracing::warn!(
"failed to send city names response for {}: {}",
resp.body_id,
e
);
}
}
}
/// Bridge plugin for client-server communication
/// Abstracts transport layer (LocalBridge/NetworkBridge)
pub struct BridgePlugin;
impl Plugin for BridgePlugin {
fn build(&self, app: &mut App) {
use crate::simulation::time::sim_not_paused;
use crate::tick_phases::TickPhase;
app.init_resource::<SnapshotBuffer>()
@@ -325,10 +484,22 @@ impl Plugin for BridgePlugin {
.init_resource::<crate::perception::query::ActivePerceptionMode>()
.init_resource::<AtlasRequestBuffer>()
.init_resource::<AtlasResponseBuffer>()
.init_resource::<StarMapRequestBuffer>()
.init_resource::<StarMapResponseBuffer>()
.init_resource::<CityNamesRequestBuffer>()
.init_resource::<CityNamesResponseBuffer>()
// Bridge I/O — PreInput (receive) and PostSnapshot (send)
.add_systems(Update, receive_bridge_inputs.in_set(TickPhase::PreInput))
.add_systems(Update, send_bridge_snapshot.in_set(TickPhase::PostSnapshot))
.add_systems(Update, send_atlas_responses.in_set(TickPhase::PostSnapshot))
.add_systems(
Update,
send_star_map_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_city_names_responses.in_set(TickPhase::PostSnapshot),
)
// Debug commands — Snapshot phase
.add_systems(
Update,
@@ -336,6 +507,13 @@ impl Plugin for BridgePlugin {
)
// Monologue chain — Simulation phase, strict intra-phase sequence.
// trigger_event_monologue must run after conversations + sound (also Simulation).
// T-970: TickPhase::Simulation is not set-gated (see
// social_plugin.rs's collect_sound_events exemption) — this whole
// chain is genuine world-advancing dialogue/monologue logic, so
// it gates safely on its own. trigger_event_monologue's
// .after(collect_sound_events) still holds while gated:
// collect_sound_events itself is never gated, and an ordering
// edge onto a skipped predecessor is trivially satisfied.
.add_systems(
Update,
(
@@ -352,15 +530,22 @@ impl Plugin for BridgePlugin {
crate::simulation::monologue::process_contradiction_monologue
.after(crate::simulation::monologue::trigger_event_monologue),
)
.run_if(sim_not_paused)
.in_set(TickPhase::Simulation),
)
// Observation systems — Simulation phase (reads positions, feeds snapshot)
// Observation systems — Simulation phase (reads positions, feeds snapshot).
// T-970: gates safely on its own (see note above) — these compute
// "current state" (visibility, nearby interactions) that's valid
// as long as nothing moved, which holds while paused since
// Movement is frozen too; unlike SoundEventQueue, nothing here
// depends on being refreshed on a tick where nothing changed.
.add_systems(
Update,
(
crate::perception::observer::compute_visibility_geometry,
crate::simulation::interaction::compute_nearby_interactions,
)
.run_if(sim_not_paused)
.in_set(TickPhase::Simulation),
)
// Observer snapshot assembly — Snapshot phase
@@ -404,4 +589,113 @@ mod inbound_tests {
// Neither shape → a malformed-frame error.
assert!(decode_inbound(&[0xff, 0xff]).is_err());
}
#[test]
fn demux_routes_star_map_requests() {
let req = StarMapRequest { star_map: true };
let frame = rmp_serde::to_vec_named(&req).unwrap();
assert!(matches!(
decode_inbound(&frame),
Ok(Inbound::StarMapRequest(r)) if r.star_map
));
}
#[test]
fn demux_routes_city_names_requests() {
let req = CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
};
let frame = rmp_serde::to_vec_named(&req).unwrap();
assert!(matches!(
decode_inbound(&frame),
Ok(Inbound::CityNamesRequest(r)) if r.body_id == "GJ1c"
));
}
/// T-949: the array-vs-map trick (D-225) still separates `Inputs` from
/// everything else, and the three map shapes' discriminator fields keep
/// them mutually exclusive — each of the four frame shapes decodes to
/// exactly its own `Inbound` variant, never a neighbor's.
#[test]
fn inbound_disambiguation_is_unambiguous_across_all_four_shapes() {
let inputs_frame = rmp_serde::to_vec_named(&Vec::<PlayerInput>::new()).unwrap();
let atlas_frame = rmp_serde::to_vec_named(&AtlasLayerRequest {
body_id: "GJ1c".into(),
up_to: CascadeLayer::Topography,
})
.unwrap();
let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap();
let city_names_frame = rmp_serde::to_vec_named(&CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(matches!(
decode_inbound(&inputs_frame),
Ok(Inbound::Inputs(_))
));
assert!(matches!(
decode_inbound(&atlas_frame),
Ok(Inbound::AtlasRequest(_))
));
assert!(matches!(
decode_inbound(&star_map_frame),
Ok(Inbound::StarMapRequest(_))
));
assert!(matches!(
decode_inbound(&city_names_frame),
Ok(Inbound::CityNamesRequest(_))
));
// Cross-check: an AtlasLayerRequest frame must NOT decode as
// CityNamesRequest even though both key on `body_id` — the missing
// `city_names` discriminator makes that a hard failure, not a silent
// "extra field ignored" success either shape could show without it.
assert!(rmp_serde::from_slice::<CityNamesRequest>(&atlas_frame).is_err());
// And a CityNamesRequest frame must NOT decode as AtlasLayerRequest —
// it's missing the required `up_to` field.
assert!(rmp_serde::from_slice::<AtlasLayerRequest>(&city_names_frame).is_err());
}
/// PR #176 review H1: a union frame carrying more than one shape's
/// discriminators must be REJECTED, not silently routed to whichever
/// shape `decode_inbound` happens to try first.
#[test]
fn ambiguous_union_frame_is_rejected() {
#[derive(serde::Serialize)]
struct StarAndCity {
star_map: bool,
city_names: bool,
body_id: String,
}
let frame = rmp_serde::to_vec_named(&StarAndCity {
star_map: true,
city_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"star_map+city_names union frame must be rejected"
);
#[derive(serde::Serialize)]
struct AtlasAndStar {
body_id: String,
up_to: CascadeLayer,
star_map: bool,
}
let frame = rmp_serde::to_vec_named(&AtlasAndStar {
body_id: "GJ1c".into(),
up_to: CascadeLayer::Topography,
star_map: true,
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"atlas+star_map union frame must be rejected"
);
}
}
+29
View File
@@ -4,6 +4,7 @@
// Used for Godot client which lacks Unix socket support
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::bridge::framing::{read_framed, write_framed, FrameAccumulator};
use std::io::BufWriter;
@@ -255,4 +256,32 @@ impl SimBridge for TcpBridge {
result?;
Ok(())
}
fn send_star_map_response(&self, resp: &StarMapResponse) -> 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(())
}
fn send_city_names_response(&self, resp: &CityNamesResponse) -> 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(())
}
}
+11
View File
@@ -520,6 +520,17 @@ pub enum PlayerAction {
UsePerceptionMode(String),
Pause,
Unpause,
/// Auto-pause the sim when a fullscreen implant app occludes gameplay
/// (T-970, D-226 layer 1: `HudGroups.gameplay_occluded`, D-170). Distinct
/// from the manual `Pause` (Space bar, D-088) so the server can tell
/// whether IT caused the current pause via `AutoPauseState` — a
/// pre-existing manual pause or Half rate survives an implant
/// open/close cycle untouched. No-op unless the sim is currently
/// `TickRate::Full` (see `simulation::time::AutoPauseState`).
AutoPause,
/// Auto-resume when the fullscreen implant app closes (T-970, D-226
/// layer 1). No-op unless `AutoPause` is what caused the current pause.
AutoResume,
/// Player walked away during active dialogue (WASD during conversation, D-064).
/// Client sends this when movement input is detected while dialogue box is visible.
/// Server records incomplete interaction in KG and clears dialogue state.
+16
View File
@@ -199,6 +199,22 @@ fn main() {
),
}
// Star-map dataset proxy (T-949a): resolve the repo-root-relative path to
// the client's pre-generated star_map_data.json (tooling/generate-star-map-data.py).
// Read fresh on every request — no caching, see atlas_data_proxy module doc.
let star_map_data_path = world_root.join("client/data/star_map_data.json");
if star_map_data_path.exists() {
tracing::info!("Star map data path resolved: {:?}", star_map_data_path);
} else {
tracing::warn!(
"star_map_data.json not found at {:?}. Star map requests will error until it exists.",
star_map_data_path
);
}
app.insert_resource(
settled_reach_server::atlas::atlas_data_proxy::StarMapDataPath(star_map_data_path),
);
// Settlement reader for Layer-3 placement (#955): reads a body's settlements
// from systems.db on a cache miss so the cascade work item stays DB-free.
match settled_reach_server::atlas::city_context_reader::CityContextReader::open(
+6 -1
View File
@@ -24,7 +24,7 @@ use std::collections::BTreeMap;
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
use crate::simulation::movement::TilePosition;
use crate::simulation::time::DayPhase;
use crate::simulation::time::{sim_not_paused, DayPhase};
/// NPC plugin: initializes NPC-related resources and systems.
pub struct NpcPlugin;
@@ -80,6 +80,11 @@ impl Plugin for NpcPlugin {
disclosure::process_unprompted_disclosure
.after(disclosure::derive_disclosure_candidates),
)
// T-970: TickPhase::Simulation is not set-gated (see
// social_plugin.rs's collect_sound_events exemption) —
// every system here is genuine NPC-behavior world
// advancement, so this whole tuple gates safely.
.run_if(sim_not_paused)
.in_set(TickPhase::Simulation),
)
// Storyteller: tell state derivation (reads mood + routine deviation)
+7 -1
View File
@@ -20,18 +20,24 @@ pub struct PerceptionPlugin;
impl Plugin for PerceptionPlugin {
fn build(&self, app: &mut App) {
use crate::simulation::time::sim_not_paused;
use crate::tick_phases::TickPhase;
app.init_resource::<interpretation::ObservationEventQueue>()
.init_resource::<query::VisibilityGeometry>()
.init_resource::<query::ActivePerceptionMode>()
// Simulation: anomaly detection (feeds monologue recognition chain)
// Simulation: anomaly detection (feeds monologue recognition chain).
// T-970: TickPhase::Simulation is not set-gated (see
// social_plugin.rs's collect_sound_events exemption) — anomaly
// detection is genuine world-advancing analysis, so this tuple
// gates safely on its own.
.add_systems(
Update,
(
anomaly::clear_anomaly_markers,
anomaly::detect_anomalies.after(anomaly::clear_anomaly_markers),
)
.run_if(sim_not_paused)
.in_set(TickPhase::Simulation),
)
// Knowledge: cognitive delay + observation interpretation
+109
View File
@@ -408,6 +408,10 @@ pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateReso
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::types::SnapshotBuffer;
use crate::simulation::time::{sim_not_paused, TickRate};
use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule};
use bevy_ecs::world::World;
use econ_sim::db::{Commodity, Economy, SystemInfo};
const SYS: &str = "sys-test";
@@ -647,4 +651,109 @@ mod tests {
.signals
.contains_key(&("ghost-system".to_string(), COM.to_string())));
}
// ── T-970 (D-226 layer 1): pause-gating split ────────────────────────────
// tick_economy_simulation is individually `run_if`-gated (economy_plugin.rs);
// serve_econ_state_query stays unconditioned so the paused-allowed
// EconStateQuery (input.rs) keeps being served while the sim is frozen.
// This mirrors the exact production system composition, not a stand-in.
// Sentinel econ_tick a real `Simulation::step()` can never produce: a
// fresh `Simulation::from_economy` starts its internal tick counter at 0
// and `step()` increments it to 1 — so 99 can only be observed here
// because we seeded it by hand, never as a coincidental real result.
// This makes "unchanged" and "changed" assertions unambiguous instead of
// accidentally matching the real post-step value (see PR review: a first
// draft of this test seeded econ_tick=1, which a real step also produces,
// so a "stays unchanged" assertion couldn't have caught the gate failing
// open).
const STALE_ECON_TICK: u64 = 99;
#[test]
fn tick_economy_simulation_skips_while_paused_but_query_still_served() {
let (mut econ, mut state) = test_resources();
// Seed a stale econ tick + real signals so a query has something to
// serve, and so a gate failure (system runs anyway) is observable.
set_state_and_rebuild(&mut econ, &mut state, STALE_ECON_TICK, 10.0, 1.0, 1.0, 1.0);
let mut world = World::new();
// NOTE: SimulationTime has a private field (`accumulated`, time.rs) —
// struct-literal update syntax (`{ tick_rate: ..., ..Default::default() }`)
// only compiles from within `time` itself or its descendants. From this
// sibling module, construct via Default::default() then assign the pub
// `tick_rate` field directly (matches the existing convention in
// simulation::input's pause-guard tests).
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.insert_resource(econ);
world.insert_resource(state);
world.insert_resource(EconQueryBuffer {
pending: Some(SYS.to_string()),
});
world.init_resource::<SnapshotBuffer>();
// Mirrors the exact production registration in economy_plugin.rs.
let mut schedule = Schedule::default();
schedule.add_systems((
tick_economy_simulation.run_if(sim_not_paused),
serve_econ_state_query.after(tick_economy_simulation),
));
schedule.run(&mut world);
assert_eq!(
world.resource::<EconStateResource>().econ_tick,
STALE_ECON_TICK,
"tick_economy_simulation must be skipped while paused — econ_tick unchanged"
);
assert!(
world.resource::<EconQueryBuffer>().pending.is_none(),
"serve_econ_state_query must still drain the pending query while paused"
);
let response = world
.resource::<SnapshotBuffer>()
.pending_economy_response
.as_ref()
.expect("serve_econ_state_query must still serve a response while paused");
assert_eq!(response.system_id, SYS);
// The served response must carry the STALE data (serve_econ_state_query
// reads whatever EconStateResource currently holds — it does not itself
// recompute anything) — confirms it served the frozen state, not some
// other fresh value.
assert_eq!(response.econ_tick, STALE_ECON_TICK);
}
#[test]
fn tick_economy_simulation_runs_normally_when_not_paused() {
// Control case: same stale-seed setup, but Full rate —
// tick_economy_simulation must actually run and overwrite the stale
// econ_tick with the real post-step value (1, from a fresh
// Simulation). Proves the gate — not some other no-op path — is what
// skips it in the test above.
let (mut econ, mut state) = test_resources();
set_state_and_rebuild(&mut econ, &mut state, STALE_ECON_TICK, 10.0, 1.0, 1.0, 1.0);
let mut world = World::new();
world.insert_resource(SimulationTime::default()); // Full, tick=0
world.insert_resource(econ);
world.insert_resource(state);
world.insert_resource(EconQueryBuffer { pending: None });
world.init_resource::<SnapshotBuffer>();
let mut schedule = Schedule::default();
schedule.add_systems((
tick_economy_simulation.run_if(sim_not_paused),
serve_econ_state_query.after(tick_economy_simulation),
));
schedule.run(&mut world);
// tick=0 (SimulationTime default) is a multiple of ECON_TICK_RATE, so
// the step runs for real: sim.step() advances the fresh Simulation's
// own internal counter from 0 to 1, overwriting the stale seed.
assert_eq!(
world.resource::<EconStateResource>().econ_tick,
1,
"tick_economy_simulation must run and overwrite the stale econ_tick when not paused"
);
}
}
+11 -1
View File
@@ -2,10 +2,20 @@
//!
//! All systems run in [`TickPhase::Economy`]. Intra-phase ordering:
//! - tick_economy_simulation → serve_econ_state_query (query reads fresh signals)
//!
//! T-970 (D-226 layer 1): `tick_economy_simulation` is individually gated with
//! `sim_not_paused` so it freezes while the sim is paused — but
//! `serve_econ_state_query` stays unconditioned so the paused-allowed
//! `EconStateQuery` (input.rs) keeps being served. `TickPhase::Economy` itself
//! is deliberately NOT set-gated in `tick_phases.rs` for exactly this reason.
//! `.after()` ordering still holds when the upstream system is skipped by its
//! own run condition — a run condition only prevents a system from running,
//! it doesn't relax the ordering edge for the passes where both do run.
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::simulation::time::sim_not_paused;
use crate::tick_phases::TickPhase;
pub struct EconomyPlugin {
@@ -27,7 +37,7 @@ impl Plugin for EconomyPlugin {
.add_systems(
Update,
(
super::economy::tick_economy_simulation,
super::economy::tick_economy_simulation.run_if(sim_not_paused),
super::economy::serve_econ_state_query
.after(super::economy::tick_economy_simulation),
)
+192 -7
View File
@@ -24,7 +24,7 @@ use crate::simulation::inventory::{handle_place, handle_take, CarriedBy, Invento
use crate::simulation::movement::{apply_move, PlayerCharacter, TilePosition};
use crate::simulation::save_io::{queue_save_load, SaveLoadCommand, SaveLoadPending};
use crate::simulation::stance::{handle_toggle_stance, PlayerMoveCooldown, Stance};
use crate::simulation::time::{SimulationTime, TickRate};
use crate::simulation::time::{PauseParams, TickRate};
use crate::test_world::reset::{handle_reset, RoomResetTrigger, RoomSnapshots};
use crate::test_world::teleport::handle_teleport_to_hub;
use bevy_ecs::prelude::*;
@@ -108,7 +108,7 @@ impl InputQueue {
#[allow(clippy::too_many_arguments)]
pub fn process_player_input(
mut input_queue: ResMut<InputQueue>,
mut time: ResMut<SimulationTime>,
mut pause: PauseParams,
mut commands: Commands,
registry: Res<EntityRegistry>,
mut player_query: PlayerInputQuery,
@@ -124,8 +124,8 @@ pub fn process_player_input(
object_types: Query<&ObjectType>,
mut bookmark: BookmarkInputParams<'_>,
) {
let current_tick = time.tick;
let paused = time.paused();
let current_tick = pause.time.tick;
let paused = pause.time.paused();
let inputs = input_queue.drain_for_tick(current_tick);
// Track whether any movement was attempted this tick (for cooldown tick advance)
@@ -134,11 +134,18 @@ pub fn process_player_input(
for input in inputs {
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
// SaveGame/LoadGame are also exempted — saving while paused is valid (#553).
// AutoPause/AutoResume (T-970) are whitelisted for the same reason as
// Pause/Unpause: AutoResume MUST reach the match arm while paused (that's
// the entire point — resuming from a paused state), and AutoPause is
// included for symmetry even though its handler is a no-op whenever the
// sim isn't already TickRate::Full.
if paused
&& !matches!(
input.action,
PlayerAction::Pause
| PlayerAction::Unpause
| PlayerAction::AutoPause
| PlayerAction::AutoResume
| PlayerAction::TeleportToHub
| PlayerAction::SaveGame { .. }
| PlayerAction::LoadGame { .. }
@@ -193,15 +200,42 @@ pub fn process_player_input(
handle_toggle_stance(&mut player_query, false);
}
PlayerAction::Pause => {
time.tick_rate = TickRate::Paused;
pause.time.tick_rate = TickRate::Paused;
tracing::debug!("Simulation paused by player input");
}
PlayerAction::Unpause => {
time.tick_rate = TickRate::Full;
pause.time.tick_rate = TickRate::Full;
tracing::debug!("Simulation unpaused by player input");
}
PlayerAction::AutoPause => {
// T-970 (D-226 layer 1, Option A): only take effect when the sim
// is currently fully running. A pre-existing manual pause or Half
// rate (D-088) is left completely untouched — no previous-rate
// stack, so if auto-pause didn't cause the pause, auto-resume
// must not clear it either (see AutoResume below).
if pause.time.tick_rate == TickRate::Full {
pause.time.tick_rate = TickRate::Paused;
if let Some(auto_pause) = pause.auto_pause.as_deref_mut() {
auto_pause.active = true;
}
tracing::debug!("Simulation auto-paused (implant fullscreen)");
}
}
PlayerAction::AutoResume => {
// T-970: only resume — and only clear the flag — if OUR OWN
// auto-pause is what caused the current pause. A prior manual
// pause or Half rate survives the implant close untouched.
let should_resume = pause.auto_pause.as_deref().is_some_and(|a| a.active);
if should_resume {
pause.time.tick_rate = TickRate::Full;
if let Some(auto_pause) = pause.auto_pause.as_deref_mut() {
auto_pause.active = false;
}
tracing::debug!("Simulation auto-resumed (implant closed)");
}
}
PlayerAction::SetTickRate(rate) => {
time.tick_rate = rate;
pause.time.tick_rate = rate;
tracing::debug!("Tick rate set to {:?} by player input", rate);
}
PlayerAction::Interact {
@@ -410,6 +444,7 @@ mod tests {
use super::*;
use crate::bridge::types::MovementStance;
use crate::simulation::movement::MoveIntent;
use crate::simulation::time::{AutoPauseState, SimulationTime};
#[test]
fn drain_returns_inputs_up_to_tick() {
@@ -855,4 +890,154 @@ mod tests {
"SetTickRate must be rejected while paused (R2-OQ-01)"
);
}
// === Auto-Pause Reconciliation Tests (T-970, D-226 layer 1) ===
// Option A (lead ruling, no previous-rate stack): AutoPause only takes
// effect from TickRate::Full; AutoResume only fires when AutoPauseState
// says auto-pause itself caused the current pause. Manual pause and Half
// rate (D-088) must survive an implant open/close cycle untouched.
#[test]
fn auto_pause_alone_then_auto_resume_unpauses() {
// No manual pause / Half rate in play — AutoPause causes the pause, so
// AutoResume (implant closing) must actually resume it.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.init_resource::<AutoPauseState>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::AutoPause,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"AutoPause must pause from Full"
);
assert!(
world.resource::<AutoPauseState>().active,
"auto_pause_active must be set — this pause was auto-triggered"
);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::AutoResume,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Full,
"auto-pause-alone -> auto-resume must actually resume"
);
assert!(
!world.resource::<AutoPauseState>().active,
"flag must clear on auto-resume"
);
}
#[test]
fn manual_pause_survives_auto_pause_and_auto_resume() {
// A prior MANUAL pause must survive the implant open/close cycle
// untouched — auto-resume must NOT fire since auto-pause wasn't the trigger.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.init_resource::<AutoPauseState>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Step 1: manual pause (Space bar).
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Pause,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused
);
// Step 2: implant opens — AutoPause fires. Already paused (not Full),
// so it must no-op and leave the flag false.
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::AutoPause,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused
);
assert!(
!world.resource::<AutoPauseState>().active,
"manual pause was not caused by auto-pause"
);
// Step 3: implant closes — AutoResume fires. Flag is false, so it must
// NOT unpause.
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::AutoResume,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"manual pause -> auto-pause -> auto-resume must leave the sim still paused"
);
}
#[test]
fn half_rate_survives_auto_pause_and_auto_resume_untouched() {
// D-088 Half rate is untouched by auto-pause/auto-resume — AutoPause
// only takes effect from Full, so Half stays Half through an implant
// open/close cycle (no previous-rate stack, "keep it simple").
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Half;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
world.init_resource::<AutoPauseState>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::AutoPause,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Half,
"AutoPause must not touch Half rate"
);
assert!(!world.resource::<AutoPauseState>().active);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::AutoResume,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Half,
"AutoResume must not touch Half rate either"
);
}
}
+35 -1
View File
@@ -1,10 +1,20 @@
//! Social simulation plugin — NPC knowledge transfer, disclosure, and social systems.
//!
//! All systems run in [`TickPhase::Simulation`].
//!
//! T-970 (D-226 layer 1): `TickPhase::Simulation` is NOT set-gated while the
//! sim is paused — `collect_sound_events` must keep running every tick
//! regardless (see its own `.run_if`-free registration below and the doc
//! comment on `tick_phases::TickPhase::configure`). Every other system in
//! this phase IS individually gated with `sim_not_paused`, collectively via
//! the tuple's own `.run_if()` (safe here because collect_sound_events isn't
//! part of that tuple — the anonymous-set-wrapping this creates only affects
//! the gated group, not the always-on system).
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::simulation::time::sim_not_paused;
use crate::tick_phases::TickPhase;
pub struct SocialPlugin;
@@ -15,11 +25,34 @@ impl Plugin for SocialPlugin {
.init_resource::<super::follow::FollowEndEventQueue>()
.init_resource::<super::monologue::PostConversationQueue>()
.init_resource::<super::poi_discovery::PoiDiscoveryEventQueue>()
// collect_sound_events: NEVER gated (T-970). It only clears-then-
// refills the this-tick SoundEventQueue from SoundEventEmitter
// components — compute_observer_snapshot (Snapshot phase,
// ungated) peeks that queue via Res every tick without draining
// it itself, so skipping this system while paused would leave
// stale sound events visible in every snapshot taken while
// frozen (confirmed by tests/golden_suite.rs going red when this
// was gated as part of the whole Simulation set).
//
// Deliberate, documented semantics (do not "fix" this later):
// with collect_sound_events left unconditioned, the FIRST paused
// tick clears the queue and no gated producer re-emits into it
// (movement/dialogue/etc. that would populate SoundEventEmitter
// are all frozen), so every snapshot served while paused shows
// sound_events: [] — a stable, silent, frozen inspection view
// one tick after the pause takes effect. This matches D-226's
// "inspection substrate" intent (sound is an instantaneous
// event, not persistent world state, so a frozen view showing
// none is correct) rather than replaying/holding the last sound
// indefinitely while paused.
.add_systems(
Update,
super::sound::collect_sound_events.in_set(TickPhase::Simulation),
)
.add_systems(
Update,
(
super::npc_knowledge_transfer::transfer_npc_knowledge,
super::sound::collect_sound_events,
// Voice enrichment (D-138) — rewrite NPC text with voiced variants.
// No-op when VoiceCacheResource is absent.
crate::voice::integration::voice_enrich_dialogue_response,
@@ -34,6 +67,7 @@ impl Plugin for SocialPlugin {
// Triangle escalation (#250) — runs on game-minute boundaries
super::triangle::tick_triangle_escalation,
)
.run_if(sim_not_paused)
.in_set(TickPhase::Simulation),
);
}
+44
View File
@@ -3,6 +3,7 @@
// Injectable time resource for deterministic replay (D-030)
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use serde::{Deserialize, Serialize};
pub const TICKS_PER_GAME_MINUTE: u64 = 10;
@@ -109,6 +110,49 @@ pub fn advance_tick(mut time: ResMut<SimulationTime>) {
}
}
/// Run condition (T-970, D-226 layer 1): gates the world-advancing tick phases
/// (Movement, Simulation, Storyteller, Knowledge, TickAdvance — wired in
/// `tick_phases::TickPhase::configure`) so they skip entirely while the sim is
/// paused. Keyed on `TickRate::Paused` only — Half rate (D-088) keeps running
/// these phases exactly as it does today; this adds a hard schedule-level stop
/// for Paused, matching what `advance_tick` above already does for the clock.
pub fn sim_not_paused(time: Res<SimulationTime>) -> bool {
!time.paused()
}
/// Tracks whether the CURRENT `TickRate::Paused` state was caused by the
/// auto-pause mechanism (T-970, D-226 layer 1: fullscreen implant apps
/// auto-pause the sim, D-170) rather than a manual player pause (D-088, Space
/// bar) or a pre-existing Half rate.
///
/// Reconciliation is "Option A" (lead ruling — no previous-rate stack):
/// - `PlayerAction::AutoPause` (`simulation::input`) only pauses — and sets
/// `active = true` — when the sim is currently `TickRate::Full`. A
/// pre-existing manual pause or Half rate is left completely untouched.
/// - `PlayerAction::AutoResume` only unpauses — and clears `active` — when
/// `active` is already true, i.e. only when auto-pause itself is what
/// caused the pause. A manual pause (or Half rate, which `AutoPause` never
/// touches) survives an implant open/close cycle untouched.
#[derive(Resource, Debug, Default)]
pub struct AutoPauseState {
pub active: bool,
}
/// Bundled SystemParam for pause-related input handling (T-970).
///
/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters
/// (mirrors `bookmark::BookmarkInputParams`'s reason for existing).
/// `process_player_input` was already at exactly 16 — bundling `time` and
/// `auto_pause` together here (rather than adding `auto_pause` as its own
/// 17th top-level parameter) keeps it under the ceiling. The two fields
/// belong together anyway: `AutoPauseState` only makes sense in terms of
/// `SimulationTime.tick_rate`.
#[derive(SystemParam)]
pub struct PauseParams<'w> {
pub time: ResMut<'w, SimulationTime>,
pub auto_pause: Option<ResMut<'w, AutoPauseState>>,
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -15,6 +15,9 @@ pub struct TimePlugin;
impl Plugin for TimePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<super::time::SimulationTime>()
// T-970 (D-226 layer 1): tracks whether the current pause was
// auto-triggered (implant fullscreen) vs. manual (D-088).
.init_resource::<super::time::AutoPauseState>()
.init_resource::<super::chunk_streaming::ChunkLoadRadius>()
.init_resource::<super::chunk_streaming::ChunkStreamingCadence>()
.init_resource::<super::ticker::TickerPool>()
+320
View File
@@ -32,6 +32,8 @@ use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::simulation::time::sim_not_paused;
/// The 10 phases of the server tick cycle.
///
/// Ordered linearly: each phase completes before the next begins.
@@ -85,5 +87,323 @@ impl TickPhase {
)
.chain(),
);
// T-970 (D-226 layer 1): freeze the world-advancing phases while the
// sim is paused (TickRate::Paused). PreInput (gen-drain, D-206), Input
// (accepts AutoResume/Unpause), Snapshot, and PostSnapshot stay
// ungated so the client keeps getting served while frozen.
//
// Movement, Storyteller, Knowledge, TickAdvance are gated at the SET
// level, one `configure_sets` call each (NOT a single call over a
// tuple of all four — see the note below on why grouping is unsafe).
//
// Simulation and Economy are deliberately NOT set-gated here — each
// needs a per-system split instead, because at least one system in
// each phase must keep running every tick regardless of pause state:
// - Economy: `serve_econ_state_query` (economy_plugin.rs) must keep
// answering the paused-allowed `EconStateQuery`.
// - Simulation: `collect_sound_events` (social_plugin.rs) must keep
// clearing `SoundEventQueue` every tick — it is a this-tick-only
// transient buffer (cleared then refilled each pass) that
// `compute_observer_snapshot` (Snapshot, ungated) only *peeks* at
// via `Res` — it does not drain it itself. Freezing the system that
// clears it left stale sound events visible in every snapshot taken
// while paused, which is exactly the class of bug T-970 exists to
// avoid: it broke `tests/golden_suite.rs`'s determinism fixture
// (`sound_events[0]: unexpected in actual`) even on a trace that
// only pauses on the last tick. Each individual Simulation-phase
// system EXCEPT collect_sound_events is gated at its own
// registration site instead (npc/mod.rs, perception/mod.rs,
// bridge/mod.rs, social_plugin.rs) — audited for the same
// peek-only-Res-of-a-phase-cleared-resource pattern; none of the
// others exhibited it against this fixture.
//
// IMPORTANT: `.run_if()` is attached to each of the four SET-gated
// phases INDIVIDUALLY here — NOT to a tuple of them as a whole (i.e.
// NOT `(Movement, Storyteller, Knowledge, TickAdvance).run_if(...)`).
// Bevy's `configure_sets` treats a >1-element group's collective
// `run_if` as a request to wrap every member in a brand-new anonymous
// parent set (`ScheduleGraph::apply_collective_conditions` only
// attaches the condition directly to the existing set when the group
// has exactly one element; for more, it mints an anonymous set and
// makes every member a child of it). That turned out to be a red
// herring for the actual bug above (an always-true dummy condition on
// the same grouped shape stayed green), but there is no upside to the
// grouped form and it is one more moving part than the single-set
// form needs, so each set gets its own call.
app.configure_sets(Update, Movement.run_if(sim_not_paused));
app.configure_sets(Update, Storyteller.run_if(sim_not_paused));
app.configure_sets(Update, Knowledge.run_if(sim_not_paused));
app.configure_sets(Update, TickAdvance.run_if(sim_not_paused));
}
}
#[cfg(test)]
mod tests {
use super::TickPhase;
use crate::simulation::time::{SimulationTime, TickRate};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
#[derive(Resource, Default)]
struct PhaseCounters {
pre_input: u32,
input: u32,
movement: u32,
simulation: u32,
economy: u32,
storyteller: u32,
snapshot: u32,
post_snapshot: u32,
knowledge: u32,
tick_advance: u32,
}
fn mark_pre_input(mut c: ResMut<PhaseCounters>) {
c.pre_input += 1;
}
fn mark_input(mut c: ResMut<PhaseCounters>) {
c.input += 1;
}
fn mark_movement(mut c: ResMut<PhaseCounters>) {
c.movement += 1;
}
fn mark_simulation(mut c: ResMut<PhaseCounters>) {
c.simulation += 1;
}
fn mark_economy(mut c: ResMut<PhaseCounters>) {
c.economy += 1;
}
fn mark_storyteller(mut c: ResMut<PhaseCounters>) {
c.storyteller += 1;
}
fn mark_snapshot(mut c: ResMut<PhaseCounters>) {
c.snapshot += 1;
}
fn mark_post_snapshot(mut c: ResMut<PhaseCounters>) {
c.post_snapshot += 1;
}
fn mark_knowledge(mut c: ResMut<PhaseCounters>) {
c.knowledge += 1;
}
fn mark_tick_advance(mut c: ResMut<PhaseCounters>) {
c.tick_advance += 1;
}
/// Builds a bare App with only the phase skeleton + one marker system per
/// phase — no other plugin — so this test exercises exactly the gating
/// wired in `TickPhase::configure`, nothing else.
fn build_test_app() -> App {
let mut app = App::new();
TickPhase::configure(&mut app);
app.init_resource::<PhaseCounters>();
app.insert_resource(SimulationTime::default());
app.add_systems(Update, mark_pre_input.in_set(TickPhase::PreInput));
app.add_systems(Update, mark_input.in_set(TickPhase::Input));
app.add_systems(Update, mark_movement.in_set(TickPhase::Movement));
app.add_systems(Update, mark_simulation.in_set(TickPhase::Simulation));
app.add_systems(Update, mark_economy.in_set(TickPhase::Economy));
app.add_systems(Update, mark_storyteller.in_set(TickPhase::Storyteller));
app.add_systems(Update, mark_snapshot.in_set(TickPhase::Snapshot));
app.add_systems(Update, mark_post_snapshot.in_set(TickPhase::PostSnapshot));
app.add_systems(Update, mark_knowledge.in_set(TickPhase::Knowledge));
app.add_systems(Update, mark_tick_advance.in_set(TickPhase::TickAdvance));
app
}
#[test]
fn world_advancing_phases_skip_while_paused_keep_alive_phases_still_run() {
// T-970: Movement/Storyteller/Knowledge/TickAdvance must freeze on
// TickRate::Paused via TickPhase::configure's SET-level gate.
// PreInput/Input/Snapshot/PostSnapshot must keep running every pass
// regardless. Simulation and Economy are deliberately NOT gated by
// TickPhase::configure at all — each needs a per-system split
// instead (economy_plugin.rs's tick_economy_simulation vs.
// serve_econ_state_query; social_plugin.rs's collect_sound_events
// exemption, proven by `simulation_phase_gates_everything_except_sound_event_collection`
// below) — so this bare-`configure()` test app, which registers no
// other plugin, correctly observes both as ungated here.
let mut app = build_test_app();
// Pass 1 (Full rate): every phase runs once.
app.update();
{
let c = app.world().resource::<PhaseCounters>();
assert_eq!(c.pre_input, 1);
assert_eq!(c.input, 1);
assert_eq!(c.movement, 1);
assert_eq!(c.simulation, 1);
assert_eq!(c.economy, 1);
assert_eq!(c.storyteller, 1);
assert_eq!(c.snapshot, 1);
assert_eq!(c.post_snapshot, 1);
assert_eq!(c.knowledge, 1);
assert_eq!(c.tick_advance, 1);
}
// Pause, then pass 2.
app.world_mut().resource_mut::<SimulationTime>().tick_rate = TickRate::Paused;
app.update();
let c = app.world().resource::<PhaseCounters>();
// Keep-alive phases ran again.
assert_eq!(
c.pre_input, 2,
"PreInput must keep running while paused (gen-drain, D-206)"
);
assert_eq!(
c.input, 2,
"Input must keep running while paused (accepts AutoResume/Unpause)"
);
assert_eq!(
c.economy, 2,
"Economy SET must stay ungated at the phase level — the split lives in economy_plugin.rs"
);
assert_eq!(
c.simulation, 2,
"Simulation SET must stay ungated at the phase level — the split lives in social_plugin.rs (collect_sound_events)"
);
assert_eq!(
c.snapshot, 2,
"Snapshot must keep running while paused (bridge assembly)"
);
assert_eq!(
c.post_snapshot, 2,
"PostSnapshot must keep running while paused (bridge send)"
);
// World-advancing phases must NOT have run again — still 1.
assert_eq!(c.movement, 1, "Movement must skip while paused");
assert_eq!(c.storyteller, 1, "Storyteller must skip while paused");
assert_eq!(c.knowledge, 1, "Knowledge must skip while paused");
assert_eq!(c.tick_advance, 1, "TickAdvance must skip while paused");
}
/// Proves the exact mechanism that broke `tests/golden_suite.rs`
/// (`sound_events[0]: unexpected in actual`) and its fix: gating
/// `collect_sound_events` (or any system with the same "clear a
/// this-tick transient buffer" job) as part of a wholesale `Simulation`
/// SET-level condition leaves the buffer un-cleared while paused, and an
/// UNGATED downstream reader (like `compute_observer_snapshot`, which
/// only peeks the queue via `Res`, never draining it itself) then serves
/// stale data. Modeled with two tiny stand-in systems reproducing that
/// exact shape — not the real sound module — to keep this test
/// self-contained and fast.
#[test]
fn simulation_phase_gates_everything_except_sound_event_collection() {
#[derive(Resource, Default)]
struct StaleBuffer {
events: Vec<u32>,
}
// Records the buffer length observed by the Snapshot-phase reader on
// each pass — a plain Vec can't be a Resource directly (orphan rule).
#[derive(Resource, Default)]
struct SeenLengths(Vec<usize>);
// Stand-in for collect_sound_events: clears-then-refills every tick
// it runs. Registered WITHOUT a run_if — it must always run.
fn clear_buffer(mut buf: ResMut<StaleBuffer>) {
buf.events.clear();
}
// Stand-in for a genuine "world advancing" Simulation-phase system —
// gated normally.
fn other_simulation_work(mut c: ResMut<PhaseCounters>) {
c.simulation += 1;
}
// Stand-in for compute_observer_snapshot: an UNGATED Snapshot-phase
// reader that only peeks the buffer (never drains it) — the role
// that observed the staleness in the real bug.
fn peek_buffer_into_snapshot(buf: Res<StaleBuffer>, mut seen: ResMut<SeenLengths>) {
seen.0.push(buf.events.len());
}
let mut app = App::new();
TickPhase::configure(&mut app);
app.init_resource::<PhaseCounters>();
app.init_resource::<StaleBuffer>();
app.init_resource::<SeenLengths>();
app.insert_resource(SimulationTime::default());
// Mirrors social_plugin.rs's actual split: collect_sound_events
// ungated, everything else in Simulation gated individually.
app.add_systems(Update, clear_buffer.in_set(TickPhase::Simulation));
app.add_systems(
Update,
other_simulation_work
.run_if(crate::simulation::time::sim_not_paused)
.in_set(TickPhase::Simulation),
);
app.add_systems(
Update,
peek_buffer_into_snapshot.in_set(TickPhase::Snapshot),
);
// Tick 1 (Full), buffer starts empty: baseline pass.
app.update();
assert_eq!(
*app.world().resource::<SeenLengths>().0.last().unwrap(),
0,
"tick 1: buffer starts empty"
);
assert_eq!(app.world().resource::<PhaseCounters>().simulation, 1);
// Simulate "a footstep just happened": an event lands in the buffer
// as a tick's leftover state — exactly what collect_sound_events
// would have harvested from a SoundEventEmitter earlier that same
// tick, in the real system. Then pause (mirrors tick 8 in
// golden_suite.rs: Pause is processed via Input, which runs before
// Simulation in the very same pass, so Simulation's gate already
// observes Paused by the time it's evaluated this tick).
app.world_mut().resource_mut::<StaleBuffer>().events.push(1);
app.world_mut().resource_mut::<SimulationTime>().tick_rate =
crate::simulation::time::TickRate::Paused;
app.update();
assert_eq!(
app.world().resource::<PhaseCounters>().simulation,
1,
"other_simulation_work must skip while paused (still 1, from tick 1's Full pass)"
);
assert_eq!(
*app.world().resource::<SeenLengths>().0.last().unwrap(),
0,
"clear_buffer (collect_sound_events stand-in) must still run while paused, \
clearing the leftover event instead of leaking it into the paused \
tick's snapshot this is the exact T-970 golden_suite.rs regression \
(sound_events[0]: unexpected in actual). If Simulation were set-gated \
as a whole again, clear_buffer would also skip and this would \
observe 1, not 0."
);
}
#[test]
fn gated_phases_resume_after_unpause() {
// Round-trip: paused -> unpaused must resume exactly where it left
// off, no double-counting or lost passes.
let mut app = build_test_app();
app.update(); // Full: 1 everywhere
app.world_mut().resource_mut::<SimulationTime>().tick_rate = TickRate::Paused;
app.update(); // Paused: world-advancing phases stay at 1
app.world_mut().resource_mut::<SimulationTime>().tick_rate = TickRate::Full;
app.update(); // Full again: world-advancing phases go to 2
let c = app.world().resource::<PhaseCounters>();
assert_eq!(c.movement, 2, "Movement must resume after unpause");
assert_eq!(c.storyteller, 2, "Storyteller must resume after unpause");
assert_eq!(c.knowledge, 2, "Knowledge must resume after unpause");
assert_eq!(c.tick_advance, 2, "TickAdvance must resume after unpause");
// Keep-alive phases (plus Simulation/Economy, ungated at this SET
// level — see the per-system splits in social_plugin.rs /
// economy_plugin.rs) ran all 3 passes.
assert_eq!(c.pre_input, 3);
assert_eq!(c.input, 3);
assert_eq!(c.economy, 3);
assert_eq!(c.simulation, 3);
}
}
+32 -4
View File
@@ -338,17 +338,22 @@ 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, HandshakeState, ServerRunning,
receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, CityNamesRequestBuffer,
HandshakeState, ServerRunning, StarMapRequestBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
let server_addr = listener.local_addr().expect("failed to get local address");
// Client: three frames back-to-back in one tick window — two input
// batches plus one atlas request (the D-225 demux path). Returns the
// stream so it stays open until assertions complete (no EOF race).
// Client: five frames back-to-back in one tick window — two input
// batches plus one of EACH request shape (atlas, star-map, city-names:
// the full D-225/T-949 demux surface over the real framing/poll path —
// PR #176 review H6). Returns the stream so it stays open until
// assertions complete (no EOF race).
let client_handle = thread::spawn(move || {
use settled_reach_server::atlas::atlas_data_proxy::{CityNamesRequest, StarMapRequest};
let mut stream = TcpStream::connect(server_addr).expect("failed to connect");
for tick in [20u64, 21] {
let inputs = vec![PlayerInput {
@@ -364,6 +369,15 @@ fn single_tick_drains_all_ready_inbound_frames() {
};
let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize");
write_framed(&mut stream, &payload).expect("write atlas frame");
let sm = StarMapRequest { star_map: true };
let payload = rmp_serde::to_vec_named(&sm).expect("failed to serialize star map");
write_framed(&mut stream, &payload).expect("write star map frame");
let cn = CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
};
let payload = rmp_serde::to_vec_named(&cn).expect("failed to serialize city names");
write_framed(&mut stream, &payload).expect("write city names frame");
stream
});
@@ -379,6 +393,8 @@ fn single_tick_drains_all_ready_inbound_frames() {
world.insert_resource(HandshakeState::Complete);
world.init_resource::<SimErrorBuffer>();
world.init_resource::<AtlasRequestBuffer>();
world.init_resource::<StarMapRequestBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world
.run_system_once(receive_bridge_inputs)
@@ -394,6 +410,18 @@ fn single_tick_drains_all_ready_inbound_frames() {
1,
"the atlas request must drain in the same tick"
);
assert_eq!(
world.resource::<StarMapRequestBuffer>().0.len(),
1,
"the star-map request must drain in the same tick (H6: real wire path)"
);
let city_names = &world.resource::<CityNamesRequestBuffer>().0;
assert_eq!(
city_names.len(),
1,
"the city-names request must drain in the same tick (H6: real wire path)"
);
assert_eq!(city_names[0].body_id, "GJ1c");
assert!(
world.resource::<ServerRunning>().0,
"draining must not shut the server down"
+57 -2
View File
@@ -3,10 +3,14 @@
use settled_reach_server::atlas::body_world_state::{DrainageBasin, RiverNetwork};
use settled_reach_server::atlas::layer1::Layer1Output;
use settled_reach_server::atlas::layer_proxy::{AtlasLayerResponse, AtlasLayerStatus};
use settled_reach_server::atlas::layer_proxy::{
AtlasLayerResponse, AtlasLayerStatus, RoadGraphEdge, RoadGraphLayer, RoadGraphNode,
SettlementEntry, SettlementLayer, SettlementSizeClass,
};
use settled_reach_server::atlas::road_graph::RoadNodeKind;
use settled_reach_server::bridge::types::*;
use settled_reach_server::simulation::generator::{
AttractorType, GeographicAttractor, SubBiomeVariant,
AttractorType, GeographicAttractor, MaintenanceAuthority, SubBiomeVariant,
};
use settled_reach_server::simulation::poi::PoiCategory;
use settled_reach_server::simulation::time::{DayPhase, TickRate};
@@ -568,11 +572,58 @@ fn generate_atlas_layer_response_fixtures() {
grid_h: 256,
district_basin_dirs: std::collections::BTreeMap::new(),
};
// T-960 §1/§2: a small populated RoadGraphLayer + SettlementLayer, one
// settlement (a capital) connected to one waypoint-free short edge.
let road_graph = RoadGraphLayer {
nodes: vec![
RoadGraphNode {
position: (12, 58),
kind: RoadNodeKind::Settlement,
city_id: Some(1),
},
RoadGraphNode {
position: (20, 70),
kind: RoadNodeKind::Settlement,
city_id: Some(2),
},
],
edges: vec![RoadGraphEdge {
from: 0,
to: 1,
path: vec![(12, 58), (16, 64), (20, 70)],
maintenance: MaintenanceAuthority::Administrative,
is_rail: false,
named_route_id: None,
}],
};
let settlements = SettlementLayer {
settlements: vec![
SettlementEntry {
city_id: 1,
name: "Port Aldren".into(),
position: (12, 58),
size_class: SettlementSizeClass::Major,
is_capital: true,
is_port: true,
},
SettlementEntry {
city_id: 2,
name: "Farmstead Rell".into(),
position: (20, 70),
size_class: SettlementSizeClass::Minor,
is_capital: false,
is_port: false,
},
],
};
let ready = AtlasLayerResponse {
body_id: "GJ1c".into(),
status: AtlasLayerStatus::Ready,
layer1: Some(layer1),
district_grid: None,
road_graph: Some(road_graph),
settlements: Some(settlements),
};
write_fixture(
"atlas_response_ready",
@@ -584,6 +635,8 @@ fn generate_atlas_layer_response_fixtures() {
status: AtlasLayerStatus::Pending,
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
};
write_fixture(
"atlas_response_pending",
@@ -595,6 +648,8 @@ fn generate_atlas_layer_response_fixtures() {
status: AtlasLayerStatus::NotFound,
layer1: None,
district_grid: None,
road_graph: None,
settlements: None,
};
write_fixture(
"atlas_response_not_found",
+9
View File
@@ -84,4 +84,13 @@ if [ -n "$MATCHES" ]; then
echo "$MATCHES"
exit 1
fi
# Restore a FULL class cache before exiting: the cold parse run re-seeds
# global_script_class_cache.cfg only partially — addon classes (e.g. gdUnit4's
# GdUnitTestCIRunner) are missing, which leaves tests/run-godot unable to even
# start (0 tests in ~350ms; found live when the pre-push gate ran the suite
# right after this script, 2026-07-14). The cold verdict above is already
# decided; this restore just returns the tree to a runnable state.
godot --headless --path "$REPO_ROOT/client" --import > /dev/null 2>&1 || true
echo "godot-cold-parse: clean"