diff --git a/client/scripts/protocol/atlas_map_protocol.gd b/client/scripts/protocol/atlas_map_protocol.gd new file mode 100644 index 000000000..cc6a0e5d9 --- /dev/null +++ b/client/scripts/protocol/atlas_map_protocol.gd @@ -0,0 +1,144 @@ +class_name AtlasMapProtocol +## AtlasLayerRequest/Response, StarMapRequest/Response, CityNamesRequest/Response +## codec — factored out of protocol.gd (T-1118) to stay under gdlint's +## max-file-lines cap, same rationale + shape as browse_protocol.gd +## (T-1131/T-1133): `mp` (the loaded messagepack.gd module) is passed in +## rather than reloaded here — protocol.gd's _mp() already owns that load(). +## +## Protocol delegates every one of these under the SAME public name +## (Protocol.atlas_response_from_raw(), Protocol.encode_star_map_request(), +## etc.) via its _amp() accessor — external callers (sim_bridge.gd, +## test_atlas_overlays.gd, test_atlas_data_delivery.gd) are unaffected by the +## move; only where the body lives changed. + + +## Encode an AtlasLayerRequest (#969, D-225) for the layer-stream proxy. +## A bare map {body_id, up_to} — NOT the Vec array — so the server's +## frame demux routes it to the atlas proxy. up_to is a CascadeLayer unit variant +## (bare string: "Heightmap" | "Topography"). +static func encode_atlas_layer_request( + mp, body_id: String, up_to: String = "Topography" +) -> PackedByteArray: + var msg := {"body_id": body_id, "up_to": up_to} + var result = mp.encode(msg) + if result.status != null: + push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status) + return PackedByteArray() + return result.value + + +## 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. +## region_grid (T-1113/T-1118): the region climate grid, same passthrough +## pattern. quarter_footprints (T-1119, D-226 T-1112 amendment touch point 3): +## the L4 quarter-footprint aggregates, same passthrough pattern — +## QuarterFootprintLayer.entries is a BTreeMap on +## the wire, decoding to a Dictionary with int keys (city_id), no reshaping. +## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints" are +## the CONFIRMED wire contract — identical to server/src/atlas/layer_proxy.rs +## AtlasLayerResponse's field names (region_grid pinned 2026-07-14, +## quarter_footprints pinned 2026-07-18; 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 + var status_raw = raw["status"] + var status := "" + var error := "" + if status_raw is String: + status = status_raw + elif status_raw is Dictionary and status_raw.has("Error"): + status = "Error" + error = str(status_raw["Error"]) + return { + "body_id": raw.get("body_id", ""), + "status": status, + "error": error, + "layer1": raw.get("layer1"), + "district_grid": raw.get("district_grid"), + "road_graph": raw.get("road_graph"), + "settlements": raw.get("settlements"), + "region_grid": raw.get("region_grid"), + "quarter_footprints": raw.get("quarter_footprints"), + } + + +## 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(mp) -> 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} + + +## 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(mp, 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", []), + } diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 1daf962cd..e697aef34 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -25,6 +25,12 @@ static func _bp(): return load("res://scripts/protocol/browse_protocol.gd") +## AtlasLayerRequest/Response + StarMapRequest/Response + CityNamesRequest/Response +## codec (T-1118) — same load()-by-path rationale as _bp() above. +static func _amp(): + return load("res://scripts/protocol/atlas_map_protocol.gd") + + # -- Decode: bytes from server → GDScript types -------------------------------- @@ -764,105 +770,41 @@ static func encode_request_bookmark_catalog() -> PackedByteArray: return result.value -## Encode an AtlasLayerRequest (#969, D-225) for the layer-stream proxy. -## A bare map {body_id, up_to} — NOT the Vec array — so the server's -## frame demux routes it to the atlas proxy. up_to is a CascadeLayer unit variant -## (bare string: "Heightmap" | "Topography"). +## AtlasLayerRequest/Response + StarMapRequest/Response + CityNamesRequest/ +## Response codec — factored into atlas_map_protocol.gd (T-1118) to stay +## under gdlint's max-file-lines, same rationale as _bp()/browse_protocol.gd +## above. Every function below is a thin delegate under its ORIGINAL public +## name — external callers (sim_bridge.gd, test_atlas_overlays.gd, +## test_atlas_data_delivery.gd) are unaffected by the move. static func encode_atlas_layer_request( body_id: String, up_to: String = "Topography" ) -> PackedByteArray: - var msg := {"body_id": body_id, "up_to": up_to} - var result = _mp().encode(msg) - if result.status != null: - push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status) - return PackedByteArray() - return result.value + return _amp().encode_atlas_layer_request(_mp(), body_id, up_to) ## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary -## {body_id, status, error, layer1}, or null if the bytes are not an atlas -## response (no "status" key — e.g. an ObserverSnapshot). status is the variant -## name ("Ready"|"Pending"|"NotFound"|"Error"); error holds the message for the -## Error variant. layer1 is the raw decoded Layer1Output map, or null. +## {body_id, status, error, layer1, district_grid, road_graph, settlements, +## region_grid, quarter_footprints}, or null if the bytes are not an atlas +## response (no "status" key — e.g. an ObserverSnapshot). static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant: return atlas_response_from_raw(decode_raw(bytes)) -## 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. -## region_grid (T-1113): the region climate grid, same passthrough pattern. -## Key names "road_graph"/"settlements"/"region_grid" 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. +## Build an AtlasLayerResponse from an already-decoded raw value. See +## atlas_map_protocol.gd for the full field-by-field wire-shape rationale. static func atlas_response_from_raw(raw: Variant) -> Variant: - if not raw is Dictionary or not raw.has("status"): - return null - var status_raw = raw["status"] - var status := "" - var error := "" - if status_raw is String: - status = status_raw - elif status_raw is Dictionary and status_raw.has("Error"): - status = "Error" - error = str(status_raw["Error"]) - return { - "body_id": raw.get("body_id", ""), - "status": status, - "error": error, - "layer1": raw.get("layer1"), - "district_grid": raw.get("district_grid"), - "road_graph": raw.get("road_graph"), - "settlements": raw.get("settlements"), - "region_grid": raw.get("region_grid"), - } - - -## 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": ""} + return _amp().atlas_response_from_raw(raw) ## 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 + return _amp().encode_star_map_request(_mp()) -## 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. +## Build a StarMapResponse from an already-decoded raw value. See +## atlas_map_protocol.gd for the data-unwrapping rationale. 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} + return _amp().star_map_response_from_raw(raw) ## Decode a StarMapResponse from MessagePack bytes. See star_map_response_from_raw. @@ -871,37 +813,15 @@ static func decode_star_map_response(bytes: PackedByteArray) -> Variant: ## 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. +## city-name pool. 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 + return _amp().encode_city_names_request(_mp(), body_id) -## 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). +## Build a CityNamesResponse from an already-decoded raw value. See +## atlas_map_protocol.gd for the SolExcluded/cities-shape rationale. 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", []), - } + return _amp().city_names_response_from_raw(raw) ## Decode a CityNamesResponse from MessagePack bytes. See city_names_response_from_raw. diff --git a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack index b45ad1b02..91f93680c 100644 --- a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack @@ -1 +1 @@ -body_idghoststatusNotFoundlayer1district_gridroad_graphsettlementsregion_grid \ No newline at end of file +body_idghoststatusNotFoundlayer1district_gridroad_graphsettlementsregion_gridquarter_footprints \ 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 2ad9d65e5..0ea79bf10 100644 --- a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack @@ -1 +1 @@ -body_idGJ1cstatusPendinglayer1district_gridroad_graphsettlementsregion_grid \ No newline at end of file +body_idGJ1cstatusPendinglayer1district_gridroad_graphsettlementsregion_gridquarter_footprints \ 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 14c10caed..bd8d0486c 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/test_atlas_overlays.gd b/client/tests/test_atlas_overlays.gd index 045a543f2..0fca3c4eb 100644 --- a/client/tests/test_atlas_overlays.gd +++ b/client/tests/test_atlas_overlays.gd @@ -9,6 +9,10 @@ extends GdUnitTestSuite # resource rather than a global identifier. const LegendPanelScript := preload("res://ui/implant/apps/atlas/atlas_legend_panel.gd") +# T-1118/T-1119 pure color-ramp/shape-selection helpers (no class_name, same +# rationale as LegendPanelScript above) — see atlas_overlay_colors.gd. +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") + func test_generation_overlays_registered() -> void: var ids: Array = [] @@ -141,9 +145,193 @@ func test_settlements_round_trip() -> void: assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements) +# ============================================================================= +# T-1118: gen_region_grid overlay (region climate grid, mean-temp channel) +# ============================================================================= + + +func test_region_grid_overlay_registered() -> void: + var ids: Array = [] + for d: Dictionary in AtlasViewer.OVERLAY_DEFS: + ids.append(d["id"]) + assert_that(ids).contains(["gen_region_grid"]) + + +## RegionGridLayer shape pinned to server/src/atlas/layer_proxy.rs (T-1113): +## dense row-major, mean_temp_dc is deci-degC with REGION_TEMP_NONE_DC +## (i16::MIN) the airless sentinel. +func test_region_grid_round_trips() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + assert_that(v.get_generation_region_grid()).is_null() + var grid := { + "cols": 2, + "rows": 1, + "season": [0, 3], + "weather": [0, 2], + "mean_temp_dc": [123, AtlasOverlayColors.REGION_TEMP_NONE_DC], + "moisture_q": [80, 5], + } + v.set_generation_region_grid(grid) + assert_that(v.get_generation_region_grid()).is_equal(grid) + var decoded: Variant = Protocol.atlas_response_from_raw( + {"body_id": "GJ1c", "status": "Ready", "region_grid": grid} + ) + assert_that((decoded as Dictionary).get("region_grid")).is_equal(grid) + + +## AtlasOverlayColors.region_temp_color() pure ramp — the one visual channel +## this ticket ships (mean temp only; season/weather/moisture deferred). +## Compares components with is_equal_approx() rather than whole-Color +## is_equal(): Godot's Color.lerp(a, b, 1.0) is NOT bit-exact to b (confirmed +## empirically — the two print identically but == is false at the ULP +## level), so an exact Color equality check is the wrong tool at a lerp +## boundary regardless of whether the ramp math itself is correct. +func test_region_temp_color_ramp() -> void: + # Cold end clamps to pure cold color. + _assert_color_approx( + AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MIN_DC), + AtlasOverlayColors.COLOR_REGION_TEMP_COLD + ) + # Hot end clamps to pure hot color. + _assert_color_approx( + AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MAX_DC), + AtlasOverlayColors.COLOR_REGION_TEMP_HOT + ) + # Midpoint (0.0 C) lands on the mid color. + _assert_color_approx( + AtlasOverlayColors.region_temp_color(0), AtlasOverlayColors.COLOR_REGION_TEMP_MID + ) + # Out-of-band readings clamp rather than extrapolate past the endpoints. + _assert_color_approx( + AtlasOverlayColors.region_temp_color(-9999), AtlasOverlayColors.COLOR_REGION_TEMP_COLD + ) + _assert_color_approx( + AtlasOverlayColors.region_temp_color(9999), AtlasOverlayColors.COLOR_REGION_TEMP_HOT + ) + + +func _assert_color_approx(actual: Color, expected: Color) -> void: + assert_float(actual.r).is_equal_approx(expected.r, 0.0001) + assert_float(actual.g).is_equal_approx(expected.g, 0.0001) + assert_float(actual.b).is_equal_approx(expected.b, 0.0001) + assert_float(actual.a).is_equal_approx(expected.a, 0.0001) + + +## The airless sentinel is a SKIP-CELL disposition (documented on +## REGION_TEMP_NONE_DC and _draw_gen_region_grid) — the renderer never calls +## region_temp_color() for it at all, so there is no "sentinel color" to +## assert on. This test instead pins the sentinel's numeric identity, which +## is what _draw_gen_region_grid's equality check depends on. +func test_region_temp_none_sentinel_is_i16_min() -> void: + assert_int(AtlasOverlayColors.REGION_TEMP_NONE_DC).is_equal(-32768) + + +# ============================================================================= +# T-1119: gen_l4_quarters overlay (quarter-footprint glyph, D-226 T-1112 amendment) +# ============================================================================= + + +func test_quarter_footprints_overlay_registered() -> void: + var ids: Array = [] + for d: Dictionary in AtlasViewer.OVERLAY_DEFS: + ids.append(d["id"]) + assert_that(ids).contains(["gen_l4_quarters"]) + + +## QuarterFootprintLayer shape pinned to server/src/atlas/layer_proxy.rs +## (D-226 T-1112 amendment SS1): entries keyed by city_id (BTreeMap on +## the wire, decodes to a Dictionary with int keys), five scalar u8/enum +## fields per entry, no per-block detail (SS2 hard ceiling). +func test_quarter_footprints_round_trips() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + assert_that(v.get_generation_quarter_footprints()).is_null() + var footprints := { + "entries": + { + 1: + { + "city_id": 1, + "density_avg_pct": 62, + "dominant_district_type": "Commercial", + "dominant_zoning": "Commercial", + "landmark_count": 3, + "corridor_count": 4, + }, + 2: + { + "city_id": 2, + "density_avg_pct": 18, + "dominant_district_type": "Residential", + "dominant_zoning": "Residential", + "landmark_count": 0, + "corridor_count": 1, + }, + }, + } + v.set_generation_quarter_footprints(footprints) + assert_that(v.get_generation_quarter_footprints()).is_equal(footprints) + var decoded: Variant = Protocol.atlas_response_from_raw( + {"body_id": "GJ1c", "status": "Ready", "quarter_footprints": footprints} + ) + assert_that((decoded as Dictionary).get("quarter_footprints")).is_equal(footprints) + + +## AtlasOverlayColors.quarter_glyph_size() — density-scaled, clamped, and +## forced to minimum when the zoom gate (show_notch) is off regardless of +## density (D-226 T-1112 amendment SS3: "below [the threshold], draws at +## minimum size with color only"). +func test_quarter_glyph_size() -> void: + # 0% density, notch shown -> base size. + assert_float(AtlasOverlayColors.quarter_glyph_size(0, true)).is_equal_approx( + AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001 + ) + # 100% density, notch shown -> max size (4.0 + 1.0*6.0 = 10.0, under the 12.0 cap). + assert_float(AtlasOverlayColors.quarter_glyph_size(100, true)).is_equal_approx(10.0, 0.001) + # Below the zoom gate: always minimum, regardless of density. + assert_float(AtlasOverlayColors.quarter_glyph_size(100, false)).is_equal_approx( + AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001 + ) + # Fixture literals: city 1 (62%) and city 2 (18%), both notch-shown. + assert_float(AtlasOverlayColors.quarter_glyph_size(62, true)).is_greater( + AtlasOverlayColors.quarter_glyph_size(18, true) + ) + + +## AtlasOverlayColors.quarter_glyph_color() — single-hue ramp within the +## settlement-gold family, endpoints pinned to the D-226 T-1112 amendment +## SS3's literal legend colors. Approx compare (see test_region_temp_color_ramp's +## comment) — a lerp(a, b, 1.0) isn't guaranteed bit-exact to b for every +## color pair, and exact equality shouldn't depend on which pair happens to +## round losslessly. +func test_quarter_glyph_color_ramp() -> void: + _assert_color_approx( + AtlasOverlayColors.quarter_glyph_color(0), AtlasOverlayColors.COLOR_QUARTER_LOW_DENSITY + ) + _assert_color_approx( + AtlasOverlayColors.quarter_glyph_color(100), AtlasOverlayColors.COLOR_QUARTER_HIGH_DENSITY + ) + + +## AtlasOverlayColors.quarter_notch_kind() — the 3-variant-plus-plain cap +## (D-226 T-1112 amendment SS3: "a coarse skeleton read, not a legend of +## every DistrictType"). Every other DistrictType — including an unknown/ +## empty string — falls through to "plain". +func test_quarter_notch_kind_selection() -> void: + assert_str(AtlasOverlayColors.quarter_notch_kind("Commercial")).is_equal("commercial") + assert_str(AtlasOverlayColors.quarter_notch_kind("Industrial")).is_equal("industrial") + assert_str(AtlasOverlayColors.quarter_notch_kind("Administrative")).is_equal("administrative") + for other in ["LogisticsHub", "Residential", "Entertainment", "MixedUse", "Transit", "Specialized"]: + assert_str(AtlasOverlayColors.quarter_notch_kind(other)).override_failure_message( + "DistrictType '%s' should read as plain (not one of the 3 marked variants)" % other + ).is_equal("plain") + assert_str(AtlasOverlayColors.quarter_notch_kind("")).is_equal("plain") + assert_str(AtlasOverlayColors.quarter_notch_kind("SomeUnrecognizedFutureVariant")).is_equal("plain") + + ## 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 — +## generate_atlas_layer_response_fixtures, regenerated 2026-07-18 with T-960's +## road_graph/settlements, T-1118's region_grid, and T-1119's +## quarter_footprints all 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: @@ -180,6 +368,83 @@ func test_atlas_response_ready_fixture_decodes_road_graph_and_settlements() -> v assert_str(minor.get("size_class")).is_equal("Minor") assert_bool(minor.get("is_capital")).is_false() + # T-1118: region_grid, a 2x1 grid (dudley-depth's fixture literal, + # 2026-07-18) — col0 Summer/Clear/12.3C(123 deci-C)/moisture 80, col1 + # Winter/Snow/airless-sentinel/moisture 5. season/weather/mean_temp_dc/ + # moisture_q are all "dense array of ints" fields (rmp_serde Vec/ + # Vec with no serde_bytes, so NOT bin_8/16/32 on the wire) — read + # element-wise via int() rather than asserting a specific Godot container + # type, since the addon decodes a plain msgpack array as Array, not + # PackedByteArray (see _dense_int helper below). + var region_grid: Dictionary = response.get("region_grid") + assert_that(region_grid).is_not_null() + assert_int(int(region_grid.get("cols", 0))).is_equal(2) + assert_int(int(region_grid.get("rows", 0))).is_equal(1) + assert_int(_dense_int(region_grid.get("mean_temp_dc"), 0)).is_equal(123) + assert_int(_dense_int(region_grid.get("mean_temp_dc"), 1)).is_equal( + AtlasOverlayColors.REGION_TEMP_NONE_DC + ) + assert_int(_dense_int(region_grid.get("moisture_q"), 0)).is_equal(80) + assert_int(_dense_int(region_grid.get("moisture_q"), 1)).is_equal(5) + + # T-1119: quarter_footprints, keyed by city_id (BTreeMap on the + # wire -> Dictionary with int keys). city_id 1 (Port Aldren, capital) gets + # a "rich" entry; city_id 2 (Farmstead Rell, minor) gets a deliberately + # sparse one (zero landmarks, one corridor) — both present, per + # dudley-depth's fixture literal, exercising the subset-safety contract + # (entries can legitimately be a SUBSET of settlements — not tested here + # since both happen to be present in this fixture, but the accessor path + # must not assume 1:1). + var quarters: Dictionary = response.get("quarter_footprints") + assert_that(quarters).is_not_null() + var qf_entries: Dictionary = quarters.get("entries", {}) + assert_int(qf_entries.size()).is_equal(2) + var rich: Dictionary = qf_entries[1] + assert_int(int(rich.get("density_avg_pct", -1))).is_equal(62) + assert_str(str(rich.get("dominant_district_type", ""))).is_equal("Commercial") + assert_str(str(rich.get("dominant_zoning", ""))).is_equal("Commercial") + assert_int(int(rich.get("landmark_count", -1))).is_equal(3) + assert_int(int(rich.get("corridor_count", -1))).is_equal(4) + var sparse: Dictionary = qf_entries[2] + assert_int(int(sparse.get("density_avg_pct", -1))).is_equal(18) + assert_str(str(sparse.get("dominant_district_type", ""))).is_equal("Residential") + assert_str(str(sparse.get("dominant_zoning", ""))).is_equal("Residential") + assert_int(int(sparse.get("landmark_count", -1))).is_equal(0) + assert_int(int(sparse.get("corridor_count", -1))).is_equal(1) + + +## pending/not_found fixtures carry region_grid/quarter_footprints as None too +## (mirrors every other Option field's "unrun layer" treatment) — a quick +## sanity check that the new fields don't silently break the OTHER two +## fixtures' decode (they were regenerated in the same batch). +func test_atlas_response_pending_and_not_found_fixtures_have_no_new_layers() -> void: + for fixture_name in ["atlas_response_pending", "atlas_response_not_found"]: + var path := "res://tests/fixtures/msgpack/%s.msgpack" % fixture_name + var f := FileAccess.open(path, FileAccess.READ) + assert_that(f).override_failure_message("missing fixture %s" % fixture_name).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_that(response.get("region_grid")).override_failure_message( + "%s should carry no region_grid" % fixture_name + ).is_null() + assert_that(response.get("quarter_footprints")).override_failure_message( + "%s should carry no quarter_footprints" % fixture_name + ).is_null() + + +## Reads element `i` from a decoded "dense numeric array" field regardless of +## whether the messagepack addon produced a PackedByteArray (bin_8/16/32) or a +## plain Array (fixarray/array_16/array_32) — rmp_serde without serde_bytes +## encodes Vec/Vec as the latter, so this is the defensively-correct +## read for region_grid's season/weather/mean_temp_dc/moisture_q. +static func _dense_int(arr: Variant, i: int) -> int: + if arr is Array or arr is PackedByteArray: + return int(arr[i]) + return 0 + # ============================================================================= # D-226 item 3: generation legend panel diff --git a/client/ui/implant/apps/atlas/atlas_generation_proxy.gd b/client/ui/implant/apps/atlas/atlas_generation_proxy.gd new file mode 100644 index 000000000..98d375e1d --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_generation_proxy.gd @@ -0,0 +1,126 @@ +extends Node + +## Generation-cascade layer-stream proxy client for AtlasViewer (#960, D-225). +## Extracted from atlas_viewer.gd (T-1118) to keep that file under the gdlint +## max-file-lines cap — this is the whole "poll the proxy, retry on Pending, +## show the diegetic GENERATING indicator, dispatch each layer field to +## AtlasGenerationState on Ready" responsibility, self-contained apart from +## reading the owning viewer's `_body`/`size` and calling its +## set_generation_*() accessors. +## +## This script has no `class_name` on purpose, matching atlas_overlay_bar.gd/ +## atlas_legend_panel.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. `extends Node` (not RefCounted) because +## this owns a child Control (the pending indicator) and needs get_tree() for +## the retry timer — added as a child via +## load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self). + +# #960: Layer-1 proxy re-poll. The proxy returns Pending on a cache miss and +# generates in the background (D-225); the client re-requests until Ready. +const GEN_RETRY_DELAY: float = 0.5 +const GEN_MAX_RETRIES: int = 20 # ~10s ceiling before giving up + +var _viewer = null # AtlasViewer (untyped to avoid cyclic ref) +var _pending: bool = false # awaiting a Layer1 response (re-polls on Pending) +var _retries: int = 0 +var _indicator = null # ImplantPending — loaded by path, not a class_name dep + + +func _init(viewer_ref = null) -> void: + _viewer = viewer_ref + + +func _ready() -> void: + if _viewer == null: + return + # 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 — can't fail to parse AtlasViewer and break the atlas from opening + # (#960). + _indicator = load("res://ui/implant/implant_pending.gd").new() + _indicator.name = "GenPending" + _viewer.add_child(_indicator) + _indicator.apply_implant_theme(_viewer.get_implant_theme()) + _viewer.resized.connect(reposition_indicator) + reposition_indicator() + + +## Reset polling state for a fresh show_body() call — the caller is +## responsible for also resetting the layer data itself +## (AtlasGenerationState.reset_layer1()). +func reset() -> void: + _pending = false + _retries = 0 + if _indicator: + _indicator.stop() + + +## 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. +func request(body_id: String) -> void: + if body_id.is_empty(): + return + _pending = true + SimBridge.request_atlas_layers(body_id) + + +## Handle a Layer-1 response. Ignores responses for a stale body (the user +## navigated away). On Pending the proxy is still generating, so we re-request +## after a short delay until Ready or the retry ceiling. +func on_response(response: Dictionary, current_body_id: String) -> void: + if str(response.get("body_id", "")) != current_body_id: + return + match str(response.get("status", "")): + "Ready": + _pending = false + _retries = 0 + _set_indicator(false) + _viewer.set_generation_layer1(response.get("layer1")) + _viewer.set_generation_district_grid(response.get("district_grid")) + _viewer.set_generation_road_graph(response.get("road_graph")) + _viewer.set_generation_settlements(response.get("settlements")) + _viewer.set_generation_region_grid(response.get("region_grid")) + _viewer.set_generation_quarter_footprints(response.get("quarter_footprints")) + "Pending": + if _retries < GEN_MAX_RETRIES: + _retries += 1 + _set_indicator(true) + _schedule_retry(current_body_id) + else: + _pending = false # gave up — overlays stay empty + _set_indicator(false) + _: + _pending = false # NotFound / Error — nothing to draw + _set_indicator(false) + + +func _schedule_retry(body_id: String) -> void: + var timer := get_tree().create_timer(GEN_RETRY_DELAY) + timer.timeout.connect( + func() -> void: + # Re-request only if still on the same body and still waiting. + if _pending and _viewer.get_body_id() == body_id: + SimBridge.request_atlas_layers(body_id) + ) + + +## Show/hide the diegetic pending indicator. Starting only when not already +## visible avoids resetting the sweep on every re-poll. +func _set_indicator(on: bool) -> void: + if _indicator == null: + return + if on: + if not _indicator.visible: + reposition_indicator() + _indicator.start("GENERATING LAYER 1") + else: + _indicator.stop() + + +func reposition_indicator() -> void: + if _indicator == null or _viewer == null: + return + var sz: Vector2 = _viewer.size + _indicator.position = Vector2((sz.x - _indicator.DEFAULT_WIDTH) * 0.5, sz.y * 0.45) diff --git a/client/ui/implant/apps/atlas/atlas_generation_state.gd b/client/ui/implant/apps/atlas/atlas_generation_state.gd new file mode 100644 index 000000000..ca85dc939 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_generation_state.gd @@ -0,0 +1,105 @@ +extends RefCounted +## Generation-cascade layer state for AtlasViewer (#960, D-225). +## +## Extracted from atlas_viewer.gd (T-1118/T-1119) to keep that file under the +## gdlint max-file-lines cap while it keeps growing a new field+accessor pair +## per generation layer (layer1, district_grid, road_graph, settlements, +## region_grid, quarter_footprints — the AtlasLayerResponse growth-ceiling +## note in the D-226 T-1112 amendment names region_grid/quarter_footprints as +## the last two candidate sibling Option fields). One `_generation_*` field + +## one get/set pair per layer, all following the same "store + redraw the +## overlay" shape — collecting them here means that shape is written once +## (in _set) instead of once per accessor pair. +## +## AtlasViewer holds one instance (`_gen_state`) and exposes the SAME public +## get_generation_*()/set_generation_*() method names it always has — +## external callers (atlas_marker_overlay.gd, test_atlas_overlays.gd) are +## unaffected; only the storage moved. Consumed via explicit load() by path +## (no class_name), matching atlas_legend_panel.gd/atlas_overlay_bar.gd +## (review #8 there): AtlasViewer is itself referenced by class_name in +## other scripts, and a second global class_name in this cluster is an +## unnecessary addition to the global class cache. + +var _overlay_node: Node2D = null # set once by AtlasViewer._ready() (the redraw target) + +var _layer1: Variant = null # #960: Layer1Output from the proxy (D-225) +var _district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226) +var _road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225) +var _settlements: Variant = null # T-960: settlement placements from the proxy (D-225) +var _region_grid: Variant = null # T-1118: RegionGridLayer climate grid (D-226/T-1113) +var _quarter_footprints: Variant = null # T-1119: QuarterFootprintLayer (D-226 T-1112 amendment) + + +func bind_overlay_node(overlay_node: Node2D) -> void: + _overlay_node = overlay_node + + +func _redraw() -> void: + if _overlay_node: + _overlay_node.queue_redraw() + + +## Reset every layer to its pre-body-load default. Called from +## AtlasViewer.show_body() so a body switch doesn't inherit the previous +## body's generation data. +func reset_layer1() -> void: + _layer1 = null + + +func set_layer1(layer1: Variant) -> void: + _layer1 = layer1 + _redraw() + + +func get_layer1() -> Variant: + return _layer1 + + +func set_district_grid(grid: Variant) -> void: + _district_grid = grid + _redraw() + + +func get_district_grid() -> Variant: + return _district_grid + + +func set_road_graph(graph: Variant) -> void: + _road_graph = graph + _redraw() + + +func get_road_graph() -> Variant: + return _road_graph + + +func set_settlements(settlements: Variant) -> void: + _settlements = settlements + _redraw() + + +func get_settlements() -> Variant: + return _settlements + + +## T-1118: store the region climate grid (RegionGridLayer) from the proxy and +## redraw. The marker overlay reads it via AtlasViewer.get_generation_region_grid(). +func set_region_grid(grid: Variant) -> void: + _region_grid = grid + _redraw() + + +func get_region_grid() -> Variant: + return _region_grid + + +## T-1119: store the L4 quarter-footprint aggregates (QuarterFootprintLayer) +## from the proxy and redraw. The marker overlay reads it via +## AtlasViewer.get_generation_quarter_footprints(). +func set_quarter_footprints(footprints: Variant) -> void: + _quarter_footprints = footprints + _redraw() + + +func get_quarter_footprints() -> Variant: + return _quarter_footprints diff --git a/client/ui/implant/apps/atlas/atlas_legend_panel.gd b/client/ui/implant/apps/atlas/atlas_legend_panel.gd index 606049044..51976d7ed 100644 --- a/client/ui/implant/apps/atlas/atlas_legend_panel.gd +++ b/client/ui/implant/apps/atlas/atlas_legend_panel.gd @@ -34,6 +34,15 @@ 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) +# T-1118 — region climate grid cold/hot ramp endpoints (matches +# atlas_overlay_colors.gd's COLOR_REGION_TEMP_COLD/COLOR_REGION_TEMP_HOT). +const COLOR_REGION_TEMP_COLD_GEN: Color = Color(0.25, 0.45, 0.85, 0.60) +const COLOR_REGION_TEMP_HOT_GEN: Color = Color(0.90, 0.25, 0.20, 0.60) +# T-1119 — quarter-footprint density ramp endpoints, D-226 T-1112 amendment +# SS3's literal legend spec (matches atlas_overlay_colors.gd's +# COLOR_QUARTER_LOW_DENSITY/COLOR_QUARTER_HIGH_DENSITY). +const COLOR_QUARTER_LOW_DENSITY_GEN: Color = Color(0.55, 0.48, 0.30, 0.6) +const COLOR_QUARTER_HIGH_DENSITY_GEN: Color = Color(0.94, 0.82, 0.38, 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 @@ -79,6 +88,15 @@ const GENERATION_LEGEND: Array = [ {"glyph": "●", "color": Color(0.55, 0.68, 0.82, 0.90), "label": "cold"}, ], }, + { + "overlay_id": "gen_region_grid", + "title": "REGION CLIMATE — mean temperature (~205 km cells)", + "rows": [ + {"glyph": "▦", "color": COLOR_REGION_TEMP_COLD_GEN, "label": "cold"}, + {"glyph": "▦", "color": COLOR_REGION_TEMP_HOT_GEN, "label": "hot"}, + {"glyph": "▦", "color": Color.TRANSPARENT, "label": "airless — no reading (skipped)"}, + ], + }, { "overlay_id": "gen_district", "title": "DISTRICT MORPHOLOGY — coarse", @@ -114,6 +132,17 @@ const GENERATION_LEGEND: Array = [ {"glyph": "★", "color": COLOR_SETTLEMENT_CAPITAL_GEN, "label": "capital / major hub"}, ], }, + { + "overlay_id": "gen_l4_quarters", + "title": "QUARTER FOOTPRINT — L4 (color = density, shape = dominant type)", + "rows": [ + {"glyph": "▪", "color": COLOR_QUARTER_LOW_DENSITY_GEN, "label": "low density"}, + {"glyph": "▪", "color": COLOR_QUARTER_HIGH_DENSITY_GEN, "label": "high density"}, + {"glyph": "◪", "color": Color.TRANSPARENT, "label": "commercial (corner tab, top-right)"}, + {"glyph": "◩", "color": Color.TRANSPARENT, "label": "industrial (corner tab, bottom-right)"}, + {"glyph": "◈", "color": Color.TRANSPARENT, "label": "administrative (diamond cutout)"}, + ], + }, ] var _viewer = null # AtlasViewer (untyped to avoid cyclic ref) diff --git a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd index f12011a6f..52e1f1635 100644 --- a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd @@ -43,44 +43,15 @@ 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 -] +## Pure color-ramp/shape-selection helpers (morphology, sub-biome, road +## authority, region temp, quarter density/notch) — factored into +## atlas_overlay_colors.gd (T-1118) to stay under gdlint's max-file-lines, +## same rationale as atlas_format.gd's static-function + preload pattern. +## That file owns the actual constant tables; this file only keeps constants +## with no decision function wrapping them (e.g. COLOR_SETTLEMENT — used +## directly, not looked up). +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") +const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC # Province boundaries (D-205, #927) const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55) @@ -92,11 +63,6 @@ const PROVINCE_BORDER_WIDTH: float = 1.2 # 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) @@ -180,6 +146,15 @@ func _draw() -> void: if viewer.is_overlay_visible("corp_presence"): _draw_corp_presence(markers) + # Generation: region climate grid (T-1118, D-243) — the coarsest area fill + # (~205 km cells), drawn first among generation overlays so the district + # morphology grid and every finer layer below reads on top of it as + # background climate context. + if viewer.is_overlay_visible("gen_region_grid"): + var region_grid: Variant = viewer.get_generation_region_grid() + if region_grid is Dictionary: + _draw_gen_region_grid(region_grid, tex_w, tex_h) + # Generation: district morphology grid (T-1046, D-226) — coarse area fill, # drawn under the Layer-1 line/point overlays. if viewer.is_overlay_visible("gen_district"): @@ -215,6 +190,15 @@ func _draw() -> void: var settlements: Variant = viewer.get_generation_settlements() if settlements != null: _draw_gen_settlements(settlements) + # T-1119: drawn immediately after gen_l3_settlements so it reads + # as "on top of" the city dot it annotates (D-226 T-1112 SS3), + # and needs the SAME settlements payload for the city_id-> + # position join (quarter footprints carry no position of + # their own). + if viewer.is_overlay_visible("gen_l4_quarters"): + var quarters: Variant = viewer.get_generation_quarter_footprints() + if quarters is Dictionary: + _draw_gen_quarter_footprints(quarters, settlements) # POIs (non-gate first, then gates on top if enabled) _draw_pois(markers) @@ -491,13 +475,49 @@ func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void: if i >= n: continue # +0.5 overdraw avoids hairline seams between adjacent cells. - draw_rect(Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5), _morphology_color(int(morphology[i]))) + draw_rect( + Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5), + AtlasOverlayColors.morphology_color(int(morphology[i])) + ) -func _morphology_color(zone: int) -> Color: - if zone >= 0 and zone < MORPHOLOGY_COLORS.size(): - return MORPHOLOGY_COLORS[zone] - return Color(0.5, 0.5, 0.5, 0.4) +## Region climate grid (T-1118, D-243 SS"region"). A coarse `cols x rows` area +## fill mirroring _draw_gen_district's SELF-CONTAINED coordinate mapping above +## (dims come from the layer dict itself, cw/ch = tex/cols|rows) — this is a +## dense row-major grid keyed by its OWN cols/rows, NOT a Layer-1 point +## position, so it does NOT use _gen_pos()/_gen_grid_w (that path is for +## Layer-1 river/basin/attractor points and the L2/L3 graphs, which share one +## working-grid coordinate space; the region grid is its own dense array with +## its own extent, exactly like district_grid). +## +## Channel: mean_temp_dc only (season/weather/moisture deferred to a future +## tooltip/variant overlay per the ticket scope — D-226 "one visual channel +## first"). Airless-body cells (REGION_TEMP_NONE_DC sentinel) are skipped +## entirely, not colored — see the REGION_TEMP_NONE_DC comment above. +func _draw_gen_region_grid(grid: Dictionary, tex_w: float, tex_h: float) -> void: + var cols: int = int(grid.get("cols", 0)) + var rows: int = int(grid.get("rows", 0)) + if cols <= 0 or rows <= 0: + return + var mean_temp_dc: Variant = grid.get("mean_temp_dc") + if not (mean_temp_dc is Array or mean_temp_dc is PackedByteArray): + return + var cw: float = tex_w / float(cols) + var ch: float = tex_h / float(rows) + var n: int = mean_temp_dc.size() + for ry in range(rows): + for rx in range(cols): + var i: int = ry * cols + rx + if i >= n: + continue + var temp_dc: int = int(mean_temp_dc[i]) + if temp_dc == REGION_TEMP_NONE_DC: + continue # airless — no temperature to show + # +0.5 overdraw avoids hairline seams between adjacent cells. + draw_rect( + Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5), + AtlasOverlayColors.region_temp_color(temp_dc) + ) func _draw_gen_rivers(layer1: Dictionary) -> void: @@ -548,18 +568,10 @@ func _draw_gen_attractors(layer1: Dictionary) -> void: if not pos_rc is Array or pos_rc.size() < 2: continue var size: float = 5.0 + strength * 4.0 - var color: Color = _sub_biome_color(str(a.get("sub_biome", ""))) + var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", ""))) _draw_attractor_shape(str(a.get("attractor_type", "")), _gen_pos(pos_rc), size, color) -## Sub-biome → marker color (Araminta's palette). Color is additive info; shape -## 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: - return SUB_BIOME_COLORS.get(sub_biome, COLOR_SUB_BIOME_DEFAULT) - - ## Attractor type → marker shape (Araminta's vocabulary, 7 types). func _draw_attractor_shape(atype: String, pos: Vector2, size: float, color: Color) -> void: match atype: @@ -599,25 +611,6 @@ func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) - # ============================================================================= -## 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 @@ -645,7 +638,7 @@ func _draw_gen_roads(road_graph: Dictionary) -> void: points.append(_gen_pos(pt)) if points.size() < 2: continue - var color: Color = _road_authority_color(str(e.get("maintenance", ""))) + var color: Color = AtlasOverlayColors.road_authority_color(str(e.get("maintenance", ""))) if bool(e.get("is_rail", false)): _draw_dashed_polyline(points, color, 1.1) else: @@ -742,6 +735,92 @@ func _draw_star(pos: Vector2, size: float, color: Color) -> void: draw_colored_polygon(pts, color) +# ============================================================================= +# Generation-cascade overlays — L4 quarter footprints (T-1119, D-226 T-1112 amendment) +# ============================================================================= + + +## Quarter-footprint aggregates (QuarterFootprintLayer — D-226 T-1112 amendment +## SS1/SS4), one density-scaled glyph per city_id anchored at the SAME position +## the L3 settlement dot already drew (`_gen_pos` on the settlement's +## `position` — quarter footprints carry no independent spatial position per +## the amendment, only a `city_id` join key). `entries` is keyed by city_id +## (BTreeMap on the wire, decodes to a Dictionary with int keys) and is +## a SUBSET of settlements — a placement can exist with no quarter entry yet +## (async per-city generation), so a missing city_id is silently skipped, not +## an error. `landmark_count`/`corridor_count` are tooltip-only per the D-226 +## SS2 hard ceiling — never drawn here (city-click sidebar addition, out of +## this overlay's scope). Draw call MUST come from the same `settlements` +## payload gen_l3_settlements just drew, so this needs the raw settlements +## data threaded in from _draw() rather than re-fetching it here. +func _draw_gen_quarter_footprints(footprints: Variant, settlements: Variant) -> void: + var entries: Dictionary = {} + if footprints is Dictionary: + entries = footprints.get("entries", {}) + if entries.is_empty(): + return + + var settlement_entries: Array = [] + if settlements is Array: + settlement_entries = settlements + elif settlements is Dictionary: + settlement_entries = settlements.get("settlements", []) + if settlement_entries.is_empty(): + return + + var show_notch: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM + for s: Variant in settlement_entries: + if not s is Dictionary: + continue + var city_id: int = int(s.get("city_id", -1)) + if city_id < 0 or not entries.has(city_id): + continue + var entry: Dictionary = entries[city_id] + var pos_rc: Variant = s.get("position") + if not pos_rc is Array or pos_rc.size() < 2: + continue + _draw_quarter_glyph(_gen_pos(pos_rc), entry, show_notch) + + +## One density-scaled square glyph. Below SETTLEMENT_LABEL_MIN_ZOOM, draws at +## minimum size with color only (the notch is illegible at a few px anyway, +## per the amendment); at/above it, full size with the dominant-type notch. +## Size/color/shape decisions live in AtlasOverlayColors (unit tested there +## directly via test_atlas_overlays.gd) — this function is just the +## draw_rect/notch calls. +func _draw_quarter_glyph(pos: Vector2, entry: Dictionary, show_notch: bool) -> void: + var density_pct: int = int(entry.get("density_avg_pct", 0)) + var side: float = AtlasOverlayColors.quarter_glyph_size(density_pct, show_notch) + var color: Color = AtlasOverlayColors.quarter_glyph_color(density_pct) + var half: Vector2 = Vector2(side, side) * 0.5 + draw_rect(Rect2(pos - half, Vector2(side, side)), color) + if not show_notch: + return + var district_type: String = str(entry.get("dominant_district_type", "")) + _draw_quarter_notch(pos, half, AtlasOverlayColors.quarter_notch_kind(district_type)) + + +## Draws the notch glyph for a resolved AtlasOverlayColors.quarter_notch_kind() result. +func _draw_quarter_notch(pos: Vector2, half: Vector2, kind: String) -> void: + var notch_color := Color(0.08, 0.08, 0.08, 0.55) + match kind: + "commercial": + # Corner tab, top-right. + var tab: float = half.x * 0.7 + draw_rect(Rect2(pos + Vector2(half.x - tab, -half.y), Vector2(tab, tab)), notch_color) + "industrial": + # Corner tab, bottom-right. + var tab: float = half.x * 0.7 + draw_rect( + Rect2(pos + Vector2(half.x - tab, half.y - tab), Vector2(tab, tab)), notch_color + ) + "administrative": + # Small diamond cutout, center (civic/landmark read). + _draw_diamond(pos, half.x * 0.45, notch_color) + _: + pass # plain square — mixed / no clear dominant + + 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_overlay_colors.gd b/client/ui/implant/apps/atlas/atlas_overlay_colors.gd new file mode 100644 index 000000000..fe0e61cb3 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_overlay_colors.gd @@ -0,0 +1,164 @@ +extends RefCounted +## Pure color-ramp / shape-selection helpers for the Atlas generation overlays +## (T-1118) — factored out of atlas_marker_overlay.gd to keep that file under +## gdlint's max-file-lines cap. draw_rect()/draw_circle()/etc are CanvasItem +## instance methods called implicitly on `self`, so the actual draw calls +## can't move here — only the pure lookups that decide WHAT color/shape to +## draw, matching atlas_format.gd's static-function + preload pattern: +## const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") +## +## Every constant/function here is duplicated FROM atlas_marker_overlay.gd +## (not the other way — that file still owns the same constants for its own +## draw calls and the legend's own copies, matching the existing +## COLOR_ROAD_*/COLOR_SETTLEMENT_* "each file owns its own reading of the +## palette" precedent, atlas_legend_panel.gd's own header comment). Consumers +## needing the DECISION (not just the constant) call the static functions +## below instead of re-deriving the same match/lerp logic a third time. +## +## gdlint's class-definitions-order requires all consts before all funcs at +## file scope (no interleaving) — hence every table up top, decisions below. + +## MorphologyZone discriminant -> overlay colour (D-239 SS6 order, T-1046). +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 +] + +## Sub-biome -> marker color (Araminta's palette, D-226). Grouped pairs share +## a color since they read the same on the map. +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 + +## T-960 L2 — MaintenanceAuthority (D-211/D-212) -> line color. +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) + +## T-1118 — region climate grid (D-243 SS"region", D-226). Cold->hot ramp over +## the realistic surface mean-annual band (~ -50C to +50C); REGION_TEMP_NONE_DC +## (i16::MIN, server/src/atlas/layer_proxy.rs) is the airless-body sentinel — +## the caller (atlas_marker_overlay.gd's _draw_gen_region_grid) skips those +## cells entirely rather than calling this function for them. +const REGION_TEMP_MIN_DC: int = -500 # -50.0 C, deci-degC +const REGION_TEMP_MAX_DC: int = 500 # +50.0 C, deci-degC +const REGION_TEMP_NONE_DC: int = -32768 # i16::MIN sentinel (airless — skip cell) +const COLOR_REGION_TEMP_COLD: Color = Color(0.25, 0.45, 0.85, 0.60) # blue +const COLOR_REGION_TEMP_MID: Color = Color(0.85, 0.85, 0.55, 0.55) # pale warm +const COLOR_REGION_TEMP_HOT: Color = Color(0.90, 0.25, 0.20, 0.60) # red + +## T-1119 — L4 quarter-footprint glyph (D-226 T-1112 amendment SS3). Square +## side [QUARTER_GLYPH_MIN_SIZE, QUARTER_GLYPH_MAX_SIZE] scales off +## density_avg_pct; color is a single-hue intensity ramp WITHIN the +## settlement-gold family so the layer reads as part of the settlement-marker +## family, not a competing hue. +const QUARTER_GLYPH_MIN_SIZE: float = 4.0 +const QUARTER_GLYPH_MAX_SIZE: float = 12.0 +const QUARTER_GLYPH_BASE_SIZE: float = 4.0 +const QUARTER_GLYPH_DENSITY_SCALE: float = 6.0 +const COLOR_QUARTER_LOW_DENSITY: Color = Color(0.55, 0.48, 0.30, 0.6) # dim gold +const COLOR_QUARTER_HIGH_DENSITY: Color = Color(0.94, 0.82, 0.38, 1.0) # COLOR_SETTLEMENT gold + + +static func morphology_color(zone: int) -> Color: + if zone >= 0 and zone < MORPHOLOGY_COLORS.size(): + return MORPHOLOGY_COLORS[zone] + return Color(0.5, 0.5, 0.5, 0.4) + + +static func sub_biome_color(sub_biome: String) -> Color: + return SUB_BIOME_COLORS.get(sub_biome, COLOR_SUB_BIOME_DEFAULT) + + +static 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 + + +## Cold->hot ramp over [REGION_TEMP_MIN_DC, REGION_TEMP_MAX_DC], clamped at +## the ends (a real reading outside the band still renders, just capped to +## the extreme color rather than clipping to nothing). +static func region_temp_color(temp_dc: int) -> Color: + var clamped: int = clampi(temp_dc, REGION_TEMP_MIN_DC, REGION_TEMP_MAX_DC) + var t: float = ( + float(clamped - REGION_TEMP_MIN_DC) / float(REGION_TEMP_MAX_DC - REGION_TEMP_MIN_DC) + ) + if t <= 0.5: + return COLOR_REGION_TEMP_COLD.lerp(COLOR_REGION_TEMP_MID, t / 0.5) + return COLOR_REGION_TEMP_MID.lerp(COLOR_REGION_TEMP_HOT, (t - 0.5) / 0.5) + + +## Density-scaled side length, clamped to [QUARTER_GLYPH_MIN_SIZE, +## QUARTER_GLYPH_MAX_SIZE]. Below SETTLEMENT_LABEL_MIN_ZOOM the glyph always +## draws at minimum size (color-only read, per the amendment) regardless of +## density — `show_notch` is the zoom gate, not a density input. +static func quarter_glyph_size(density_avg_pct: int, show_notch: bool) -> float: + if not show_notch: + return QUARTER_GLYPH_MIN_SIZE + var density_frac: float = clampf(float(density_avg_pct) / 100.0, 0.0, 1.0) + return clampf( + QUARTER_GLYPH_BASE_SIZE + density_frac * QUARTER_GLYPH_DENSITY_SCALE, + QUARTER_GLYPH_MIN_SIZE, + QUARTER_GLYPH_MAX_SIZE + ) + + +## Single-hue intensity ramp within the settlement-gold family (D-226 T-1112 +## amendment SS3's literal legend colors). +static func quarter_glyph_color(density_avg_pct: int) -> Color: + var density_frac: float = clampf(float(density_avg_pct) / 100.0, 0.0, 1.0) + return COLOR_QUARTER_LOW_DENSITY.lerp(COLOR_QUARTER_HIGH_DENSITY, density_frac) + + +## Shape = dominant_district_type, capped at 3 marked variants + plain (D-226 +## SS3: "a coarse skeleton read, not a legend of every DistrictType"). Only +## Commercial/Industrial/Administrative get a distinct mark; every other +## DistrictType (LogisticsHub, Residential, Entertainment, MixedUse, Transit, +## Specialized) — including an unrecognized/empty string — reads as "plain" +## (mixed / no clear dominant), matching the legend. Returns a lowercase kind +## string ("commercial"|"industrial"|"administrative"|"plain") the caller +## switches on to pick the actual draw call. +static func quarter_notch_kind(district_type: String) -> String: + match district_type: + "Commercial", "Industrial", "Administrative": + return district_type.to_lower() + _: + return "plain" diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd index ad047d4c4..20eff46b5 100644 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -31,11 +31,6 @@ const MIN_ZOOM: float = 0.5 const MAX_ZOOM: float = 8.0 const ZOOM_STEP: float = 1.15 -# #960: Layer-1 proxy re-poll. The proxy returns Pending on a cache miss and -# generates in the background (D-225); the client re-requests until Ready. -const GEN_RETRY_DELAY: float = 0.5 -const GEN_MAX_RETRIES: int = 20 # ~10s ceiling before giving up - const PANEL_WIDTH: float = 320.0 const PANEL_MARGIN: float = 16.0 @@ -164,6 +159,18 @@ const OVERLAY_DEFS: Array = [ "group": "toggle", "tooltip": "Layer 3 — settlement placements, sized by population (generation overlay, T-960)." }, + { + "id": "gen_region_grid", + "label": "TMP", + "group": "toggle", + "tooltip": "Region climate grid — mean temperature (generation overlay, T-1118/D-243)." + }, + { + "id": "gen_l4_quarters", + "label": "QTR", + "group": "toggle", + "tooltip": "Layer 4 — quarter footprint, color=density shape=type (generation overlay, T-1119)." + }, ] # ── Context (set by show_body) ───────────────────────────────────────────────── @@ -174,12 +181,25 @@ var _implant_theme = null # ── Heightmap + markers ─────────────────────────────────────────────────────── 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 +# Generation-cascade state (#960, D-225) is split into two companion objects, +# both extracted from this file (T-1118) to keep it under the gdlint +# max-file-lines cap: AtlasGenerationState owns the per-layer data +# (get/set_generation_*() accessors below delegate to it) and AtlasGenerationProxy +# owns the proxy-polling/retry/pending-indicator machinery. +# +# _gen_state is built as a FIELD DEFAULT (not in _ready()) — it is a pure +# RefCounted with no tree dependency, and get/set_generation_*() must work on +# a bare `AtlasViewer.new()` that never enters the scene tree (the +# established test pattern throughout this suite — auto_free(AtlasViewer.new()) +# with no add_child(), so _ready() never fires). _overlay_node isn't bound +# until _ready() (bind_overlay_node() below), so a redraw is a safe no-op +# until then — see atlas_generation_state.gd's _redraw() null-guard. +# +# _gen_proxy is a Node (needs get_tree() for its retry timer, owns the +# pending-indicator child Control) and is built in _ready() like every other +# child node on this class — nothing calls into it before the tree exists. +var _gen_state = load("res://ui/implant/apps/atlas/atlas_generation_state.gd").new() +var _gen_proxy = null var _grid_w: float = 512.0 var _grid_h: float = 256.0 var _tex_w: float = 1024.0 @@ -217,7 +237,6 @@ var _city_panel = null # ImplantPanel sidebar (city data) 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 @@ -247,6 +266,17 @@ func _ready() -> void: _overlay_node.viewer = self _canvas.add_child(_overlay_node) + # _gen_state itself was already constructed as a field default (see the + # comment there) — bind its redraw target now that _overlay_node exists. + _gen_state.bind_overlay_node(_overlay_node) + + # #960: proxy-polling + diegetic "generating" indicator (D-225). Added as a + # child Node (not RefCounted) so it can own the indicator Control and use + # get_tree() for the retry timer; see atlas_generation_proxy.gd. + _gen_proxy = load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self) + _gen_proxy.name = "GenerationProxy" + add_child(_gen_proxy) + _build_screen_header() _build_city_panel() _build_empty_notice() @@ -261,17 +291,6 @@ func _ready() -> void: 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 — - # can't fail to parse AtlasViewer and break the atlas from opening (#960). - _gen_pending_indicator = load("res://ui/implant/implant_pending.gd").new() - _gen_pending_indicator.name = "GenPending" - add_child(_gen_pending_indicator) - _gen_pending_indicator.apply_implant_theme(_implant_theme) - resized.connect(_position_pending_indicator) - _position_pending_indicator() - func _exit_tree() -> void: if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): @@ -294,12 +313,9 @@ func show_body(body: Dictionary, system: Dictionary) -> void: _refresh_screen_header() _city_panel.visible = false _empty_notice.visible = (_heightmap_texture == null) - _generation_layer1 = null - _gen_pending = false - _gen_retries = 0 - if _gen_pending_indicator: - _gen_pending_indicator.stop() - _request_generation_layers() + _gen_state.reset_layer1() + _gen_proxy.reset() + _gen_proxy.request(_dict_str(_body, "body_id", "")) grab_focus() queue_redraw() _overlay_node.queue_redraw() @@ -361,123 +377,75 @@ func set_view(zoom: float, offset: Vector2) -> void: _apply_transform() -## #960: store the Layer-1 generation output (from atlas_layers_received) and -## redraw the overlay. The marker overlay reads it via get_generation_layer1(). +## Per-layer generation accessors (#960, D-225; region_grid T-1118, quarter_footprints +## T-1119) — thin delegates onto AtlasGenerationState (see atlas_generation_state.gd +## for what each layer is and its store-then-redraw contract). Same public method +## names as before the T-1118 extraction; callers (atlas_marker_overlay.gd, +## test_atlas_overlays.gd) are unaffected. func set_generation_layer1(layer1: Variant) -> void: - _generation_layer1 = layer1 - if _overlay_node: - _overlay_node.queue_redraw() + _gen_state.set_layer1(layer1) func get_generation_layer1() -> Variant: - return _generation_layer1 + return _gen_state.get_layer1() -## T-1046: store the coarse district/morphology grid (DistrictGridLayer) from the -## proxy and redraw. The marker overlay reads it via get_generation_district_grid(). func set_generation_district_grid(grid: Variant) -> void: - _generation_district_grid = grid - if _overlay_node: - _overlay_node.queue_redraw() + _gen_state.set_district_grid(grid) func get_generation_district_grid() -> Variant: - return _generation_district_grid + return _gen_state.get_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() + _gen_state.set_road_graph(graph) func get_generation_road_graph() -> Variant: - return _generation_road_graph + return _gen_state.get_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() + _gen_state.set_settlements(settlements) func get_generation_settlements() -> Variant: - return _generation_settlements + return _gen_state.get_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. -func _request_generation_layers() -> void: - var body_id: String = _dict_str(_body, "body_id", "") - if body_id.is_empty(): - return - _gen_pending = true - SimBridge.request_atlas_layers(body_id) +func set_generation_region_grid(grid: Variant) -> void: + _gen_state.set_region_grid(grid) -## #960: handle a Layer-1 response. Ignores responses for a stale body (the -## user navigated away). On Pending the proxy is still generating, so we -## re-request after a short delay until Ready or the retry ceiling. +func get_generation_region_grid() -> Variant: + return _gen_state.get_region_grid() + + +func set_generation_quarter_footprints(footprints: Variant) -> void: + _gen_state.set_quarter_footprints(footprints) + + +func get_generation_quarter_footprints() -> Variant: + return _gen_state.get_quarter_footprints() + + +## #960: forward a Layer-1 response to AtlasGenerationProxy, which handles +## staleness, retry, indicator, and dispatch to AtlasGenerationState (T-1118 +## extraction — see atlas_generation_proxy.gd). func _on_atlas_layers_received(response: Dictionary) -> void: - var body_id: String = _dict_str(_body, "body_id", "") - if str(response.get("body_id", "")) != body_id: - return - match str(response.get("status", "")): - "Ready": - _gen_pending = false - _gen_retries = 0 - _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 - _set_gen_indicator(true) - _schedule_gen_retry(body_id) - else: - _gen_pending = false # gave up — overlays stay empty - _set_gen_indicator(false) - _: - _gen_pending = false # NotFound / Error — nothing to draw - _set_gen_indicator(false) + _gen_proxy.on_response(response, _dict_str(_body, "body_id", "")) -func _schedule_gen_retry(body_id: String) -> void: - var timer := get_tree().create_timer(GEN_RETRY_DELAY) - timer.timeout.connect( - func() -> void: - # Re-request only if still on the same body and still waiting. - if _gen_pending and _dict_str(_body, "body_id", "") == body_id: - SimBridge.request_atlas_layers(body_id) - ) +## Read-only accessors AtlasGenerationProxy needs (it is added as a child, not +## a subclass, so it reaches viewer state through the same public surface any +## other caller would use). +func get_implant_theme() -> Variant: + return _implant_theme -## Show/hide the diegetic pending indicator. Starting only when not already -## visible avoids resetting the sweep on every re-poll. -func _set_gen_indicator(on: bool) -> void: - if _gen_pending_indicator == null: - return - if on: - if not _gen_pending_indicator.visible: - _position_pending_indicator() - _gen_pending_indicator.start("GENERATING LAYER 1") - else: - _gen_pending_indicator.stop() - - -func _position_pending_indicator() -> void: - if _gen_pending_indicator == null: - return - _gen_pending_indicator.position = Vector2( - (size.x - _gen_pending_indicator.DEFAULT_WIDTH) * 0.5, size.y * 0.45 - ) +func get_body_id() -> String: + return _dict_str(_body, "body_id", "") func get_hovered_city() -> Dictionary: diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 50f5c883b..74205a403 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1597,11 +1597,78 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **(4) Six wiring touch points (follow-up ticket, not built here).** `AtlasLayerResponse` gains `quarter_footprints: Option` + a `build_quarter_footprint_layer(state, placements)` function mirroring `build_district_grid`'s "empty source → `None`" contract (`server/src/atlas/layer_proxy.rs`); `ZoningType` gains `PartialOrd, Ord` derives mirroring `DistrictType`'s T-994 precedent (`server/src/simulation/generator.rs` — declaration order is not a stability-pinned wire format on either enum, so the additive derive is safe), which is what makes §1's lowest-declaration-order tie-break computable for `dominant_zoning` (PR #179 review: `DistrictType` already carries the derives, `ZoningType` does not — the tie rule itself is unchanged); `protocol.gd` passthrough for the new field (mirrors the existing `district_grid`/`road_graph`/`settlements` fields); a `gen_l4_quarters` entry in `OVERLAY_DEFS` (`client/ui/implant/apps/atlas/atlas_viewer.gd`); a `_draw_gen_l4_quarters()` function imitating `_draw_gen_district`'s "read `viewer.get_generation_quarter_footprints()`, guard on `Dictionary`, draw" shape (`atlas_marker_overlay.gd`); and the `GENERATION_LEGEND` entry above (`atlas_legend_panel.gd`). `layer_proxy.rs` was mid-concurrent-edit for T-1113's `region_grid` addition at design time — read-only pass, no conflict expected (both land as new sibling `Option` fields on `AtlasLayerResponse`, following the same one-field-per-layer pattern the growth-ceiling note on that struct already anticipates naming T-1112 and T-1113 as the last two candidates). + **Amended 2026-07-18 (T-1124 — windowed district-resolution regional map, the jet-plane altitude between the planetary Atlas and the never-mapped chunk/voxel world):** a new Atlas *viewing mode*, deliberately not counted alongside item (3)/T-1112/T-1113's whole-body-layer sequence, because it is a different kind of thing from all of them — those are **whole-body layers** (computed once, cached per body, one screenful, `up_to`-gated but otherwise request-independent); this is a **windowed viewport query** (parameterized by a client-chosen rect, re-issued on every pan, never a whole-body snapshot). District resolution (2,048 m/cell, D-243) sits strictly between the D-226(d) settlement/quarter-skeleton ceiling and the never-Atlas-mapped chunk/voxel tier — in remit, and the first time the D-227 "invented deterministically" terrain (everything finer than the ~40–78 km/px heightmap) has ever been surfaced to a screen rather than a debug probe. Design only — implementation is a follow-up ticket (T-1124 report; touch points named below). + + - **(1) Request — extend `AtlasLayerRequest`, do not add a sixth inbound shape.** `bridge/mod.rs`'s `Inbound` demux doc names `BrowseRequest` (T-1131, PR #184) the **fifth and last** map-shape probe this hand-rolled scheme should ever carry; a sixth top-level request shape is explicitly forbidden without migrating the whole channel to the tagged-envelope framing D-225 deferred. The window parameters therefore ride on the **existing** `AtlasLayerRequest{body_id, up_to}` as new `#[serde(default)]` fields, absent = whole-body (today's behavior, byte-unchanged for every existing caller): + ```rust + pub struct AtlasLayerRequest { + pub body_id: String, + pub up_to: CascadeLayer, + /// District-window centre (T-1124). `None` = no window requested + /// (whole-body layers only, today's behavior). + #[serde(default)] + pub window_center: Option, + /// Window side length in districts. Ignored when `window_center` is + /// `None`. Clamped server-side to `[1, DISTRICT_WINDOW_MAX_N]` (§4) — + /// never trusted from the wire. + #[serde(default)] + pub window_n: u32, + } + ``` + This is the identical pattern `StartupMessage.role` already uses (`bridge/types.rs`, D-254 §2) — an old client sending only `{body_id, up_to}` still decodes cleanly, `window_center` defaults to `None`, no protocol version bump, no new demux branch. `up_to` is unaffected and keeps gating which whole-body layers run; a window request rides alongside any `up_to` value — the window derivation depends only on `TerrainAnalysis` + `BodyParams` being resolvable for the body (the same precondition `aliveness_probe --render` has), not on which whole-body layers the cascade has cached. + + **Serving model — the window derives on the Rayon background queue, NOT inline on the tick thread (binding).** `serve_atlas_requests` runs in `TickPhase::PreInput` and drains *all* queued atlas requests synchronously in one loop (`plugin.rs:99-128`), sharing that tick's response flush with the star-map/city-names/browse serve systems. Every expensive path in that system today goes to the Rayon background queue and returns `Pending` — `drain_generation_completions`' own doc is explicit that this drain is "a cheap channel drain + cache insert, **never the ~45 ms cascade itself**" (`plugin.rs:207`). A window derive at n=32/64 is ~7–29 ms (§4); running it inline would blow that "cheap" contract, and a **pan-burst would stack several inline derives in one drain loop**, delaying the entire tick's response flush (star-map, browse, everything). Client debounce (§5) is *courtesy* — the server cannot enforce it and must not depend on it. So a window request follows the **exact same background-queue pattern as a whole-body cache miss**: `handle_atlas_request` submits a window-derive work item to the `GenerationQueue` (a new `GenWorkItem` variant carrying `body_id` + `(center, n)` + the resolved terrain/params) rather than deriving inline; a later tick's `drain_generation_completions` receives the finished `DistrictWindowLayer` and caches it (keyed by `(body, center, n)`, alongside `BodyWorldState` or in a sibling window cache — a wiring-ticket call). Because `district_window` is an `Option`, an as-yet-underived window is simply served as `None` — the *same* "layer hasn't produced yet → `None`" signal every whole-body layer already uses, independent of the body-level `AtlasLayerStatus` (a body whose whole-body layers are cached still answers `Ready` with `district_window: None` until the window job completes; the client re-requests via the existing D-225 poll loop and gets the window on a later response once cached). This avoids overloading the body-level status with window-readiness — the `Option` carries it. §5's border-fade already covers this multi-tick wait as UX. **Recommended (not mandated here):** per-connection window-request coalescing — a newly-queued window request for the same body/connection supersedes an unserved older one, so a pan-burst collapses to one derive server-side even if the client's debounce let several through. The precise `GenWorkItem` shape, the coalescing key, and the completion-routing wiring are a follow-up-ticket concern (T-1137); this record fixes only that window derivation is background-queued like every other expensive atlas path, never inline on the PreInput drain. + + - **(2) Response carrier — a distinct payload, not a sixth/seventh dense-layer `Option` field (RULING, binding).** The growth-ceiling note on `AtlasLayerResponse` (this record's base text, re-affirmed by T-1112 §4's "last two candidates" framing) governs one specific family: **dense, whole-body, cache-keyed-on-body-alone layers** — `district_grid`, `road_graph`, `settlements`, `region_grid`, and `quarter_footprints` (T-1112/T-1119, landing concurrently with this design). A windowed district payload is a different kind of traffic by construction: its content is keyed on **`(body, center, n)`**, it is re-requested on every pan (not cached once per body and reused), and a stale response must be **detectable and discardable** by the client rather than silently rendered — none of which is true of the five/six-member family the ceiling was written for. Retrofitting it into that family as a bare `district_window: Option` sitting next to `region_grid` would misrepresent its semantics (implying the same "cached snapshot, always current" contract its neighbours have) even before the slot-count argument. The ceiling's subject is therefore explicitly re-scoped here to **the dense whole-body layer family** — it does not gate this field, and this field does not count against it. This is option (c) from the T-1124 refinement's three choices, chosen over (a) (a bare seventh/eighth `Option` peer — technically fits the struct, dishonestly fits the family) and (b) (a second response message — real complexity, a new framing concept, for a problem the existing struct already solves once the semantics are named correctly). + + **Windowed-family ceiling (binding, replaces the migration trigger the re-scoping removed).** Re-scoping the whole-body ceiling to exclude windowed queries must not leave the windowed family *uncapped* — that would let a second windowed field (a windowed chunk-preview, a second simultaneous viewport) land frictionless as `district_window_2`, exactly the drift the whole-body cap exists to prevent. So the windowed family gets its own hard rule, mirroring the request side's "five HARD, a sixth migrates" discipline: **there is exactly ONE windowed-query field on `AtlasLayerResponse` (`district_window`), and a second windowed query is choice (b) — a dedicated response message — by rule, not by case-by-case judgment.** The rationale is symmetric with (b)'s rejection here: one windowed payload fits the existing response struct honestly (the client asked for a window, got a window); two concurrent windowed payloads riding one `AtlasLayerResponse` would need per-field request-correlation (which echo matches which in-flight request?) that the single-field echo-key design deliberately avoids — that correlation machinery *is* the tagged/multiplexed framing a dedicated message provides, so a second windowed consumer is the trigger to build it, not a reason to bolt a second `Option` on. So: `district_window` is the windowed family's five-HARD equivalent at one, and the next windowed field is a migration, full stop. + + `AtlasLayerResponse` gains exactly one new field along these lines: + ```rust + pub struct AtlasLayerResponse { + // ...existing fields unchanged... + pub region_grid: Option, + /// The requested district window (T-1124), or `None` when the request + /// carried no `window_center` / no window data is cached yet for a + /// pending body. Distinct from the five layers above: keyed on the + /// REQUEST (body, center, n), not on the body alone — see §2. + pub district_window: Option, + } + ``` + `DistrictWindowLayer` **echoes `center`/`n` back on the response** — this is the client's race-condition guard, not a convenience field. Because window derivation is pure and deterministic (D-227: `derive_district` is a function of `(seed, body_id, body_params, terrain, district_pos)` only — no hidden request-order dependence), the same `(center, n)` query always yields the same payload, so the echoed tuple *is* the cache/staleness key: the client compares it against whichever window it most recently asked for and discards any response whose echo doesn't match (superseded by a later pan). No sequence number or request-id is needed — D-227's purity is what makes the echo sufficient. **`body_id` is not part of the echo tuple because it does not need to be:** the echo rides *inside* `AtlasLayerResponse`, whose existing `body_id` field already scopes the whole response to one body (the same field the whole-body layers use), and the response-routing path is per-connection-and-body already — so a body switch (or a window response arriving in-flight across a body switch) is disambiguated by the enclosing `AtlasLayerResponse.body_id`, not left to `(center, n)` to catch. The client's **cache key is the full `(body_id, center, n)`** (§4) — `body_id` from the response envelope, `(center, n)` from the echo — so cross-body confusion is ruled out by the routing that encloses the echo, and the echo's job is narrowed to exactly what it is good at: disambiguating *which window of the current body* a response answers. + ```rust + pub struct DistrictWindowLayer { + pub center: DistrictPos, + pub n: u32, // window side length in districts (n × n cells) + pub morphology: Vec, // MorphologyZone discriminant, 17-entry frozen vocab (D-239 §6) + pub elev_q: Vec, // 0-100, matches DistrictGridLayer.elev_q encoding + pub temp_dc: Vec, // deci-°C, REGION_TEMP_NONE_DC sentinel — same scheme as RegionGridLayer.mean_temp_dc, deliberately NOT a separate district-tier quantization (see rationale below) + pub moisture_q: Vec, // 0-100, matches DistrictGridLayer precedent + pub vegetation: Vec, // VegetationClass discriminant, 0-6 incl. Marine (T-1126) + pub glaciation: Vec, // GlaciationGrade discriminant, 0-4 (T-1127, accepted — see §3) + } + ``` + All six arrays are dense row-major `n × n`, same indexing convention as `DistrictGridLayer`/`RegionGridLayer` (`i = row * n + col`), built by iterating `derive_district` over `[center.0 - n/2, center.0 + n/2) × [center.1 - n/2, center.1 + n/2)` exactly as `aliveness_probe --render`'s `render_window_panels` already does — this design promotes that probe's window loop from a debug binary to a served layer, unchanged in mechanism. **Temperature stays `i16` deci-°C with the existing `REGION_TEMP_NONE_DC` sentinel**, not a new `u8` band-relative scheme (a live design-round proposal, overruled here): the district window and the region climate overlay (item T-1113) must share one temperature colorizer on the client, and D-243's edge-fuzz discipline ("climate does not change on a line") argues against two independently-chosen quantizations that could paint a visible ramp discontinuity at the region/district zoom-swap threshold — a rendering seam standing in for a data seam that D-243 explicitly rules out. The 1 extra byte/cell this costs over `u8` is immaterial at the window sizes in §4. + + - **(3) Field-list dispositions (mandatory per the T-1124 refinement).** Six fields ship, all already-derived `DistrictProfile` members with no new derivation logic: + - **`glaciation_grade`: ACCEPT.** T-1127 (done, PR merged) explicitly deferred this field's wire half to this design pass and flagged the render pattern already works — the per-pixel ice tint on the morphology panel (`aliveness_probe::apply_ice_tint`) is production-proven, and `glaciation_grade` is already a first-class `DistrictProfile` field with a stable 0–4 discriminant. Shipping it as its own array (rather than baking the tint server-side into `morphology`) matches Araminta's encoding needs: the client can choose to tint, use a dedicated glaciation overlay, or ignore it, exactly as the probe renderer offers a sixth dedicated panel alongside the tinted morphology panel. + - **`vegetation` palette/legend must be exhaustive over `Marine = 6`.** T-1126 appended `Marine` to `VegetationClass` (open water, morphology-derived, never a threshold of its own) specifically because the district tier is where the ocean-blind-vegetation bug (frozen bodies reading Forest over open sea) was caught. Any client palette/legend for this field that omits `Marine` reintroduces exactly that bug at window resolution — non-negotiable inclusion, not a nice-to-have. + - **T-1127's ceiling ruling generalizes to this whole field list.** Its refinement text is explicit: *"the cap governs new top-level Option layers, not per-cell fields inside a shipped layer"* — the precedent it cites (`elev_q` already riding inside `DistrictGridLayer` with no separate governance gate) is the same shape as all six fields here riding inside one `DistrictWindowLayer`. Per-cell field additions to an already-shipped layer are engineering, not governance, unless a field would reopen a frozen vocabulary (D-239 §6's 17-zone freeze) — none of these six do; `morphology_zone` and `vegetation_class` both ship their existing frozen/T-1126-amended discriminant sets unchanged. + + - **(4) Budget (binding numbers).** Per-cell wire cost is **7 bytes** (`morphology` 1 + `elev_q` 1 + `temp_dc` 2 + `moisture_q` 1 + `vegetation` 1 + `glaciation` 1), before MessagePack array-header overhead (negligible at these sizes — six flat byte/i16 arrays, no per-element framing). **`DISTRICT_WINDOW_MAX_N = 64`** (request-side hard clamp, §1): 64×64 = 4,096 cells → 28 KiB raw payload. This is deliberately the same `n` as `aliveness_probe --render`'s default window (T-1123) — a value already proven to render correctly server-side and matching the "regional inspection" altitude the ticket names (64 districts × 2.048 km ≈ 131 km per side — city-and-hinterland scale, not planetary). **`DISTRICT_WINDOW_DEFAULT_N = 32`** (client's interactive default, 1,024 cells → 7 KiB) — half the cap. The derive cost is **background-queue latency, not tick-thread cost** (per §1's serving model, the window derives on a Rayon worker and returns via a later tick's completion drain, never inline): at the ~7 µs/derive debug-build rate T-1123's window renderer measured, a full window is ≈ 7 ms (n=32) / ≈ 29 ms (n=64 cap) of **Rayon-worker time**, i.e. the enqueue-to-completion delay a client waits across (a tick or few, bridged by §5's border-fade), *not* time spent on the PreInput drain. **These are debug-build figures with no committed release-build number yet** — release is expected meaningfully faster (no debug assertions, inlining) but the design does not presume a specific multiplier. The cap's job in the background-queue model is to bound how long one window job occupies a worker (so it can't starve whole-body cascade jobs sharing the pool) and to keep the client-visible wait short; if profiling shows it should move, `DISTRICT_WINDOW_MAX_N` is the one constant to tune, not a redesign. Because the derive is off the tick thread, D-200's 5 ms on-demand tile-fill budget is irrelevant here (that governs the main-tick chunk-boundary path); the window is served over the D-226 item (1) paused-sim bridge path (Snapshot/PostSnapshot stay alive while Movement/Simulation/Economy/etc. freeze) exactly as whole-body layers are, and its responsiveness budget is the same background-queue-plus-poll latency those already accept, not a per-tick deadline. **Re-request-on-pan policy:** the client re-requests only when a **pan** carries the view past the held window's edge — **never on zoom** (§5): only pan changes `center`, and because window derivation is deterministic (D-227) a zoom-triggered re-fetch of the same `(center, n)` would spam byte-identical responses for zero new information. The client debounces pan motion (do not fire on every drag-frame delta); the exact debounce interval and whether windows snap to a fixed grid vs. float on the pan center are client-side screen decisions (Araminta's §5) using the request/response contract fixed here. **Client cache policy:** windows are cacheable client-side keyed on `(body_id, center, n)` — D-227's determinism guarantee (same seed/body/position → same derived output, always) means a previously-fetched window is valid forever for that body+seed and can be kept in an LRU without a freshness check, exactly the same guarantee that makes the whole invention pipeline (D-227's "invented deterministically" clause, the actual subject T-1124 surfaces to a screen for the first time) safe to memoize; eviction policy (size, LRU depth) is a client implementation detail, not fixed here. + + - **(5) Screen (Araminta).** The regional view is a **zoom-threshold LOD swap on the existing `AtlasViewer`, not a new screen**: crossing a second, higher zoom threshold (`DISTRICT_WINDOW_MIN_ZOOM`, proposed `6.0`, past the existing `SETTLEMENT_LABEL_MIN_ZOOM = 2.0`) swaps the draw target in place — same `AtlasViewer` node, same `RegionalScreen` nav-stack "regional" state `show_body()` already establishes, no nav-stack push, no second `Control` scene, no new crumb. **The swap trigger is the zoom threshold alone** — there is no "must be over a settlement" precondition; the district window derives for *any* `DistrictPos` (`derive_district` is defined everywhere, ocean included), so the player can descend anywhere they can pan to. **The first window centers on the pan-center's derived `DistrictPos`** — the `DistrictPos` nearest the current screen-center point at the moment the threshold is crossed (`true_district_of_pixel`-style inverse mapping), *consistent with §4's float-on-center pan model*, **not** snapped to the nearest settlement. (Settlements matter only as the *practical* reason a player zooms in on a spot — the map's markers draw the eye — but nothing in the mechanism keys on them; a player zooming into open coastline gets that coastline's window, correctly.) Both thresholds live on the existing `_view_zoom` float. Crossing back out (zoom below the threshold, or Esc — the two exits are one code path, a zoom-value transition watcher) swaps back. This reuses the viewer's own established LOD vocabulary (zoom past a threshold reveals more detail — labels at 2.0, the district window here) and its "reveal more without leaving" instinct (city-click opens a sidebar, not a nav push). **Rejected:** a nav-stack push (the planetary→regional push earns its crumb because the rendering genuinely changes — galaxy scatter → heightmap texture; body→window is the same viewer at a smaller camera window, so a crumb would imply "you left somewhere" for a metric reached by scrolling in); click-to-open on the settlement dot (overloads the existing city-click sidebar gesture — two intents on one gesture, and D-013 argues the zoom gesture should own spatial descent); a dedicated "view district" button (duplicates what pan/zoom already promises at every other level of this map). + + - **Pan re-fetches; zoom does not.** A **pan** past the held window's edge re-centers `window_center` and fires a new request (§1's optional fields, same request machinery); **zoom never re-fetches** — with `DISTRICT_WINDOW_MAX_N = 64` the composite is a texture the client zooms client-side (the existing `_view_zoom` mechanic, now on the smaller composite) to get from a coarse read (~14 px/cell at n=64, 1.0×) to a detail read (~28 px/cell at 2×, comparable to the T-1123 `w256` probe renders' native fine texture) **from already-held data**. In-window zoom doing real legibility work is what separates "fetch = ground coverage" from "render zoom = detail resolution"; a zoom-triggered re-fetch would spam byte-identical (D-227) responses for the exact `(center, n)` already on screen. This is the §4 re-request policy's client-side rationale. + - **Debounce + window origin (the two client-side calls §4 left open).** Re-fetch fires **150 ms after the last drag-release** (not per-drag-frame) — long enough to collapse a flick-and-resettle into one request, short enough that a deliberate single pan-and-stop never feels delayed (no competing tick-driven redraw under the D-226 pause). Windows **float on the pan center** (nearest `DistrictPos` to the new screen-center), **not grid-snapped** — snapping would jump the composite by up to half a window-width across a snap boundary (a visually discontinuous "invisible re-fetch"), and floating keeps the spot the player is looking at exactly under the screen-center on entry and after every pan (consistent with the first-window centering above — the descent point stays put, whether it's a settlement or open coast); the §4 client cache still gets real hit value because Esc-then-re-enter and pan-back reproduce the same `(body_id, center, n)` (D-227 makes exact-repeat the common case for the two navigation patterns that matter), without a grid forcing arbitrary alignment. + - **What renders during the wait** (the "invisible re-fetch"): the previous composite, panned to its new screen position, with a **border-fade to the underlying whole-body heightmap** (already resident, coarser `district_grid`/`region_grid` data — real data seen through, not a placeholder) at the newly-exposed edge; **no black, no spinner** unless the wait exceeds a ~0.5 s grace window (reusing the existing `_gen_pending_indicator`, not a new mechanism). Because §4's cache is D-227-valid indefinitely, a pan back toward a recently-cached window composites from cache with zero wait — the genuine-miss `Pending`/re-poll path (D-225's existing loop) becomes the minority case, not the default. + - **Overlay/legend reuse against §2–§4.** The base layer is **morphology, lightness-modulated by `elev_q`** (one `0.7 + 0.3*(elev_q/100)` multiply per cell — relief read without a second draw call, the T-1112 "shape=identity, cheap second channel=magnitude" instinct as hue=type / lightness=elevation), reusing the T-1123 probe's 17-entry `MORPHOLOGY_RGB` hues verbatim; it is always-on once the LOD threshold is crossed (it *is* this screen's `terrain` layer), so it takes no toggle id. Three switchable overlays get new `gen_dw_temp` / `gen_dw_moisture` / `gen_dw_veg` `OVERLAY_DEFS` ids (`group: "toggle"`, the `gen_l1_*` multi-toggle-over-one-base precedent): **temperature** reuses T-1118's region-grid ramp *exactly* (same `i16` deci-°C domain + `REGION_TEMP_NONE_DC` sentinel disposition — one colorizer across both zoom levels, §2's consistency ruling); **moisture** reuses the existing `SUB_BIOME_COLORS` dry-sand→wet-teal endpoints; **vegetation** is a green-family ramp with **`Marine = 6` rendered transparent** (lets the morphology water-blue show through — `Marine` is `derive_vegetation`'s bookkeeping answer for already-`OpenOcean`/`Lake` districts, not new player information; a second blue would fight or duplicate the morphology read — this is the exhaustive disposition §3 mandates). **Glaciation is a modifier, not a toggle**: an ice-tint wash gated on `glaciation_grade >= Light` (alpha scaling with grade) composited over whichever layer shows — the `aliveness_probe::apply_ice_tint` approach ported to the player composite; it keeps sea-ice (tint over `OpenOcean` navy → whitened blue) visually distinct from open ocean and from ice-capped land (tint over alpine grey → near-white) by alpha-compositing over different bases rather than three drifting hard-coded colors. Legend: one `GENERATION_LEGEND` entry per new id (existing data-driven `atlas_legend_panel.gd` table, no new panel class); the morphology base folds its 17 zones into ~5 family rows (water / coastal-transition / plains-river / upland / volcanic) with the full mapping in the city-click sidebar, mirroring T-1112's "not everything earns permanent screen space" discipline. Implant chrome discipline (D-169/D-170): `ImplantHeader` carries a location label (the nearest settlement's name when the window is over/near one, else a coordinate/region label — the window is not settlement-anchored, per the entry clause above) + extent-in-real-units subtitle (e.g. "4.1 × 4.1 km · 2.0 km/cell") + one optional flavor line; the map-data palettes stay **out of** the theme's semantic accent roles (especially `ACCENT_ACTIVE` gold, which the settlement marker owns and must not compete with); no scanline/glitch dressing (the implant is confident working tech — a signal-quality state, if ever needed, rides `_gen_pending_indicator`, not cosmetic noise). Full color/ramp/compositing/legibility rationale and the n=32↔n=64 on-screen-scale math live in Araminta's companion T-1124 sections (visual encoding / implant aesthetic / legibility constraints), not re-derived here. + - **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem. - **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`. - **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer. - **Raised by:** Jeroen + Claude (design), with Tyre (channel/pause/headless architecture) + Araminta (overlay encoding + affordance UX), 2026-05-24. -- **Cross-reference:** [D-225](#d-225) (layer-stream proxy — the data path), [D-166](#d-166) (per-layer Atlas progress viewer), [D-191](#d-191) (Atlas viewer), [D-169](#d-169) / [D-170](#d-170) (implant components / HUD occlusion — `gameplay_occluded` trigger), [D-200](#d-200) / [D-203](#d-203) (execution tiers / LRU cache), Q-099 (mod content catalog), `tests/run-visual` (capture primitive), `save_state.rs` (save-inspection consumer). **T-1112 amendment additionally:** [D-222](#d-222) (Quarter terminology — the 512m unit this layer surfaces), [D-234](#d-234) (footprint geometry — the block-subdivision source the aggregates summarize), [D-243](#d-243) (quarter = 512m rung, and the containment ladder that makes a quarter sub-pixel at planetary projection — Araminta's no-outline rationale), [D-010](#d-010) (determinism — integer-only aggregates, `BTreeMap` keying). +- **Cross-reference:** [D-225](#d-225) (layer-stream proxy — the data path), [D-166](#d-166) (per-layer Atlas progress viewer), [D-191](#d-191) (Atlas viewer), [D-169](#d-169) / [D-170](#d-170) (implant components / HUD occlusion — `gameplay_occluded` trigger), [D-200](#d-200) / [D-203](#d-203) (execution tiers / LRU cache), Q-099 (mod content catalog), `tests/run-visual` (capture primitive), `save_state.rs` (save-inspection consumer). **T-1112 amendment additionally:** [D-222](#d-222) (Quarter terminology — the 512m unit this layer surfaces), [D-234](#d-234) (footprint geometry — the block-subdivision source the aggregates summarize), [D-243](#d-243) (quarter = 512m rung, and the containment ladder that makes a quarter sub-pixel at planetary projection — Araminta's no-outline rationale), [D-010](#d-010) (determinism — integer-only aggregates, `BTreeMap` keying). **T-1124 amendment additionally:** [D-227](#d-227) (derive-don't-store + the "invented deterministically" clause this design is the first to surface on a screen; determinism is what makes the echoed-center staleness guard and the client-side window cache both sound), [D-243](#d-243) (district = 2,048m rung — this is precisely the D-226(d) ceiling's floor, the regional altitude above quarter-skeleton and below the never-mapped chunk/voxel tier; edge-fuzz discipline — the temperature quantization-consistency rationale), [D-239](#d-239) §6 (the frozen 17-zone `MorphologyZone` vocabulary this layer serves unchanged) / §8 (vegetation climate law, amended by T-1126's `Marine`), D-225 extension / T-1131 / PR #184 (the five-map-shape demux ceiling — why the window rides on `AtlasLayerRequest` rather than a new inbound shape), [D-010](#d-010) (wire-integer discipline — all six per-cell fields are integer/quantized, no `f32` on the wire). §5 (screen) additionally leans on [D-191](#d-191) (the `AtlasViewer` whose `_view_zoom` LOD vocabulary and `SETTLEMENT_LABEL_MIN_ZOOM` threshold the district window extends in place), [D-169](#d-169) / [D-170](#d-170) (implant chrome / theme accent-role discipline — map palettes stay out of `ACCENT_ACTIVE` gold the settlement marker owns), and [D-013](#d-013) (diegetic navigation — the zoom gesture owns spatial descent, so it is not overloaded onto the city-click sidebar). Tickets: T-1123 (the `derive_district` window-render precedent this design promotes to a served layer), T-1127 (glaciation_grade derivation + probe render pattern, wire half deferred here and now accepted), T-1126 (`VegetationClass::Marine`), T-1118 (region-grid climate overlay — §5's temperature/moisture overlays reuse its ramp so one colorizer spans both zoom levels), T-1119 (concurrent `quarter_footprints` wiring — the sibling whole-body layer this design's §2 distinguishes itself from). - **Dissent:** None --- diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 011e89f2f..399a95a7e 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -21,8 +21,8 @@ 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}; +use crate::seed::{SeedChain, SeedDomain}; +use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAuthority, ZoningType}; /// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk /// (the loader prefers the chunk; this is only the floor). @@ -71,12 +71,31 @@ pub struct DistrictGridLayer { /// A layer response: the computed `Layer1Output` + the coarse district grid /// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2) + -/// the region climate grid (T-1113), or a non-ready status. +/// the region climate grid (T-1113) + the quarter-footprint overlay (T-1112, +/// T-1119), 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) as the only remaining candidate. +/// pattern tops out at six fields for the **dense whole-body layer family** +/// (`district_grid`, `road_graph`, `settlements`, `region_grid`, +/// `quarter_footprints` — each a compute-once, cache-per-body snapshot) — +/// D-226's 2026-07-13 amendment (d) rules out any L5/tile Atlas layer ever, +/// and `quarter_footprints` below is the last candidate the 2026-07-16 T-1112 +/// amendment named. **That budget is now consumed:** a seventh *whole-body* +/// field is not a naming exercise like the six before it — a future +/// generation-layer addition needs its own governance, not a drive-by field. +/// +/// The D-226 T-1124 amendment (2026-07-18) RESOLVED what carries the next +/// addition, and it is NOT this family: a **windowed viewport query** is a +/// categorically different payload (keyed on the *request* `(body, center, n)`, +/// re-fetched per pan, not a per-body snapshot). T-1124 specifies a +/// `district_window: Option` field (wiring is a follow-up +/// ticket, T-1137 — not yet added here) that rides on `AtlasLayerResponse` but +/// is explicitly OUTSIDE the whole-body family and does not count against this +/// six-field ceiling (D-226 T-1124 §2). The windowed family has its own hard +/// cap: exactly ONE windowed-query field; a second windowed query (a second +/// viewport, a windowed chunk-preview) is a dedicated response message by rule, +/// not a second `Option` here (D-226 T-1124 §2, symmetric with the +/// request-side five-shape demux ceiling in `bridge/mod.rs`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtlasLayerResponse { pub body_id: String, @@ -97,6 +116,13 @@ pub struct AtlasLayerResponse { /// The region climate grid for the Atlas overlay (D-243 §3, T-1113). /// `Some` on a cache hit once the Region layer has run; `None` otherwise. pub region_grid: Option, + /// The quarter-footprint overlay (D-226 T-1112 amendment, T-1119). `Some` + /// on a cache hit once at least one settlement's quarter skeleton has been + /// generated (`state.quarters` non-empty); `None` otherwise, including a + /// body with placed settlements whose quarters haven't finished the async + /// `GenerateSkeleton` pass yet (skeleton generation runs at `Low` priority + /// after the body's own `Ready` snapshot is cached — see `plugin.rs`). + pub quarter_footprints: Option, } /// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046). @@ -209,6 +235,148 @@ pub fn build_region_grid( }) } +// --------------------------------------------------------------------------- +// QuarterFootprintLayer (D-226 T-1112 amendment, T-1119) +// --------------------------------------------------------------------------- + +/// Per-settlement aggregate over one quarter's 4×4 `BlockSkeleton` grid, for +/// the Atlas quarter-footprint overlay (D-226 T-1112 amendment §1). Five +/// scalar fields earn their place per the amendment's hard ceiling (§2): no +/// per-block zoning/street/tag detail ever reaches the wire, and no +/// chunk/tile/voxel data is touched. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuarterFootprintEntry { + pub city_id: u64, + /// Basis-point mean of `BlockSkeleton.density_pct` across the 16 blocks + /// (integer division, D-010 — no `f32` on the wire). + pub density_avg_pct: u8, + /// Mode `DistrictType` across the 16 blocks; ties resolve to the lowest + /// declaration-order variant (the `Ord` derive on `DistrictType`, T-994). + pub dominant_district_type: DistrictType, + /// Mode `ZoningType` across the 16 blocks; same tie rule (the `Ord` + /// derive added on `ZoningType` for this ticket, T-1119). + pub dominant_zoning: ZoningType, + /// Count of blocks with `landmark: Some(_)` across the 16 blocks (max 16). + /// Tooltip/sidebar-only per the D-226(d) ceiling — never a map-visible + /// channel (§2). + pub landmark_count: u8, + /// `QuarterSkeleton.corridors.len()`, clamped to `u8`. Tooltip/sidebar-only, + /// same ceiling as `landmark_count`. + pub corridor_count: u8, +} + +/// The quarter-footprint overlay for one body (D-226 T-1112 amendment §1), +/// keyed by `city_id` — a quarter carries no independent spatial position of +/// its own (`QuarterId` is a content-addressable hash, not a coordinate), so +/// the layer anchors at the existing L3 settlement position client-side and +/// this map only needs to answer "does this settlement have quarter data, and +/// if so what does it aggregate to". +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuarterFootprintLayer { + /// `BTreeMap` for D-010 determinism, matching `RegionGridLayer`'s and the + /// source `QuarterWorldState.block_tags`' own `BTreeMap` precedent. + pub entries: std::collections::BTreeMap, +} + +/// Aggregate one quarter's 16 `BlockSkeleton`s into a [`QuarterFootprintEntry`] +/// for `city_id`. +fn aggregate_quarter_footprint( + city_id: u64, + skeleton: &crate::simulation::generator::QuarterSkeleton, +) -> QuarterFootprintEntry { + let blocks: Vec<&crate::simulation::generator::BlockSkeleton> = + skeleton.blocks.iter().flatten().collect(); + let n = blocks.len() as u32; // always 16 (the fixed 4×4 grid) — computed + // rather than hardcoded so the mean formula + // stays correct if the grid shape ever changes. + + let density_sum: u32 = blocks.iter().map(|b| b.density_pct as u32).sum(); + let density_avg_pct = if n == 0 { 0 } else { (density_sum / n) as u8 }; + + let dominant_district_type = mode_by_declaration_order(blocks.iter().map(|b| &b.district_type)) + .cloned() + .unwrap_or_default(); + let dominant_zoning = mode_by_declaration_order(blocks.iter().map(|b| &b.zoning)) + .cloned() + .unwrap_or_default(); + + let landmark_count = blocks.iter().filter(|b| b.landmark.is_some()).count() as u8; + let corridor_count = skeleton.corridors.len().min(u8::MAX as usize) as u8; + + QuarterFootprintEntry { + city_id, + density_avg_pct, + dominant_district_type, + dominant_zoning, + landmark_count, + corridor_count, + } +} + +/// Mode of an `Ord` value over an iterator, tie-broken by lowest declaration +/// order (i.e. the `Ord`-smallest value among the tied-for-max-count values). +/// `None` for an empty iterator. +/// +/// **Not** `counts.into_iter().max_by_key(...)`: `Iterator::max_by_key` +/// returns the *last* maximum on a tie (documented behaviour), which is the +/// opposite of what's needed here. `BTreeMap` iterates keys in ascending +/// `Ord` order (= declaration order for these enums), so walking forward and +/// only replacing the running best on a *strictly greater* count keeps the +/// first-seen — i.e. lowest-declaration-order — winner on every tie. +fn mode_by_declaration_order<'a, T: Ord + 'a>( + values: impl Iterator, +) -> Option<&'a T> { + let mut counts: std::collections::BTreeMap<&'a T, u32> = std::collections::BTreeMap::new(); + for v in values { + *counts.entry(v).or_insert(0) += 1; + } + let mut best: Option<(&'a T, u32)> = None; + for (v, count) in counts { + match best { + Some((_, best_count)) if count <= best_count => {} + _ => best = Some((v, count)), + } + } + best.map(|(v, _)| v) +} + +/// Build the [`QuarterFootprintLayer`] from a body's cached state (T-1119). +/// Returns `None` when the Quarter-skeleton layer has not run for any +/// settlement (empty `state.quarters`). +/// +/// `state.quarters` carries no independent spatial position — the only +/// spatial anchor a quarter has is the `city_id` it was generated for +/// (D-226 T-1112 amendment §1). So this recomputes the same deterministic +/// `QuarterId` derivation the L3→L4 dispatch path uses +/// (`SeedChain::for_body(world_seed, body_id).derive(SeedDomain::Layer4Quarter, +/// city_id).seed()`, `plugin.rs::build_skeleton_work_item`) for every placed +/// settlement and looks it up in `state.quarters`. A placement whose derived +/// id isn't found (skeleton generation is async, dispatched at `Low` priority +/// after the body's `Ready` snapshot is already cached — `plugin.rs`) is +/// skipped, not defaulted: an absent quarter is not a zero-footprint quarter. +pub fn build_quarter_footprint_layer( + state: &BodyWorldState, + world_seed: u64, +) -> Option { + if state.quarters.is_empty() { + return None; + } + let body_chain = SeedChain::for_body(world_seed, &state.body_id); + let mut entries = std::collections::BTreeMap::new(); + for placement in &state.placements { + let quarter_id = body_chain + .derive(SeedDomain::Layer4Quarter, placement.city_id) + .seed(); + if let Some(quarter_state) = state.quarters.get(&quarter_id) { + entries.insert( + placement.city_id, + aggregate_quarter_footprint(placement.city_id, &quarter_state.skeleton), + ); + } + } + Some(QuarterFootprintLayer { entries }) +} + // --------------------------------------------------------------------------- // RoadGraphLayer (T-960 §1, T-1038) // --------------------------------------------------------------------------- @@ -422,6 +590,7 @@ pub fn handle_atlas_request( let road_graph = build_road_graph_layer(state); let settlements = build_settlement_layer(state); let region_grid = build_region_grid(state); + let quarter_footprints = build_quarter_footprint_layer(state, world_seed); return AtlasLayerResponse { body_id: req.body_id.clone(), status: AtlasLayerStatus::Ready, @@ -430,6 +599,7 @@ pub fn handle_atlas_request( road_graph, settlements, region_grid, + quarter_footprints, }; } @@ -503,6 +673,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, } } // Unknown / no terrain → re-requesting won't help. @@ -515,6 +686,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }, Err(e) => AtlasLayerResponse { body_id: req.body_id.clone(), @@ -524,6 +696,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }, } } @@ -656,6 +829,338 @@ mod tests { assert_eq!(grid.moisture_q[1], 5); } + /// T-1119: `build_quarter_footprint_layer` returns `None` when no + /// settlement's quarter skeleton has been generated (`state.quarters` + /// empty), mirroring `build_district_grid`/`build_region_grid`'s + /// "unrun layer → None" contract. + #[test] + fn quarter_footprint_layer_none_when_quarters_empty() { + let state = blank_state("GJ1c"); + assert!(build_quarter_footprint_layer(&state, 42).is_none()); + } + + /// A `BlockSkeleton` fixture builder for quarter-footprint tests — only + /// the fields the aggregate reads are wired; the rest default. + fn block( + zoning: crate::simulation::generator::ZoningType, + district_type: DistrictType, + density_pct: u8, + landmark: Option, + ) -> crate::simulation::generator::BlockSkeleton { + crate::simulation::generator::BlockSkeleton { + zoning, + district_type, + density_pct, + landmark, + ..Default::default() + } + } + + /// T-1119: a populated quarter aggregates correctly — the density mean, + /// the dominant-mode fields (including a tie resolving to the lowest + /// declaration-order variant per the D-226 T-1112 amendment §1), the + /// landmark count, and the corridor count. + #[test] + fn quarter_footprint_layer_aggregates_populated_quarter() { + use crate::atlas::attractor_matching::CityPlacement; + use crate::simulation::generator::{ + ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType, + }; + + let mut state = blank_state("GJ1c"); + let placement = CityPlacement { + city_id: 7, + name: "Millbrook".into(), + position: (30, 40), + attractor_type: AttractorType::ValleyFloor, + score: 500, + synthetic: false, + political_archetype: PoliticalArchetype::Commission, + arrangement_pattern: ArrangementPattern::RadialCore, + founding_orientation: FoundingOrientation::Cardinal, + population: 200_000, + is_capital: false, + is_standalone_hq: false, + }; + state.placements = vec![placement.clone()]; + + let world_seed = 42; + let quarter_id = SeedChain::for_body(world_seed, "GJ1c") + .derive(SeedDomain::Layer4Quarter, placement.city_id) + .seed(); + + // 16 blocks: 10 Commercial/Commercial, 6 Industrial/Industrial — a + // clean (non-tied) mode on both district_type and zoning, plus a + // known density mean and landmark/corridor counts. + let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default(); + let mut flat: Vec<&mut crate::simulation::generator::BlockSkeleton> = + blocks.iter_mut().flatten().collect(); + for (i, b) in flat.iter_mut().enumerate() { + if i < 10 { + **b = block(ZoningType::Commercial, DistrictType::Commercial, 60, None); + } else { + **b = block( + ZoningType::Industrial, + DistrictType::Industrial, + 20, + Some("landmark".to_string()), + ); + } + } + // 3 landmarks among the Industrial blocks (indices 10, 11, 12). + *flat[10] = block( + ZoningType::Industrial, + DistrictType::Industrial, + 20, + Some("A".to_string()), + ); + *flat[11] = block( + ZoningType::Industrial, + DistrictType::Industrial, + 20, + Some("B".to_string()), + ); + *flat[12] = block( + ZoningType::Industrial, + DistrictType::Industrial, + 20, + Some("C".to_string()), + ); + for b in flat.iter_mut().skip(13) { + **b = block(ZoningType::Industrial, DistrictType::Industrial, 20, None); + } + + // (10 * 60 + 6 * 20) / 16 = 720 / 16 = 45. + state.quarters.insert( + quarter_id, + crate::simulation::generator::QuarterWorldState { + skeleton: crate::simulation::generator::QuarterSkeleton { + quarter_id, + blocks, + corridors: vec![ + crate::simulation::generator::CorridorSpine { + from: 0, + to: 1, + path: vec![(0, 0), (4, 4)], + }, + crate::simulation::generator::CorridorSpine { + from: 1, + to: 2, + path: vec![(4, 4), (8, 8)], + }, + ], + ..Default::default() + }, + block_tags: Default::default(), + }, + ); + + let layer = + build_quarter_footprint_layer(&state, world_seed).expect("populated quarters → Some"); + let entry = layer.entries.get(&7).expect("city_id 7 entry present"); + assert_eq!(entry.city_id, 7); + assert_eq!(entry.density_avg_pct, 45); + assert_eq!(entry.dominant_district_type, DistrictType::Commercial); + assert_eq!(entry.dominant_zoning, ZoningType::Commercial); + assert_eq!(entry.landmark_count, 3); + assert_eq!(entry.corridor_count, 2); + } + + /// T-1119: the mode tie-break resolves to the lowest declaration-order + /// variant (the `Ord` derive), per the D-226 T-1112 amendment §1's + /// explicit tie rule — this is the reason `ZoningType` gained + /// `PartialOrd`/`Ord` in this same ticket. + #[test] + fn quarter_footprint_layer_tie_breaks_by_declaration_order() { + use crate::atlas::attractor_matching::CityPlacement; + use crate::simulation::generator::{ + ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType, + }; + + let mut state = blank_state("GJ1c"); + let placement = CityPlacement { + city_id: 3, + name: "Farmstead Rell".into(), + position: (50, 60), + attractor_type: AttractorType::PlainCenter, + score: 100, + synthetic: false, + political_archetype: PoliticalArchetype::Commission, + arrangement_pattern: ArrangementPattern::RadialCore, + founding_orientation: FoundingOrientation::Cardinal, + population: 8_000, + is_capital: false, + is_standalone_hq: false, + }; + state.placements = vec![placement.clone()]; + + let world_seed = 99; + let quarter_id = SeedChain::for_body(world_seed, "GJ1c") + .derive(SeedDomain::Layer4Quarter, placement.city_id) + .seed(); + + // 8 blocks Industrial, 8 blocks Commercial — an exact tie. Declaration + // order on both DistrictType and ZoningType lists Commercial before + // Industrial, so the tie-broken dominant must be Commercial on both. + let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default(); + for (i, b) in blocks.iter_mut().flatten().enumerate() { + *b = if i < 8 { + block(ZoningType::Industrial, DistrictType::Industrial, 50, None) + } else { + block(ZoningType::Commercial, DistrictType::Commercial, 50, None) + }; + } + state.quarters.insert( + quarter_id, + crate::simulation::generator::QuarterWorldState { + skeleton: crate::simulation::generator::QuarterSkeleton { + quarter_id, + blocks, + ..Default::default() + }, + block_tags: Default::default(), + }, + ); + + let layer = + build_quarter_footprint_layer(&state, world_seed).expect("populated quarters → Some"); + let entry = layer.entries.get(&3).expect("city_id 3 entry present"); + assert_eq!( + entry.dominant_district_type, + DistrictType::Commercial, + "tie resolves to Commercial (declared before Industrial)" + ); + assert_eq!( + entry.dominant_zoning, + ZoningType::Commercial, + "tie resolves to Commercial (declared before Industrial)" + ); + } + + /// T-1119: a placement whose deterministically-derived `quarter_id` is + /// NOT yet in `state.quarters` (skeleton generation is async, dispatched + /// after the body's own snapshot is cached — D-226 T-1112 amendment §1) + /// is skipped, not defaulted. `entries` is a subset of `placements`. + #[test] + fn quarter_footprint_layer_skips_placement_without_matching_quarter() { + use crate::atlas::attractor_matching::CityPlacement; + use crate::simulation::generator::{ + ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType, + }; + + let mut state = blank_state("GJ1c"); + let has_quarter = 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, + is_standalone_hq: false, + }; + let no_quarter_yet = CityPlacement { + city_id: 2, + name: "Farmstead Rell".into(), + ..has_quarter.clone() + }; + state.placements = vec![has_quarter.clone(), no_quarter_yet]; + + let world_seed = 42; + let quarter_id = SeedChain::for_body(world_seed, "GJ1c") + .derive(SeedDomain::Layer4Quarter, has_quarter.city_id) + .seed(); + let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default(); + for b in blocks.iter_mut().flatten() { + *b = block(ZoningType::Mixed, DistrictType::MixedUse, 10, None); + } + state.quarters.insert( + quarter_id, + crate::simulation::generator::QuarterWorldState { + skeleton: crate::simulation::generator::QuarterSkeleton { + quarter_id, + blocks, + ..Default::default() + }, + block_tags: Default::default(), + }, + ); + + let layer = + build_quarter_footprint_layer(&state, world_seed).expect("populated quarters → Some"); + assert_eq!( + layer.entries.len(), + 1, + "only the placement with a matching quarter gets an entry" + ); + assert!(layer.entries.contains_key(&1)); + assert!( + !layer.entries.contains_key(&2), + "city_id 2 has no generated quarter yet — must be absent, not defaulted" + ); + } + + /// T-1119 (D-010): building the layer twice from identical state produces + /// byte-identical output — the `city_id → quarter_id` derivation and the + /// mode aggregation are pure functions of their inputs. + #[test] + fn quarter_footprint_layer_is_deterministic() { + use crate::atlas::attractor_matching::CityPlacement; + use crate::simulation::generator::{ + ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType, + }; + + let mut state = blank_state("GJ1c"); + let placement = CityPlacement { + city_id: 5, + name: "Groombridge".into(), + position: (1, 1), + attractor_type: AttractorType::PlainCenter, + score: 300, + synthetic: false, + political_archetype: PoliticalArchetype::Commission, + arrangement_pattern: ArrangementPattern::RadialCore, + founding_orientation: FoundingOrientation::Cardinal, + population: 60_000, + is_capital: false, + is_standalone_hq: false, + }; + state.placements = vec![placement.clone()]; + + let world_seed = 7; + let quarter_id = SeedChain::for_body(world_seed, "GJ1c") + .derive(SeedDomain::Layer4Quarter, placement.city_id) + .seed(); + let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default(); + for (i, b) in blocks.iter_mut().flatten().enumerate() { + *b = block( + ZoningType::Residential, + DistrictType::Residential, + (i as u8) * 5, + None, + ); + } + state.quarters.insert( + quarter_id, + crate::simulation::generator::QuarterWorldState { + skeleton: crate::simulation::generator::QuarterSkeleton { + quarter_id, + blocks, + ..Default::default() + }, + block_tags: Default::default(), + }, + ); + + let a = build_quarter_footprint_layer(&state, world_seed); + let b = build_quarter_footprint_layer(&state, world_seed); + assert_eq!(a, b, "identical state must produce identical output"); + } + /// 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 { @@ -819,7 +1324,7 @@ mod tests { assert!(build_settlement_layer(&unrun).is_none()); } - /// T-960: the new layers survive a MessagePack round trip inside + /// T-960 / T-1119: 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). @@ -828,7 +1333,9 @@ mod tests { use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode}; use crate::simulation::generator::{ - ArrangementPattern, FoundingOrientation, MaintenanceAuthority, PoliticalArchetype, + ArrangementPattern, BlockSkeleton, DistrictType, FoundingOrientation, + MaintenanceAuthority, PoliticalArchetype, QuarterSkeleton, QuarterWorldState, + ZoningType, }; let mut state = blank_state("GJ1c"); @@ -865,6 +1372,28 @@ mod tests { is_rail: false, }], }; + let world_seed = 42; + let quarter_id = SeedChain::for_body(world_seed, "GJ1c") + .derive(SeedDomain::Layer4Quarter, 1) + .seed(); + let mut block = BlockSkeleton { + zoning: ZoningType::Commercial, + district_type: DistrictType::Commercial, + density_pct: 40, + ..Default::default() + }; + block.position = (0, 0); + state.quarters.insert( + quarter_id, + QuarterWorldState { + skeleton: QuarterSkeleton { + quarter_id, + blocks: std::array::from_fn(|_| std::array::from_fn(|_| block.clone())), + ..Default::default() + }, + block_tags: Default::default(), + }, + ); let resp = AtlasLayerResponse { body_id: "GJ1c".into(), @@ -874,6 +1403,7 @@ mod tests { road_graph: build_road_graph_layer(&state), settlements: build_settlement_layer(&state), region_grid: build_region_grid(&state), + quarter_footprints: build_quarter_footprint_layer(&state, world_seed), }; let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); @@ -886,6 +1416,13 @@ mod tests { rg.edges[0].maintenance, MaintenanceAuthority::Administrative ); + let qf = decoded + .quarter_footprints + .expect("quarter_footprints survives round trip"); + let entry = qf.entries.get(&1).expect("city_id 1 entry present"); + assert_eq!(entry.density_avg_pct, 40); + assert_eq!(entry.dominant_district_type, DistrictType::Commercial); + assert_eq!(entry.dominant_zoning, ZoningType::Commercial); let settlements = decoded .settlements .expect("settlements survives round trip"); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index ce4e2813f..84b66f109 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -121,6 +121,7 @@ fn serve_atlas_requests( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }, }; responses.0.push((conn_id, resp)); @@ -358,9 +359,7 @@ fn drain_generation_completions( // Key by state.skeleton.quarter_id (D-194/D-230): a city has many // quarters, each with its own QuarterId. `city_id` is only the // dispatch key used in the work item — the canonical insert key is - // the quarter's own stable id. TODO(#957): the stub GenerateSkeleton - // returns a default skeleton with quarter_id=0; real gen (#957) will - // populate it from CityGenerationContext. + // the quarter's own stable id. let _ = city_id; // used as dispatch key only; quarter_id is the map key body_state .quarters diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 4e694fa87..89682a079 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -231,7 +231,13 @@ pub enum DistrictLayoutMode { } /// Zoning classification for a block or floor zone. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] +/// +/// `Ord`/`PartialOrd` (T-1119, mirroring `DistrictType`'s T-994 precedent above): +/// lets the quarter-footprint aggregate (`layer_proxy::build_quarter_footprint_layer`) +/// resolve a `dominant_zoning` mode-tie by lowest declaration-order variant. +/// Declaration order is not a stability-pinned wire format on this enum (unlike +/// `MorphologyZone` or `SeedDomain`), so this is a safe additive derive. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum ZoningType { Commercial, Residential, diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 9a60a9808..601a33528 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -4,16 +4,20 @@ 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, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, - SettlementEntry, SettlementLayer, SettlementSizeClass, + AtlasLayerResponse, AtlasLayerStatus, QuarterFootprintEntry, QuarterFootprintLayer, + RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, SettlementEntry, + SettlementLayer, SettlementSizeClass, }; +use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState}; use settled_reach_server::atlas::road_graph::RoadNodeKind; use settled_reach_server::bridge::types::*; use settled_reach_server::simulation::generator::{ - AttractorType, GeographicAttractor, MaintenanceAuthority, SubBiomeVariant, + AttractorType, DistrictType, GeographicAttractor, MaintenanceAuthority, SubBiomeVariant, + ZoningType, }; use settled_reach_server::simulation::poi::PoiCategory; use settled_reach_server::simulation::time::{DayPhase, TickRate}; +use std::collections::BTreeMap; use std::fs; use std::path::Path; @@ -616,6 +620,53 @@ fn generate_atlas_layer_response_fixtures() { }, ], }; + // T-1113: a small populated RegionGridLayer — a 2×1 covering grid, one + // temperate region and one airless-style region (mean_temp_dc sentinel), + // mirroring the values exercised by + // `build_region_grid_encodes_dense_quantized_climate` in layer_proxy.rs. + let region_grid = RegionGridLayer { + cols: 2, + rows: 1, + season: vec![SeasonPhase::Summer as u8, SeasonPhase::Winter as u8], + weather: vec![WeatherState::Clear as u8, WeatherState::Snow as u8], + mean_temp_dc: vec![ + 123, + settled_reach_server::atlas::layer_proxy::REGION_TEMP_NONE_DC, + ], + moisture_q: vec![80, 5], + }; + // T-1119 (D-226 T-1112 amendment): a populated QuarterFootprintLayer + // covering both fixture settlements — city_id 1 (Port Aldren, a dense + // Commercial-dominant quarter with landmarks/corridors) and city_id 2 + // (Farmstead Rell, a sparse Residential-dominant quarter with neither), + // so the fixture exercises both a "rich" entry and a "minimal" entry + // rather than only one shape. + let quarter_footprints = QuarterFootprintLayer { + entries: BTreeMap::from([ + ( + 1, + QuarterFootprintEntry { + city_id: 1, + density_avg_pct: 62, + dominant_district_type: DistrictType::Commercial, + dominant_zoning: ZoningType::Commercial, + landmark_count: 3, + corridor_count: 4, + }, + ), + ( + 2, + QuarterFootprintEntry { + city_id: 2, + density_avg_pct: 18, + dominant_district_type: DistrictType::Residential, + dominant_zoning: ZoningType::Residential, + landmark_count: 0, + corridor_count: 1, + }, + ), + ]), + }; let ready = AtlasLayerResponse { body_id: "GJ1c".into(), @@ -624,7 +675,8 @@ fn generate_atlas_layer_response_fixtures() { district_grid: None, road_graph: Some(road_graph), settlements: Some(settlements), - region_grid: None, + region_grid: Some(region_grid), + quarter_footprints: Some(quarter_footprints), }; write_fixture( "atlas_response_ready", @@ -639,6 +691,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }; write_fixture( "atlas_response_pending", @@ -653,6 +706,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }; write_fixture( "atlas_response_not_found",