diff --git a/client/scripts/autoloads/hud_groups.gd b/client/scripts/autoloads/hud_groups.gd index 7e35e2ac2..81efca4d5 100644 --- a/client/scripts/autoloads/hud_groups.gd +++ b/client/scripts/autoloads/hud_groups.gd @@ -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] = [] diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 1a2991111..650f8011c 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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 diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 33e7fe19c..e0fddaebe 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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)} diff --git a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack index aa580b491..ac84932a1 100644 --- a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack @@ -1 +1 @@ -body_idghoststatusNotFoundlayer1 \ No newline at end of file +body_idghoststatusNotFoundlayer1district_gridroad_graphsettlements \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack index 1606cc5ab..97399184a 100644 --- a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack @@ -1 +1 @@ -body_idGJ1cstatusPendinglayer1 \ No newline at end of file +body_idGJ1cstatusPendinglayer1district_gridroad_graphsettlements \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack index bc1ba44c8..ec4101d91 100644 Binary files a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack and b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 6d9f70243..149c5059a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 6b2f4c4f5..7e7ff03ec 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 376daff0d..0f7243d3e 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 54bd601ac..5a5ba8c56 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index e648490a9..aa816e294 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 6d9f70243..149c5059a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack index bc21bd235..3139352f3 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack index 3a7b418d0..206842b2d 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack and b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 0e3c35c2b..23d12f9cc 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index b35bca377..7c826a526 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 31b244945..cc6f022e9 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 6c1f4b7ca..ef9d0bfec 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack b/client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack index 91984c0e5..cbc522c88 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack and b/client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack differ diff --git a/client/tests/test_atlas_city_names.gd b/client/tests/test_atlas_city_names.gd new file mode 100644 index 000000000..faf9d067d --- /dev/null +++ b/client/tests/test_atlas_city_names.gd @@ -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() diff --git a/client/tests/test_atlas_data_delivery.gd b/client/tests/test_atlas_data_delivery.gd new file mode 100644 index 000000000..e0986150d --- /dev/null +++ b/client/tests/test_atlas_data_delivery.gd @@ -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() diff --git a/client/tests/test_atlas_overlays.gd b/client/tests/test_atlas_overlays.gd index 15b76feb3..045a543f2 100644 --- a/client/tests/test_atlas_overlays.gd +++ b/client/tests/test_atlas_overlays.gd @@ -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() diff --git a/client/tests/test_hud_groups_auto_pause.gd b/client/tests/test_hud_groups_auto_pause.gd new file mode 100644 index 000000000..2c1d9fd5a --- /dev/null +++ b/client/tests/test_hud_groups_auto_pause.gd @@ -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() diff --git a/client/tests/test_system_index.gd b/client/tests/test_system_index.gd new file mode 100644 index 000000000..a86fec42c --- /dev/null +++ b/client/tests/test_system_index.gd @@ -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() diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 8b5bdbf83..85fa9a3ec 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/atlas_legend_panel.gd b/client/ui/implant/apps/atlas/atlas_legend_panel.gd new file mode 100644 index 000000000..606049044 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_legend_panel.gd @@ -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() diff --git a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd index 038d6c780..f12011a6f 100644 --- a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd @@ -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: diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd index b4fa832b6..30327394e 100644 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -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() diff --git a/client/ui/implant/apps/economics/screens/overview_screen.gd b/client/ui/implant/apps/economics/screens/overview_screen.gd index 26aae710a..b2907a446 100644 --- a/client/ui/implant/apps/economics/screens/overview_screen.gd +++ b/client/ui/implant/apps/economics/screens/overview_screen.gd @@ -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 # ============================================================================= diff --git a/client/ui/implant/widgets/system_index.gd b/client/ui/implant/widgets/system_index.gd index 6025e2088..164092e2d 100644 --- a/client/ui/implant/widgets/system_index.gd +++ b/client/ui/implant/widgets/system_index.gd @@ -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 diff --git a/server/src/atlas/atlas_data_proxy.rs b/server/src/atlas/atlas_data_proxy.rs new file mode 100644 index 000000000..87a72b039 --- /dev/null +++ b/server/src/atlas/atlas_data_proxy.rs @@ -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` 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, +} + +/// 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::(&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, +} + +/// 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()); + } +} diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs index 3488130c2..a31f252fe 100644 --- a/server/src/atlas/attractor_matching.rs +++ b/server/src/atlas/attractor_matching.rs @@ -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![ diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs index 487acdd1f..6c754303a 100644 --- a/server/src/atlas/believability.rs +++ b/server/src/atlas/believability.rs @@ -541,18 +541,21 @@ fn read_cities(db: &PathBuf, body_id: &str) -> Result, 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}"))? diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index b87d3e3b7..2d14d9220 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -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(); diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index 088196f27..9ed3bee95 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -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>(2)?, row.get::<_, i64>(3)?, row.get::<_, Option>(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 { + 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>(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, 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" + ); + } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index f17aa8012..621209562 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -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, + /// 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, + /// 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, } /// 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, +} + +/// 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, +} + +/// 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, + pub edges: Vec, +} + +/// 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 { + 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,000–999,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, +} + +/// `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 { + 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(), diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 5b3572223..b75751b90 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -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; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 04acd2a8f..cce7eb40f 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -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, + mut responses: ResMut, + path: Option>, +) { + 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, + mut responses: ResMut, + city_reader: Option>, +) { + 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::().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::(); + assert_eq!(responses.0.len(), 1); + assert_eq!(responses.0[0].status, StarMapStatus::Ready); + assert!(world.resource::().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::(); + 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::(); + 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::().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 { diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index 986b77db8..c9ce7ea31 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -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, } } diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index ae3f24afa..0b0453187 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -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 { diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index da8efd0c6..8bd2e4ef2 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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` 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` 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), /// 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` (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` 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, + up_to: Option, + star_map: Option, + city_names: Option, +} + +/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries, +/// in order: `Vec` (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 { if let Ok(inputs) = rmp_serde::from_slice::>(payload) { return Ok(Inbound::Inputs(inputs)); } - match rmp_serde::from_slice::(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::(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::(payload) { + return Ok(Inbound::AtlasRequest(req)); + } + if let Ok(req) = rmp_serde::from_slice::(payload) { + return Ok(Inbound::StarMapRequest(req)); + } + match rmp_serde::from_slice::(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, mut error_buffer: ResMut, mut atlas_requests: ResMut, + mut star_map_requests: ResMut, + mut city_names_requests: ResMut, time: Option>, ) { 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); + +/// 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); + +/// 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>, + mut buffer: ResMut, +) { + 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); + +/// 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); + +/// 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>, + mut buffer: ResMut, +) { + 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::() @@ -325,10 +484,22 @@ impl Plugin for BridgePlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() // 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::::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::(&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::(&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" + ); + } } diff --git a/server/src/bridge/tcp.rs b/server/src/bridge/tcp.rs index 65f53fcbe..8a8eb2358 100644 --- a/server/src/bridge/tcp.rs +++ b/server/src/bridge/tcp.rs @@ -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(()) + } } diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index d300efaf8..43ef1edcd 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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. diff --git a/server/src/main.rs b/server/src/main.rs index 0e36326b7..131c1905b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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( diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 459782690..aedca0970 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -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) diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 745f5e6fb..51ac29371 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -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::() .init_resource::() .init_resource::() - // 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 diff --git a/server/src/simulation/economy.rs b/server/src/simulation/economy.rs index 57e15e33e..a35344312 100644 --- a/server/src/simulation/economy.rs +++ b/server/src/simulation/economy.rs @@ -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::(); + + // 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::().econ_tick, + STALE_ECON_TICK, + "tick_economy_simulation must be skipped while paused — econ_tick unchanged" + ); + assert!( + world.resource::().pending.is_none(), + "serve_econ_state_query must still drain the pending query while paused" + ); + let response = world + .resource::() + .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::(); + + 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::().econ_tick, + 1, + "tick_economy_simulation must run and overwrite the stale econ_tick when not paused" + ); + } } diff --git a/server/src/simulation/economy_plugin.rs b/server/src/simulation/economy_plugin.rs index 23ab02f5f..a417e76b5 100644 --- a/server/src/simulation/economy_plugin.rs +++ b/server/src/simulation/economy_plugin.rs @@ -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), ) diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 50dfa7afa..bffec6b0f 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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, - mut time: ResMut, + mut pause: PauseParams, mut commands: Commands, registry: Res, 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::(); + world.init_resource::(); + + 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::().push(PlayerInput { + tick: 0, + action: PlayerAction::AutoPause, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().tick_rate, + TickRate::Paused, + "AutoPause must pause from Full" + ); + assert!( + world.resource::().active, + "auto_pause_active must be set — this pause was auto-triggered" + ); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::AutoResume, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().tick_rate, + TickRate::Full, + "auto-pause-alone -> auto-resume must actually resume" + ); + assert!( + !world.resource::().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::(); + world.init_resource::(); + + 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::().push(PlayerInput { + tick: 0, + action: PlayerAction::Pause, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().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::().push(PlayerInput { + tick: 0, + action: PlayerAction::AutoPause, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().tick_rate, + TickRate::Paused + ); + assert!( + !world.resource::().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::().push(PlayerInput { + tick: 0, + action: PlayerAction::AutoResume, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().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::(); + world.init_resource::(); + + 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::().push(PlayerInput { + tick: 0, + action: PlayerAction::AutoPause, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().tick_rate, + TickRate::Half, + "AutoPause must not touch Half rate" + ); + assert!(!world.resource::().active); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::AutoResume, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().tick_rate, + TickRate::Half, + "AutoResume must not touch Half rate either" + ); + } } diff --git a/server/src/simulation/social_plugin.rs b/server/src/simulation/social_plugin.rs index 765ceb190..68b24ae32 100644 --- a/server/src/simulation/social_plugin.rs +++ b/server/src/simulation/social_plugin.rs @@ -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::() .init_resource::() .init_resource::() + // 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), ); } diff --git a/server/src/simulation/time.rs b/server/src/simulation/time.rs index e985316ca..2e1aa403c 100644 --- a/server/src/simulation/time.rs +++ b/server/src/simulation/time.rs @@ -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) { } } +/// 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) -> 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>, +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/simulation/time_plugin.rs b/server/src/simulation/time_plugin.rs index b66f8d76a..a4ddbb5fc 100644 --- a/server/src/simulation/time_plugin.rs +++ b/server/src/simulation/time_plugin.rs @@ -15,6 +15,9 @@ pub struct TimePlugin; impl Plugin for TimePlugin { fn build(&self, app: &mut App) { app.init_resource::() + // T-970 (D-226 layer 1): tracks whether the current pause was + // auto-triggered (implant fullscreen) vs. manual (D-088). + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/server/src/tick_phases.rs b/server/src/tick_phases.rs index abd5d2146..f8818d29b 100644 --- a/server/src/tick_phases.rs +++ b/server/src/tick_phases.rs @@ -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) { + c.pre_input += 1; + } + fn mark_input(mut c: ResMut) { + c.input += 1; + } + fn mark_movement(mut c: ResMut) { + c.movement += 1; + } + fn mark_simulation(mut c: ResMut) { + c.simulation += 1; + } + fn mark_economy(mut c: ResMut) { + c.economy += 1; + } + fn mark_storyteller(mut c: ResMut) { + c.storyteller += 1; + } + fn mark_snapshot(mut c: ResMut) { + c.snapshot += 1; + } + fn mark_post_snapshot(mut c: ResMut) { + c.post_snapshot += 1; + } + fn mark_knowledge(mut c: ResMut) { + c.knowledge += 1; + } + fn mark_tick_advance(mut c: ResMut) { + 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::(); + 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::(); + 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::().tick_rate = TickRate::Paused; + app.update(); + let c = app.world().resource::(); + + // 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, + } + + // 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); + + // 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) { + buf.events.clear(); + } + + // Stand-in for a genuine "world advancing" Simulation-phase system — + // gated normally. + fn other_simulation_work(mut c: ResMut) { + 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, mut seen: ResMut) { + seen.0.push(buf.events.len()); + } + + let mut app = App::new(); + TickPhase::configure(&mut app); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + 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::().0.last().unwrap(), + 0, + "tick 1: buffer starts empty" + ); + assert_eq!(app.world().resource::().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::().events.push(1); + app.world_mut().resource_mut::().tick_rate = + crate::simulation::time::TickRate::Paused; + app.update(); + + assert_eq!( + app.world().resource::().simulation, + 1, + "other_simulation_work must skip while paused (still 1, from tick 1's Full pass)" + ); + assert_eq!( + *app.world().resource::().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::().tick_rate = TickRate::Paused; + app.update(); // Paused: world-advancing phases stay at 1 + + app.world_mut().resource_mut::().tick_rate = TickRate::Full; + app.update(); // Full again: world-advancing phases go to 2 + + let c = app.world().resource::(); + 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); } } diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 079e3a565..4c31b700a 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -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::(); world.init_resource::(); + world.init_resource::(); + world.init_resource::(); 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::().0.len(), + 1, + "the star-map request must drain in the same tick (H6: real wire path)" + ); + let city_names = &world.resource::().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::().0, "draining must not shut the server down" diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index bf8ef5869..bc88579d3 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -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", diff --git a/tooling/godot-cold-parse b/tooling/godot-cold-parse index 053069685..613813f16 100755 --- a/tooling/godot-cold-parse +++ b/tooling/godot-cold-parse @@ -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"