Compare commits

...
8 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Fable 5 71f70f035e style(simulation): gate bounce — cargo fmt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:53:41 +02:00
jpmschweitzer 497f9dc94d Merge remote-tracking branch 'origin/main' into atlas-feature-names
# Conflicts:
#	CHANGELOG.md
2026-07-25 16:44:51 +02:00
jpmschweitzerandClaude Fable 5 1eb596ec78 chore(meta): update changelog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:11:12 +02:00
jpmschweitzerandClaude Fable 5 8da9670e0f feat(simulation): feature-name pipeline wired + legacy window_granularity u32 retired (T-1169, T-1159)
One commit for two tickets whose changes share the bridge/plugin
plumbing files. T-1169 connects the three dormant feature-name pieces:
atlas_feature_names populated at regen (17,891 rows — 15,190 mountain,
2,701 river — via populate_atlas_feature_names mirroring the city-names
importer; systems.db regenerated, stamp fresh), attach_feature_names
wired into the cascade's Topography block with name pools threaded
DB-free through AnalyzeBody (D-225 pattern) and assignments stored on
Layer1Output/BodyWorldState for future consumers, and a
FeatureNamesRequest/Response read proxy as the bridge's 7th tagged
envelope (D-236 pattern, both SimBridge impls). Client label DRAW is
deliberately NOT here — implementation proved both river and mountain
labels need a wire-carried position (the pool is position-free; course
polylines aren't correlated with the named attractors by construction) —
deferred to T-1195's single design pass. cascade_layer1 golden re-pinned
(additive feature_names field).

T-1159 retires the legacy u32 granularity field fully shadowed by
window_granularity_v2: AtlasLayerRequest.window_granularity,
DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and
resolve_window_granularity are gone server-side; client encode paths and
the caller-less atlas_window_cache legacy key component dropped;
msgpack fixtures regenerated; the T-1150 aliasing regression test now
drives through the surviving enum field. The district_window carrier
itself survives byte-compatible per D-255(c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:10:27 +02:00
jpmschweitzerandClaude Fable 5 befdb689c1 fix(simulation): orbital rung applies coast_warp_px — one coastline at every rung (T-1160)
The Global/Region rungs sampled the heightmap ocean mask raw while every
finer rung samples it at the coast-warped position — a structurally
different coastline at the orbital-to-district seam. Audited on all 267
real body heightmaps at region spacing, coastal band only: 5.01%
land/ocean classification disagreement (2,911 of 58,073 cells), mean
displacement ~6.0 km, max ~18.9 km. Fork taken: APPLY the warp —
derive_orbital_at_metres now runs invent_primitives' steps 1-3 (driver
climate -> coast character -> coast_warp_px) before sampling, still
skipping detail-scatter (its octave ceiling, 32.8 km, is below region
spacing; the WARP's octaves reach 262 km, which is why skipping it was
wrong). Cost measured: ~784 ns/cell added (~15 ms on an Earth-class
Global canvas; low hundreds of ms parallel at the 8.3M-cell ceiling).
The ignored audit test preserves the pre-fix baseline for the record.
Golden re-pinned — region rows only, district/quarter byte-identical.
project.yaml 0.4.2 -> 0.4.3: orbital canvas bytes changed, client disk
caches must miss. Awaiting Araminta's review-seat sign-off per ticket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:09:46 +02:00
jpmschweitzerandClaude Fable 5 686021bee8 fix(ui): PR #207 review round — crop-gated taper, true mitres, hybrid AA (T-1175)
Five findings from the araminta+hoshe round. Tapering now fires only on
a TRUE upstream source: the course's first raw world point is tested
against the canvas's own world bounds (conservative 1m epsilon) —
exact detection because the server crop keeps one point beyond the
window (layer_proxy crop_course_to_window lo = first_in-1, contract
documented), so crop passthroughs draw the old flat full-width cut and
never a false headwater. The averaged-normal joint is replaced by a
real mitre (half_w/cos(theta/2) recovered trig-free via the bisector
normal), clamped by a 2x mitre limit AND 0.45x the shorter adjacent
segment — restoring true perpendicular width at bends (the 29% pinch at
confluences is gone) and preventing the hairpin bowtie; the winding doc
now states the actual bounded guarantee. Antialiasing restored via the
hybrid: only the varying-width taper head draws as a ribbon; the
constant-width ~85% of every course keeps the original antialiased
draw_polyline (byte-identical for untapered courses), split at an
interpolated arc-length point sharing position and width — junction
capture evidence in .cache/screenshots/t1175-fix-round/. Flat-fill
single-element color array. Suite 24 -> 48 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:05:17 +02:00
jpmschweitzerandClaude Fable 5 28d2e6c80b chore(meta): update changelog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:25:58 +02:00
jpmschweitzerandClaude Fable 5 40cf89c3cb feat(ui): river source tapering + width-grammar retune — map fluency pass (T-1175)
The single large polish pass Jeroen requested off the T-1170 captures,
benchmarked against RimWorld's world-map fluency. Courses now draw as
source-tapered ribbons: per-vertex width ramps from a hairline at the
upstream source to full class width over 15% of the course's arc length
(arc-length parameterized, not vertex-indexed, so point density doesn't
change the read), built as one draw_polygon ribbon with mitred joins.
Class width table retuned 0.9/1.4/2.2 -> 0.6/1.2/2.4: a clean ~2x
per-class ladder so a tributary-joins-trunk confluence reads as a join,
trunk held at its visually-proven weight, stream thinner per the
benchmark's thin/consistent/restrained grammar. Opacity untouched
(Araminta's T-1170 ruling stands). Coast-gradient item resolved as
already-correct (the coastal transition zones + elevation lightness
render the shoreline band; capture-verified) — no wire change. Stipple
assessment filed as T-1194. Eight new taper-geometry tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:25:30 +02:00
34 changed files with 2254 additions and 516 deletions
+6
View File
@@ -6,7 +6,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Changed
- **Rivers read like rivers** (T-1175) — map courses now taper to a point at their upstream source instead of starting at full width (the classic cartographic river grammar), and the stream/tributary/trunk width ladder was retuned so a tributary joining a trunk visibly reads as a join. Part of the map-fluency polish pass benchmarked against the best-in-class world maps
- **Rivers and mountain ranges now have names behind the map** (T-1169) — every body's rivers and peaks are assigned names from the curated per-system pools (17,891 names across the Reach) during world generation, queryable over the wire. The labels that will draw them on the Atlas come in a follow-up; the naming layer underneath is live
- **Internal: legacy map-window field retired** (T-1159) — the obsolete duplicate zoom-granularity field was removed from the map wire protocol and caches; no player-visible change
### Fixed
- **One coastline at every zoom step** (T-1160) — the whole-body and Region views drew a structurally different coastline than the closer zoom steps (they skipped the deterministic coast-detail warp — about 5% of coastal cells disagreed, displaced up to ~19 km). All six zoom steps now draw the same coastline at their own density: zooming in refines the line instead of moving it. (Game version → 0.4.3: old on-disk map caches refresh automatically)
- **Climate now respects latitude on both hemispheres** (T-1186) — a sign-convention bug made every northern hemisphere read as polar (+90°) and the southern hemisphere read as compressed northern tropics: temperate lakes at 76°S, ice only at the top of the world map. Temperature baselines, glaciation, and everything derived from them now band correctly north AND south of the equator — the same spot near the antarctic circle that used to derive as an 18 °C ice-free lake now comes out at 2 °C under light glaciation
- **Lakes deepen from the shore** (T-1188) — lake shorelines no longer render as hard flat-blue step-edges: every lake cell now carries its settled-hydrology depth band, and the map shades lakes from pale at the water's edge to dark at the deepest point of the basin — the same visual grammar ocean coastlines already had — each lake shaded relative to its own deepest point, so shallow ponds and deep basins both read correctly. (Game version 0.4.0 → 0.4.2: old on-disk map caches refresh automatically)
- **The Atlas map now fills the screen properly** (T-1189, T-1192) — the whole-body Global view now fills the frame at an integer texel scale (never blurry, never a sliver in the corner) and centers in the viewport, with the legend sitting beside the map instead of on top of it. The Region zoom step no longer shows the planet repeating side-by-side or smearing into stripes past the poles — the map view is capped at the body's actual size and letterboxed, so what you see is the planet once, correctly framed
+10 -10
View File
@@ -434,21 +434,22 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void
## whole-body-layer caller (show_body()'s existing request), so their wire
## traffic is byte-unchanged.
##
## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the
## zoom-ladder quarter rung — district (0/omitted) stays the default for
## every caller in this codebase today.
## window_min_wl_m (T-1150): struct/key plumbing for the octave cutoff — 0
## (omitted) stays the default for every caller in this codebase today.
##
## window_granularity_v2 (T-1152/T-1153): the R5-redesigned string-tag
## granularity ("Quarter"/"District"/"Region") — the ONLY way to request the
## coarser-than-district Region rung the legacy u32 field cannot express.
## Empty string (omitted) is the default for every caller that doesn't pass
## it, byte-compatible with every pre-T-1152 request.
## window_granularity_v2 (T-1152/T-1153): the string-tag granularity
## ("Quarter"/"District"/"Region") — the way to request the coarser-than-
## district Region rung. Empty string (omitted) is the default for every
## caller that doesn't pass it, resolving to District server-side.
##
## T-1159: the legacy `window_granularity: int` parameter this function used
## to also forward is retired — see atlas_map_protocol.gd's
## encode_atlas_layer_request doc for the full rationale.
func request_atlas_layers(
body_id: String,
up_to: String = "Topography",
window_center: Variant = null,
window_n: int = 0,
window_granularity: int = 0,
window_min_wl_m: int = 0,
window_granularity_v2: String = ""
) -> void:
@@ -459,7 +460,6 @@ func request_atlas_layers(
up_to,
window_center,
window_n,
window_granularity,
window_min_wl_m,
window_granularity_v2
)
+14 -19
View File
@@ -32,29 +32,27 @@ class_name AtlasMapProtocol
## client-side default/cap constants (DISTRICT_WINDOW_DEFAULT_N/MAX_N) live on
## the regional-window viewer, not duplicated into the codec.
##
## `window_granularity`/`window_min_wl_m` (T-1150): the derivation-granularity
## axis (district=1/omitted vs. quarter=4) and the octave cutoff, in whole
## metres. Both OMITTED (not sent as 0) when at their default — this is
## struct/key plumbing only (T-1150 scope): no caller in this codebase
## requests quarter granularity yet (that's T-1153); this function just makes
## it possible to ask, byte-compatible with every existing caller that
## doesn't pass them.
## `window_min_wl_m` (T-1150): the octave cutoff, in whole metres. OMITTED
## (not sent as 0) when at its default — struct/key plumbing, byte-compatible
## with every existing caller that doesn't pass it.
##
## `window_granularity_v2` (T-1152, R5 redesign — see
## server/src/atlas/layer_proxy.rs's `WindowGranularity` doc): the ONLY way to
## express a coarser-than-district rung (`"Region"`) the legacy `u32` field
## cannot encode. A plain STRING variant tag ("Quarter" | "District" |
## server/src/atlas/layer_proxy.rs's `WindowGranularity` doc): the derivation
## granularity, a plain STRING variant tag ("Quarter" | "District" |
## "Region"), matching `RoadNodeKind`'s existing wire precedent on this same
## carrier (a bare `#[derive(Serialize, Deserialize)]` enum with no
## `#[serde(rename_all)]` — rmp_serde encodes the Rust variant NAME verbatim,
## not an integer discriminant). OMITTED (not sent as "") when
## `window_granularity_v2` is the empty string — `#[serde(default)]` on the
## Rust side decodes absence as `None`, falling back to the legacy `u32`
## field's `resolve_window_granularity_v2()` precedence rule (that field wins
## over the legacy one whenever present — see that Rust doc for the full
## precedence contract). Every pre-T-1152 caller (and every T-1150 caller that
## only ever sends `window_granularity`) omits this field entirely and stays
## byte-compatible.
## Rust side decodes absence as `None`, resolving to District (the default
## rung).
##
## **T-1159:** the legacy `window_granularity: int` field this codec used to
## also encode (T-1150's finer-than-district-only `u32` wire encoding) is
## retired — `window_granularity_v2` fully shadowed it since T-1152, and no
## caller in this codebase (nor any external client — single-repo
## client/server pair) ever sent it as anything but the byte-compatible
## default.
##
## **Quantization split (PR #191 review, Hoshe 1 / Tyre C3):** `window_min_wl_m`
## is sent HERE as a raw, unquantized value — this codec does NOT snap it to
@@ -71,7 +69,6 @@ static func encode_atlas_layer_request(
up_to: String = "Topography",
window_center: Variant = null,
window_n: int = 0,
window_granularity: int = 0,
window_min_wl_m: int = 0,
window_granularity_v2: String = ""
) -> PackedByteArray:
@@ -80,8 +77,6 @@ static func encode_atlas_layer_request(
var center: Vector2i = window_center
msg["window_center"] = [center.x, center.y]
msg["window_n"] = window_n
if window_granularity != 0:
msg["window_granularity"] = window_granularity
if window_min_wl_m != 0:
msg["window_min_wl_m"] = window_min_wl_m
if not window_granularity_v2.is_empty():
+7 -7
View File
@@ -786,17 +786,18 @@ static func encode_request_bookmark_catalog() -> PackedByteArray:
## windowed district-resolution regional-map query — see
## atlas_map_protocol.gd's encode_atlas_layer_request doc for the wire shape.
## Omitted callers (every whole-body-layer call site predating T-1138) are
## byte-unchanged. window_granularity/window_min_wl_m (T-1150): same
## byte-compatibility contract, see atlas_map_protocol.gd.
## window_granularity_v2 (T-1152): the R5-redesigned string-tag granularity
## ("Quarter"/"District"/"Region") — the only way to request the Region rung.
## Omitted (empty string) by every caller that doesn't pass it.
## byte-unchanged. window_min_wl_m (T-1150): same byte-compatibility
## contract, see atlas_map_protocol.gd.
## window_granularity_v2 (T-1152): the string-tag granularity
## ("Quarter"/"District"/"Region"). Omitted (empty string) by every caller
## that doesn't pass it, resolving to District server-side. T-1159: the
## legacy `window_granularity: int` parameter this used to also forward is
## retired — see atlas_map_protocol.gd's encode_atlas_layer_request doc.
static func encode_atlas_layer_request(
body_id: String,
up_to: String = "Topography",
window_center: Variant = null,
window_n: int = 0,
window_granularity: int = 0,
window_min_wl_m: int = 0,
window_granularity_v2: String = ""
) -> PackedByteArray:
@@ -806,7 +807,6 @@ static func encode_atlas_layer_request(
up_to,
window_center,
window_n,
window_granularity,
window_min_wl_m,
window_granularity_v2
)
Binary file not shown.
+21 -27
View File
@@ -214,35 +214,32 @@ func test_encode_atlas_layer_request_carries_window_params() -> void:
assert_that(decoded.value.get("window_n")).is_equal(32)
## T-1150: window_granularity/window_min_wl_m are OMITTED (not sent as 0)
## when at their default — a windowed request that doesn't pass them (every
## pre-T-1150 window caller) is byte-identical to pre-T-1150 wire traffic,
## same contract as window_center/window_n's own default-omission above.
func test_encode_atlas_layer_request_omits_granularity_and_min_wl_by_default() -> void:
## T-1150: window_min_wl_m is OMITTED (not sent as 0) when at its default —
## a windowed request that doesn't pass it (every pre-T-1150 window caller)
## is byte-identical to pre-T-1150 wire traffic, same contract as
## window_center/window_n's own default-omission above.
func test_encode_atlas_layer_request_omits_min_wl_by_default() -> void:
var bytes := Protocol.encode_atlas_layer_request(
"GJ1c", "Topography", Vector2i(140, 260), 32
)
var decoded = Messagepack.decode(bytes)
assert_bool(decoded.value.has("window_granularity")).is_false()
assert_bool(decoded.value.has("window_min_wl_m")).is_false()
## T-1150: a quarter-granularity request with an octave cutoff carries both
## new fields verbatim, unclamped (the server owns
## resolve_window_granularity()/clamp_window_n() — never trusted from the
## wire, same posture as window_n).
func test_encode_atlas_layer_request_carries_granularity_and_min_wl() -> void:
## T-1150: a request with an octave cutoff carries it verbatim, unclamped
## (the server owns quantize_min_wl_m() — never trusted from the wire, same
## posture as window_n).
func test_encode_atlas_layer_request_carries_min_wl() -> void:
var bytes := Protocol.encode_atlas_layer_request(
"GJ1c", "Topography", Vector2i(140, 260), 32, 4, 512
"GJ1c", "Topography", Vector2i(140, 260), 32, 512
)
var decoded = Messagepack.decode(bytes)
assert_that(decoded.value.get("window_granularity")).is_equal(4)
assert_that(decoded.value.get("window_min_wl_m")).is_equal(512)
## T-1152/T-1153: window_granularity_v2 is OMITTED (not sent as "") when at
## its empty-string default — same byte-compatibility contract as
## window_granularity/window_min_wl_m's own default-omission.
## window_min_wl_m's own default-omission.
func test_encode_atlas_layer_request_omits_granularity_v2_by_default() -> void:
var bytes := Protocol.encode_atlas_layer_request(
"GJ1c", "Topography", Vector2i(140, 260), 32
@@ -252,22 +249,20 @@ func test_encode_atlas_layer_request_omits_granularity_v2_by_default() -> void:
## T-1152/T-1153: a Region-rung request carries window_granularity_v2 as the
## bare string "Region" — the ONLY way to express the coarser-than-district
## rung (WindowGranularity's Rust doc: "the ONLY way to actually request
## Region is window_granularity_v2 = Some(WindowGranularity::Region)"), a
## plain rmp_serde variant-name encoding matching RoadNodeKind's existing
## wire precedent, NOT an integer discriminant.
## bare string "Region" — the way to express the coarser-than-district rung
## (WindowGranularity's Rust doc), a plain rmp_serde variant-name encoding
## matching RoadNodeKind's existing wire precedent, NOT an integer
## discriminant.
##
## T-1159: the legacy `window_granularity: int` param this call used to also
## pass positionally (between window_n and window_min_wl_m) is retired — see
## atlas_map_protocol.gd's encode_atlas_layer_request doc.
func test_encode_atlas_layer_request_carries_granularity_v2_region() -> void:
var bytes := Protocol.encode_atlas_layer_request(
"GJ1c", "Topography", Vector2i(0, 0), 6400, 0, 0, "Region"
"GJ1c", "Topography", Vector2i(0, 0), 6400, 0, "Region"
)
var decoded = Messagepack.decode(bytes)
assert_that(decoded.value.get("window_granularity_v2")).is_equal("Region")
# The legacy window_granularity field is independently omittable — a
# Region request sends ONLY the v2 tag, never a legacy value pretending
# to mean something for Region (WINDOW_GRANULARITY_REGION_KEY is a
# key-space tag the SERVER echoes, never a legal wire INPUT).
assert_bool(decoded.value.has("window_granularity")).is_false()
## §2: district_window is a distinct payload (echoes center/n for the
@@ -280,7 +275,7 @@ func test_atlas_response_district_window_passthrough() -> void:
var window := {
"center": [140, 260],
"n": 32,
"granularity": 4, # T-1150: quarter granularity, passed through same as every other field
"granularity_v2": "Quarter",
"min_wl_m": 512,
"morphology": PackedByteArray([8, 14, 0, 5]),
"elev_q": PackedByteArray([40, 62, 5, 88]),
@@ -307,7 +302,6 @@ func test_atlas_response_district_window_passthrough_region_rung() -> void:
var window := {
"center": [0, 0],
"n": 6400,
"granularity": 4294967295, # WINDOW_GRANULARITY_REGION_KEY (u32::MAX) — key-space tag, not a real multiplier
"granularity_v2": "Region",
"min_wl_m": 0,
"morphology": PackedByteArray([8, 14, 0, 5]),
+32 -84
View File
@@ -122,100 +122,58 @@ func test_clear_empties_the_cache() -> void:
# =============================================================================
# granularity / min_wl_m (T-1150, zoom ladder design doc §3 aliasing risk)
# min_wl_m (T-1150, zoom ladder design doc §3 aliasing risk)
# =============================================================================
## **MANDATORY aliasing regression (client half, T-1150):** a granularity-4
## (quarter) key and a granularity-1 (district) key at the IDENTICAL
## (body_id, center, n) must be DISTINCT cache keys — this is what prevents a
## quarter-spacing request from silently reading (or overwriting) a
## district-spacing window's cache entry, and vice versa.
func test_make_key_distinguishes_granularity_at_identical_body_center_n() -> void:
var k_district := AtlasWindowCache.make_key(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY
)
var k_quarter := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 4)
assert_str(k_district).is_not_equal(k_quarter)
## Same aliasing risk, the other new axis: two requests identical except for
## `min_wl_m` (the octave cutoff) must not collide either — different cutoffs
## are different derived payloads (T-1149/T-1150).
func test_make_key_distinguishes_min_wl_m_at_identical_body_center_n_granularity() -> void:
var k_uncut := AtlasWindowCache.make_key(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0
)
var k_cut := AtlasWindowCache.make_key(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 512
)
## **MANDATORY aliasing regression (client half, T-1150):** two requests
## identical except for `min_wl_m` (the octave cutoff) must not collide —
## different cutoffs are different derived payloads (T-1149/T-1150).
func test_make_key_distinguishes_min_wl_m_at_identical_body_center_n() -> void:
var k_uncut := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 0)
var k_cut := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 512)
assert_str(k_uncut).is_not_equal(k_cut)
## Omitting granularity/min_wl_m (every pre-T-1150 call site) must produce the
## SAME key as passing the explicit district/no-cutoff defaults — byte/string
## compatibility for existing callers, not just "doesn't crash".
func test_omitted_granularity_and_min_wl_m_match_explicit_district_defaults() -> void:
## Omitting min_wl_m (every pre-T-1150 call site) must produce the SAME key
## as passing the explicit no-cutoff default — byte/string compatibility for
## existing callers, not just "doesn't crash".
func test_omitted_min_wl_m_matches_explicit_default() -> void:
var k_omitted := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32)
var k_explicit := AtlasWindowCache.make_key(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0
)
var k_explicit := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 0)
assert_str(k_omitted).is_equal(k_explicit)
## End-to-end through put()/get_window()/has() (not just make_key() in
## isolation): a quarter-granularity window and a district-granularity window
## at the identical (body, center, n) must both be independently retrievable,
## neither one clobbering or masking the other.
func test_district_and_quarter_windows_coexist_at_identical_body_center_n() -> void:
var cache := AtlasWindowCache.new()
var district_window := {"granularity": AtlasWindowCache.DISTRICT_GRANULARITY, "id": "district"}
var quarter_window := {"granularity": 4, "id": "quarter"}
cache.put("GJ1c", Vector2i(10, 20), 32, district_window, AtlasWindowCache.DISTRICT_GRANULARITY)
cache.put("GJ1c", Vector2i(10, 20), 32, quarter_window, 4)
assert_int(cache.size()).is_equal(2)
assert_that(
cache.get_window("GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY)
).is_equal(district_window)
assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32, 4)).is_equal(quarter_window)
# =============================================================================
# granularity_v2 (T-1152/T-1153): the string-tag axis — the ONLY thing that
# distinguishes Region from District/Quarter, since Region has no legal
# legacy-int representation (WindowGranularity::legacy_u32() returns None for
# Region — see the server's own doc). This is the SAME mandatory-aliasing
# regression class as the granularity/min_wl_m tests above, extended to the
# new axis.
# distinguishes Region from District/Quarter (WindowGranularity has no
# integer representation at all — see the server's own doc). This is the
# SAME mandatory-aliasing regression class as the min_wl_m tests above,
# extended to the new axis.
#
# T-1159: these tests used to also carry a legacy int `granularity`
# positional argument (mirroring the server's now-retired
# `window_granularity: u32` wire field) — removed along with the cache's own
# legacy key component (see atlas_window_cache.gd's doc).
# =============================================================================
## **MANDATORY aliasing regression (T-1152/T-1153):** a Region-rung key and a
## District-rung key at the IDENTICAL (body_id, center, n, legacy
## granularity, min_wl_m) must be DISTINCT cache keys — the legacy int slot
## alone (both "District" and default-omitted callers pass
## DISTRICT_GRANULARITY=1) cannot tell them apart; granularity_v2 is what
## does.
## District-rung key at the IDENTICAL (body_id, center, n, min_wl_m) must be
## DISTINCT cache keys — granularity_v2 is the only axis that can tell them
## apart.
func test_make_key_distinguishes_granularity_v2_region_from_district() -> void:
var k_district := AtlasWindowCache.make_key(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District"
)
var k_region := AtlasWindowCache.make_key(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "Region"
)
var k_district := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 0, "District")
var k_region := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 0, "Region")
assert_str(k_district).is_not_equal(k_region)
## Omitting granularity_v2 (every pre-T-1152 call site) must produce the SAME
## key as passing the explicit "District" default — byte/string
## compatibility, same contract as the legacy granularity/min_wl_m defaults.
## compatibility, same contract as the min_wl_m default above.
func test_omitted_granularity_v2_matches_explicit_district_default() -> void:
var k_omitted := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32)
var k_explicit := AtlasWindowCache.make_key(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District"
)
var k_explicit := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 0, "District")
assert_str(k_omitted).is_equal(k_explicit)
@@ -229,23 +187,13 @@ func test_region_and_district_windows_coexist_at_identical_body_center_n() -> vo
var district_window := {"granularity_v2": "District", "id": "district"}
var region_window := {"granularity_v2": "Region", "id": "region"}
cache.put(
"GJ1c", Vector2i(10, 20), 32, district_window, AtlasWindowCache.DISTRICT_GRANULARITY, 0,
"District"
)
cache.put(
"GJ1c", Vector2i(10, 20), 32, region_window, AtlasWindowCache.DISTRICT_GRANULARITY, 0,
"Region"
)
cache.put("GJ1c", Vector2i(10, 20), 32, district_window, 0, "District")
cache.put("GJ1c", Vector2i(10, 20), 32, region_window, 0, "Region")
assert_int(cache.size()).is_equal(2)
assert_that(
cache.get_window(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District"
)
cache.get_window("GJ1c", Vector2i(10, 20), 32, 0, "District")
).is_equal(district_window)
assert_that(
cache.get_window(
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "Region"
)
cache.get_window("GJ1c", Vector2i(10, 20), 32, 0, "Region")
).is_equal(region_window)
+11 -7
View File
@@ -512,13 +512,17 @@ func test_decode_atlas_response_not_found() -> void:
## PR #191 review, Hoshe 3: `atlas_response_ready_with_window.msgpack` had NO
## consumer anywhere in client/tests — regenerated by the T-1150 `granularity`/
## `min_wl_m` field additions but nothing decoded it through the real IPC path.
## This is that consumer, matching the sibling `test_decode_atlas_response_*`
## tests' style/fixture-dir convention above: full decode_atlas_layer_response()
## consumer anywhere in client/tests — regenerated by the T-1150 `min_wl_m`
## field addition but nothing decoded it through the real IPC path. This is
## that consumer, matching the sibling `test_decode_atlas_response_*` tests'
## style/fixture-dir convention above: full decode_atlas_layer_response()
## round trip (not a hand-built Dictionary like test_atlas_data_delivery.gd's
## passthrough tests), confirming `district_window.granularity`/`.min_wl_m`
## (T-1150's two new echo fields) survive the real client decode path.
## passthrough tests), confirming `district_window.granularity_v2`/`.min_wl_m`
## survive the real client decode path.
##
## T-1159: the legacy `district_window.granularity` int echo this test used
## to also assert is retired server-side — see
## server/src/atlas/layer_proxy.rs's `DistrictWindowLayer` doc.
func test_decode_atlas_response_ready_with_window() -> void:
var bytes := _load_fixture("atlas_response_ready_with_window")
var resp = Protocol.decode_atlas_layer_response(bytes)
@@ -528,7 +532,7 @@ func test_decode_atlas_response_ready_with_window() -> void:
var window: Dictionary = resp.district_window
assert_that(window.get("center")).is_equal([10, -5])
assert_that(int(window.get("n"))).is_equal(2)
assert_that(int(window.get("granularity"))).is_equal(1)
assert_that(str(window.get("granularity_v2"))).is_equal("District")
assert_that(int(window.get("min_wl_m"))).is_equal(0)
@@ -74,3 +74,312 @@ func test_world_to_local_uses_the_held_frame() -> void:
world_center, world_center, "Quarter", extent
)
assert_that(layer._world_to_local(world_center)).is_equal_approx(expected, Vector2(0.01, 0.01))
# -----------------------------------------------------------------------
# T-1175 seeded item 2 — source-taper ribbon geometry
# -----------------------------------------------------------------------
## A straight 5-point course (evenly spaced, 10px apart along +X) — the
## simplest case for pinning the arc-length taper ramp: cumulative length
## at vertex i is exactly i*10, total 40, so the taper window
## (TAPER_ARC_FRACTION * 40 = 6px) falls strictly inside the first segment.
func _straight_course_points(spacing_px: float = 10.0) -> PackedVector2Array:
var pts := PackedVector2Array()
for i in range(5):
pts.append(Vector2(float(i) * spacing_px, 0.0))
return pts
## Vertex 0 (the source, cumulative length 0) gets the hairline minimum
## width, never the class's full width — this is the taper's whole point.
## _head_widths_by_arc_length() is handed the HEAD span only (post PR #207
## finding 4's ribbon/polyline split) — this test exercises it directly on
## a short head span (the first two points), which is what
## _split_course_at_arc_length() would hand it for this same course.
func test_head_widths_by_arc_length_starts_at_the_taper_minimum() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var head := PackedVector2Array([Vector2(0.0, 0.0), Vector2(6.0, 0.0)])
var widths: PackedFloat32Array = layer._head_widths_by_arc_length(head, 2.4)
assert_float(widths[0]).is_equal_approx(StepCanvasAnnotationLayer.TAPER_MIN_WIDTH_PX, 0.001)
## The head's own LAST vertex always ramps to exactly full_width — that's
## the butt-joint contract _draw_tapered_course() relies on to hand off to
## the AA polyline tail at identical width.
func test_head_widths_by_arc_length_ends_at_full_width() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var head := PackedVector2Array([Vector2(0.0, 0.0), Vector2(3.0, 0.0), Vector2(6.0, 0.0)])
var widths: PackedFloat32Array = layer._head_widths_by_arc_length(head, 2.4)
assert_float(widths[2]).is_equal_approx(2.4, 0.001)
## The ramp is monotonically non-decreasing from source to the head's last
## vertex — no "wobble" where a later vertex is narrower than an earlier one.
func test_head_widths_by_arc_length_is_monotonic() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var head := _straight_course_points(1.0)
var widths: PackedFloat32Array = layer._head_widths_by_arc_length(head, 2.4)
for i in range(1, widths.size()):
assert_float(widths[i]).is_greater_equal(widths[i - 1])
## A degenerate two-point head where both points coincide (zero-length)
## must not divide by zero — every vertex falls back to full width rather
## than crashing or producing NaN.
func test_head_widths_by_arc_length_handles_a_degenerate_zero_length_span() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var head := PackedVector2Array([Vector2(5.0, 5.0), Vector2(5.0, 5.0)])
var widths: PackedFloat32Array = layer._head_widths_by_arc_length(head, 2.4)
assert_float(widths[0]).is_equal_approx(2.4, 0.001)
assert_float(widths[1]).is_equal_approx(2.4, 0.001)
# -----------------------------------------------------------------------
# PR #207 finding 4 — head/tail split (the AA-hybrid seam)
# -----------------------------------------------------------------------
## The split point lands EXACTLY at TAPER_ARC_FRACTION of the total arc
## length, interpolated within the straddling segment — not snapped to the
## nearest existing vertex (see _split_course_at_arc_length()'s own doc for
## why interpolation, not snapping, is required).
func test_split_course_at_arc_length_interpolates_the_exact_fraction() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
# Total length 40 (4 segments of 10px); taper fraction 0.15 -> split at
# arc-length 6, which is 60% of the way through the FIRST segment
# (0 -> 10), i.e. at x=6.
var pts := _straight_course_points()
var split: Array = layer._split_course_at_arc_length(pts, StepCanvasAnnotationLayer.TAPER_ARC_FRACTION)
var head: PackedVector2Array = split[0]
var tail: PackedVector2Array = split[1]
assert_vector(head[head.size() - 1]).is_equal_approx(Vector2(6.0, 0.0), Vector2(0.001, 0.001))
assert_vector(tail[0]).is_equal_approx(Vector2(6.0, 0.0), Vector2(0.001, 0.001))
## The head and tail share their boundary point EXACTLY (the butt-joint
## contract) — no gap, no overlap.
func test_split_course_at_arc_length_head_and_tail_share_the_boundary_point() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var pts := _straight_course_points()
var split: Array = layer._split_course_at_arc_length(pts, StepCanvasAnnotationLayer.TAPER_ARC_FRACTION)
var head: PackedVector2Array = split[0]
var tail: PackedVector2Array = split[1]
assert_vector(head[head.size() - 1]).is_equal(tail[0])
## `_split_course_at_arc_length()` is a generic arc-length splitter (the
## `t_fraction` parameter is not hardwired to TAPER_ARC_FRACTION) — when the
## requested fraction covers the WHOLE course (t_fraction >= 1.0, "the taper
## window would run past the mouth"), there is no meaningful post-split
## span: the whole course is the head, tail is empty. TAPER_ARC_FRACTION
## itself (0.15) can never trigger this branch for a real course (any
## positive-length course has SOME arc beyond 15% of itself) — this pins
## the branch directly via an out-of-the-ordinary fraction, the same way a
## unit test for a generic clamp function exercises both ends of its range
## regardless of what the one real call site happens to pass.
func test_split_course_at_arc_length_returns_empty_tail_when_fraction_covers_the_whole_course() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var pts := PackedVector2Array([Vector2(0.0, 0.0), Vector2(1.0, 0.0)])
var split: Array = layer._split_course_at_arc_length(pts, 1.0)
var head: PackedVector2Array = split[0]
var tail: PackedVector2Array = split[1]
assert_int(tail.size()).is_equal(0)
assert_int(head.size()).is_equal(pts.size())
## The real call site's fraction (TAPER_ARC_FRACTION, 0.15) DOES still split
## even a very short two-point course — the split point just lands close to
## the source rather than at the mouth, and both head and tail are
## non-empty. This is the behavior _draw_tapered_course() actually relies
## on for a minimal two-point interior-source course.
func test_split_course_at_arc_length_still_splits_a_short_two_point_course() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var pts := PackedVector2Array([Vector2(0.0, 0.0), Vector2(1.0, 0.0)])
var split: Array = layer._split_course_at_arc_length(pts, StepCanvasAnnotationLayer.TAPER_ARC_FRACTION)
var head: PackedVector2Array = split[0]
var tail: PackedVector2Array = split[1]
assert_int(head.size()).is_equal(2)
assert_int(tail.size()).is_equal(2)
assert_vector(head[head.size() - 1]).is_equal_approx(Vector2(0.15, 0.0), Vector2(0.001, 0.001))
## A degenerate (zero-length, coincident-point) course must not divide by
## zero in the split math — falls back to "whole course is the head".
func test_split_course_at_arc_length_handles_a_degenerate_zero_length_course() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var pts := PackedVector2Array([Vector2(5.0, 5.0), Vector2(5.0, 5.0)])
var split: Array = layer._split_course_at_arc_length(pts, StepCanvasAnnotationLayer.TAPER_ARC_FRACTION)
var tail: PackedVector2Array = split[1]
assert_int(tail.size()).is_equal(0)
# -----------------------------------------------------------------------
# PR #207 findings 2/3 — mitred offset (perpendicular width at bends,
# clamped against self-intersection at hairpins)
# -----------------------------------------------------------------------
## A perpendicular offset at any point along a straight horizontal course
## points along +/-Y, never +/-X — the ribbon must widen ACROSS the flow
## direction, not along it. On a straight run theta=0, so the mitred offset
## reduces to the plain half-width (no widening).
func test_mitred_offset_is_perpendicular_on_a_straight_course() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var pts := _straight_course_points()
var offset: Vector2 = layer._mitred_offset(pts, 2, 1.0)
assert_float(offset.x).is_equal_approx(0.0, 0.001)
assert_float(absf(offset.y)).is_equal_approx(1.0, 0.001)
## Finding 3 (Hoshe) — at a 90-degree bend, the mitred offset LENGTH is
## half_w / cos(45deg) = half_w * sqrt(2) ~= 1.414 * half_w, which projects
## back to exactly half_w perpendicular to EACH adjacent segment (the true
## width the old averaged-unit-normal joint under-widened by cos(theta/2),
## a 29% pinch). Course: (0,0) -> (10,0) -> (10,10) — a clean right-angle
## turn at the middle vertex.
func test_mitred_offset_at_a_90_degree_bend_restores_perpendicular_width() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var pts := PackedVector2Array([Vector2(0.0, 0.0), Vector2(10.0, 0.0), Vector2(10.0, 10.0)])
var half_w := 1.0
var offset: Vector2 = layer._mitred_offset(pts, 1, half_w)
# The offset's projection onto EITHER adjacent segment's own unit
# normal must equal half_w (the true perpendicular width on both
# faces of the bend) — not the offset's raw length (which is longer,
# by design, along the bisector).
var incoming_normal := Vector2(0.0, 1.0) # normal to the (0,0)->(10,0) segment
var outgoing_normal := Vector2(1.0, 0.0) # normal to the (10,0)->(10,10) segment
assert_float(absf(offset.dot(incoming_normal))).is_equal_approx(half_w, 0.01)
assert_float(absf(offset.dot(outgoing_normal))).is_equal_approx(half_w, 0.01)
## Finding 2 (Hoshe) — a tight hairpin (turn radius below half-width) must
## not produce a self-intersecting bowtie: the mitre offset is clamped to
## HAIRPIN_SEGMENT_FACTOR of the SHORTER adjacent segment length. Course
## with a very short middle segment (length 1) and a near-180-degree turn
## back on itself — an unclamped mitre would blow the offset length far
## past that short segment.
func test_mitred_offset_clamps_at_a_tight_hairpin() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
# (0,0) -> (1,0) -> (0, 0.01): a near-reversal at vertex 1, short
# adjacent segments (length 1 and ~1).
var pts := PackedVector2Array([Vector2(0.0, 0.0), Vector2(1.0, 0.0), Vector2(0.0, 0.01)])
var half_w := 1.0
var offset: Vector2 = layer._mitred_offset(pts, 1, half_w)
var shortest_segment := minf(pts[1].distance_to(pts[0]), pts[2].distance_to(pts[1]))
assert_float(offset.length()).is_less_equal(
shortest_segment * StepCanvasAnnotationLayer.HAIRPIN_SEGMENT_FACTOR + 0.001
)
## The ribbon polygon for an n-point head span has exactly 2n vertices (n on
## each side) — this pins the "side-A then side-B reversed" construction
## produces a closed strip outline with no dropped or duplicated vertex.
func test_head_widths_output_size_matches_head_point_count() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var head := PackedVector2Array([Vector2(0, 0), Vector2(2, 0), Vector2(4, 0), Vector2(6, 0)])
var widths: PackedFloat32Array = layer._head_widths_by_arc_length(head, 2.4)
assert_int(widths.size()).is_equal(head.size())
# -----------------------------------------------------------------------
# PR #207 finding 1 — crop-edge false-headwater detection gate
# -----------------------------------------------------------------------
## An interior source (well inside the canvas bounds) IS a true source —
## tapering fires.
func test_is_true_source_in_canvas_true_for_an_interior_point() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
# District spacing 2048m, extent 4x4 -> half-extent 4096m on each axis.
layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4))
assert_bool(layer._is_true_source_in_canvas(Vector2(1000.0, 2000.0))).is_true()
## A point beyond the canvas's own declared bounds is the one-station crop
## overhang (`crop_course_to_window`'s `lo = first_in.saturating_sub(1)`),
## not a true source — no taper.
func test_is_true_source_in_canvas_false_for_a_point_outside_the_bounds() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4))
# Half-extent is 4096m; world center + 5000m on X is well outside.
assert_bool(layer._is_true_source_in_canvas(Vector2(1000.0 + 5000.0, 2000.0))).is_false()
## A source sitting exactly at the boundary (within CROP_EDGE_EPSILON_M)
## behaves conservatively — treated as OUTSIDE (no taper), per the ruling.
func test_is_true_source_in_canvas_is_conservative_at_the_exact_boundary() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2.ZERO, "District", Vector2i(4, 4))
# Half-extent is 4096m exactly. A point AT the boundary (x=4096) is
# within epsilon of the edge -> conservatively NOT a true source.
assert_bool(layer._is_true_source_in_canvas(Vector2(4096.0, 0.0))).is_false()
## A null/malformed point (defensive — the caller already guards this via
## screen_pts.size() < 2) is conservatively NOT a true source.
func test_is_true_source_in_canvas_false_for_null() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2.ZERO, "District", Vector2i(4, 4))
assert_bool(layer._is_true_source_in_canvas(null)).is_false()
## Godot only allows draw_*() calls INSIDE an active `_draw()`/NOTIFICATION_
## DRAW context (calling `_draw_tapered_course()` directly, outside that
## context, is a Godot Runtime Error, not a code bug) — so the "does not
## crash" smoke check for the taper=false/true routing goes through the SAME
## public entry every other "no crash" test in this suite already uses:
## `set_frame()` + `queue_redraw()` (matches
## `test_set_frame_stores_the_frame_and_triggers_no_crash_on_draw`'s own
## established pattern). This end-to-end path exercises
## `_draw_one_course()`'s routing decision (`_is_true_source_in_canvas()` ->
## `_draw_tapered_course()`'s `taper` argument) for real, without requiring
## a SubViewport or an explicit live-render await — matching this suite's
## own stated "pin the frame state, not pixels" scope. A crop-passthrough
## course (source point OUTSIDE the canvas bounds) exercises the taper=false
## flat-polyline path.
func test_set_frame_with_a_crop_passthrough_course_does_not_crash() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var canvas := {
"width": 4,
"height": 4,
# world_center (0,0), District extent 4x4 -> half-extent 4096m. A
# source at x=-9000 is well outside the canvas bounds — the crop
# overhang case (finding 1).
"courses": [{"class": 2, "points": [[-9000, 0], [0, 0], [10, 0]], "terminus": ""}],
}
layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4))
assert_object(layer).is_not_null()
## An interior-source course (source point inside the canvas bounds)
## exercises the taper=true ribbon-head + polyline-tail hybrid path.
func test_set_frame_with_an_interior_source_course_does_not_crash() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var canvas := {
"width": 4,
"height": 4,
"courses": [{"class": 2, "points": [[0, 0], [500, 0], [1000, 0], [1500, 0]], "terminus": "Mouth"}],
}
layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4))
assert_object(layer).is_not_null()
@@ -2,27 +2,30 @@ extends RefCounted
## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226
## T-1124 amendment §4 "Client cache policy"; extended T-1150 for the
## granularity/min_wl axes; extended T-1152/T-1153 for the granularity_v2
## string-tag axis that makes Region representable at all).
## min_wl axis; extended T-1152/T-1153 for the granularity_v2 string-tag
## axis that makes Region representable at all).
##
## Keyed on (body_id, center, n, granularity, min_wl_m, granularity_v2) —
## D-227's determinism guarantee (same seed + body + position + derivation
## params -> same derived output, always) means a previously-fetched window is
## valid FOREVER for that body+seed. This is an LRU-evict-only cache: no
## freshness check, no TTL, no invalidation path at all. The only reason an
## entry ever leaves is capacity pressure.
## Keyed on (body_id, center, n, min_wl_m, granularity_v2) — D-227's
## determinism guarantee (same seed + body + position + derivation params ->
## same derived output, always) means a previously-fetched window is valid
## FOREVER for that body+seed. This is an LRU-evict-only cache: no freshness
## check, no TTL, no invalidation path at all. The only reason an entry ever
## leaves is capacity pressure.
##
## granularity/min_wl_m/granularity_v2 default to
## DISTRICT_GRANULARITY/0/DEFAULT_GRANULARITY_V2 ("District", district
## spacing, no octave cutoff) — every pre-T-1150 caller that doesn't pass them
## keeps its existing key shape and cache behavior unchanged. This is the
## client half of the mandatory aliasing fix (T-1150 design doc §3, extended
## min_wl_m/granularity_v2 default to 0/DEFAULT_GRANULARITY_V2 ("District",
## no octave cutoff) — every pre-T-1150 caller that doesn't pass them keeps
## its existing key shape and cache behavior unchanged. This is the client
## half of the mandatory aliasing fix (T-1150 design doc §3, extended
## T-1152): a quarter-granularity request, a district-granularity request,
## and a REGION-granularity request all at the identical (body, center, n)
## MUST NOT collide on the same cache slot — the legacy int alone cannot
## distinguish Region (it has no legal legacy-int value, see
## GRANULARITY_V2_REGION's doc), which is exactly why granularity_v2 is a
## SEPARATE key component rather than a replacement for the legacy one.
## MUST NOT collide on the same cache slot — granularity_v2 is what
## distinguishes them.
##
## **T-1159:** the legacy int `granularity` key component (district=1 /
## quarter=4, mirroring the server's now-retired `window_granularity: u32`
## wire field) is retired — `granularity_v2` alone has always been sufficient
## to distinguish every rung (it's the ONLY axis that can express Region at
## all), so carrying both was redundant.
##
## Godot's Dictionary preserves insertion order, so "move to the end on
## touch, evict from the front on overflow" is the whole LRU implementation —
@@ -35,26 +38,16 @@ extends RefCounted
const DEFAULT_MAX_ENTRIES: int = 24
## Mirrors the server's WINDOW_GRANULARITY_DISTRICT (layer_proxy.rs) — the
## default granularity every pre-T-1150 caller implicitly requests.
const DISTRICT_GRANULARITY: int = 1
## Mirrors the server's WindowGranularity enum (T-1152, R5 redesign,
## layer_proxy.rs) — the string-tag vocabulary rmp_serde encodes a bare
## `#[derive(Serialize, Deserialize)]` enum's variant name as, verbatim (same
## wire convention `RoadNodeKind` already established on this carrier). This
## is the KEY-SPACE axis (T-1153): the legacy int `granularity` param below
## stays wired for every existing District/Quarter caller (byte/behavior
## compatible), but a cache slot is now ALSO qualified by this string so a
## Region-rung window can never alias onto a District/Quarter slot at the
## identical (body, center, n, legacy_granularity, min_wl_m) — the exact
## aliasing risk the T-1150 design doc §3 flagged, extended to the new axis.
## wire convention `RoadNodeKind` already established on this carrier).
const GRANULARITY_V2_QUARTER: String = "Quarter"
const GRANULARITY_V2_DISTRICT: String = "District"
const GRANULARITY_V2_REGION: String = "Region"
## Default v2 tag for every caller that doesn't pass one — matches
## DISTRICT_GRANULARITY's own "district is the implicit default" contract, so
## an omitted v2 tag and an explicit "District" tag key identically.
## Default v2 tag for every caller that doesn't pass one — district spacing
## is the implicit default, so an omitted v2 tag and an explicit "District"
## tag key identically.
const DEFAULT_GRANULARITY_V2: String = GRANULARITY_V2_DISTRICT
var _max_entries: int = DEFAULT_MAX_ENTRIES
@@ -65,42 +58,37 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void:
_max_entries = maxi(1, max_entries)
## Build the cache key from the six fields D-227 + T-1150/T-1152 make
## Build the cache key from the five fields D-227 + T-1150/T-1152 make
## sufficient: body_id (which world+body), center (a [row, col] pair or
## Vector2i), n (window extent in districts), granularity (the legacy int:
## district=1 / quarter=4), min_wl_m (the octave cutoff, 0 = none), and
## granularity_v2 (the T-1152 string tag: "Quarter"/"District"/"Region" — the
## axis that actually distinguishes Region from every finer rung, since
## Region has no legal legacy-int representation and the legacy slot alone
## cannot tell a Region window's cache entry apart from a District one at the
## same (center, n)). String-keyed rather than a nested Dictionary/Array key
## — Godot Dictionary keys compare by value for primitives but a consistent
## stringification sidesteps any Vector2i-vs-Array identity mismatch between
## what a caller happens to hand in.
## Vector2i), n (window extent in districts), min_wl_m (the octave cutoff,
## 0 = none), and granularity_v2 (the T-1152 string tag:
## "Quarter"/"District"/"Region"). String-keyed rather than a nested
## Dictionary/Array key — Godot Dictionary keys compare by value for
## primitives but a consistent stringification sidesteps any
## Vector2i-vs-Array identity mismatch between what a caller happens to hand
## in.
static func make_key(
body_id: String,
center: Vector2i,
n: int,
granularity: int = DISTRICT_GRANULARITY,
min_wl_m: int = 0,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> String:
return "%s:%d,%d:%d:%d:%d:%s" % [
body_id, center.x, center.y, n, granularity, min_wl_m, granularity_v2
return "%s:%d,%d:%d:%d:%s" % [
body_id, center.x, center.y, n, min_wl_m, granularity_v2
]
## True if a window is already cached for this exact (body, center, n,
## granularity, min_wl_m, granularity_v2).
## min_wl_m, granularity_v2).
func has(
body_id: String,
center: Vector2i,
n: int,
granularity: int = DISTRICT_GRANULARITY,
min_wl_m: int = 0,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> bool:
return _entries.has(make_key(body_id, center, n, granularity, min_wl_m, granularity_v2))
return _entries.has(make_key(body_id, center, n, min_wl_m, granularity_v2))
## Fetch a cached window, touching it (move-to-most-recently-used). Returns
@@ -112,11 +100,10 @@ func get_window(
body_id: String,
center: Vector2i,
n: int,
granularity: int = DISTRICT_GRANULARITY,
min_wl_m: int = 0,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> Variant:
var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)
var key := make_key(body_id, center, n, min_wl_m, granularity_v2)
if not _entries.has(key):
return null
var value: Variant = _entries[key]
@@ -133,11 +120,10 @@ func put(
center: Vector2i,
n: int,
window: Dictionary,
granularity: int = DISTRICT_GRANULARITY,
min_wl_m: int = 0,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> void:
var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)
var key := make_key(body_id, center, n, min_wl_m, granularity_v2)
if _entries.has(key):
_entries.erase(key)
_entries[key] = window
@@ -49,16 +49,107 @@ const RIVER_CLASS_TRIBUTARY: int = 1
const RIVER_CLASS_TRUNK: int = 2
## Course polyline width/opacity per class — LITERAL screen-space px/alpha,
## no zoom compensation needed (this layer is never scaled). Same functional
## defaults as the retired COURSE_CLASS_WIDTH_PX/COURSE_CLASS_OPACITY tables
## (Araminta's ruling, trunk widest/stream thinnest).
## no zoom compensation needed (this layer is never scaled). T-1175 width-
## grammar retune (Stig, RimWorld fluency pass): the T-1182 defaults read
## trunk-vs-tributary correctly in ORDER but too close together in MAGNITUDE
## (0.9/1.4/2.2 — tributary only 0.64x trunk) for the "visible tributary-
## joins-trunk convergence" the benchmark shows (a trunk reading as roughly
## DOUBLE a tributary's width, a tributary roughly double a stream's, is
## what makes the confluence point read as a join rather than a color
## change along one uniform line). Retuned to a ~2x-per-rung ladder
## (0.6/1.2/2.4) while keeping the SAME overall footprint (trunk unchanged
## at the visually-proven 2.2ish, stream thinner so it stays "thin" per the
## benchmark's own "thin/consistent/restrained" phrasing rather than pushing
## every class wider). Opacity table is UNCHANGED — Araminta's T-1170 ruling
## (width+opacity sufficient, hue solvable-later) already gives stream a
## faded read; widening the gap in WIDTH is the one lever this pass turns.
const COURSE_CLASS_WIDTH_PX: Dictionary = {
RIVER_CLASS_STREAM: 0.9, RIVER_CLASS_TRIBUTARY: 1.4, RIVER_CLASS_TRUNK: 2.2
RIVER_CLASS_STREAM: 0.6, RIVER_CLASS_TRIBUTARY: 1.2, RIVER_CLASS_TRUNK: 2.4
}
const COURSE_CLASS_OPACITY: Dictionary = {
RIVER_CLASS_STREAM: 0.8, RIVER_CLASS_TRIBUTARY: 0.9, RIVER_CLASS_TRUNK: 1.0
}
## Source tapering (T-1175 seeded item 2 — "courses taper to a point at
## their upstream source instead of starting at full class width", the
## classic cartographic river grammar the RimWorld reference shows on every
## visible tributary). `points[0]` is the course's own upstream end (Ruling
## 2d/3h's own ordering — layer_proxy.rs's RiverCourse.points is "points
## along the course... cropped to this window", walked source->mouth, the
## SAME direction _draw_mouth_ring() already assumes by ringing the LAST
## point). Width ramps linearly from TAPER_MIN_WIDTH_PX at arc-length 0 to
## the class's own full COURSE_CLASS_WIDTH_PX at TAPER_ARC_FRACTION of the
## course's total length, then holds full width to the mouth — "taper over
## a sensible arc-length fraction, not the whole run" (ticket instruction).
## A stream-class course is thin enough end-to-end that a long taper would
## read as "the whole line fades", so the fraction is deliberately small.
const TAPER_ARC_FRACTION: float = 0.15
## Never fully zero — a true point-width vertex degenerates the polygon
## triangulation at that end (two coincident vertices) for no visible gain;
## a hairline width reads as "tapered to a point" at any display ratio this
## layer draws at (District..Chunk, 1px/gridunit) while staying a valid strip.
const TAPER_MIN_WIDTH_PX: float = 0.15
## PR #207 round, finding 1 (Araminta, blocking) — CROP-EDGE FALSE HEADWATERS.
## `points[0]` is only the course's TRUE upstream source when the course's
## full extent fits inside this canvas. When a course enters the canvas
## MID-RIVER, server-side cropping (`layer_proxy::crop_course_to_window` /
## `step_canvas::crop_course_for_canvas`, both: "Crop range: one station
## beyond each edge... `let lo = first_in.saturating_sub(1)`") keeps exactly
## ONE point beyond the window edge on the upstream side — SILENTLY: there is
## no wire-carried upstream analog of `CourseTerminus` (that enum only
## resolves the DOWNSTREAM end, `terminus` field). Tapering `points[0]`
## unconditionally therefore draws a fake spring at the crop line for every
## passthrough course. The one-point overhang IS the detection signal,
## client-side, with no wire change: a cropped course's `points[0]` lies
## OUTSIDE this canvas's own declared bounds (that overhang point exists
## PRECISELY so a consumer can see one station of "what's beyond the edge" —
## a consumer checking whether it's IN the window is reading that signal as
## intended); a true-source course's `points[0]` is the source itself,
## which is only kept because it was already inside (`first_in == 0` implies
## `lo == 0 == first_in`). CONTRACT: if a future crop change ever ships ZERO
## points beyond the window (or more than one), this gate silently breaks —
## re-derive it against `crop_course_to_window`'s own `lo`/`first_in` logic
## before touching either side.
##
## Epsilon absorbs float roundtrip slop between the server's `window_rect`/
## `canvas_rect` (f64, half-extent split via integer `width/2`+`height-half_w`,
## step_canvas.rs's `fixed_canvas_world_rect`) and this file's own SYMMETRIC
## `extent_cells * 0.5` half-extent (`world_m_to_canvas_local`'s existing
## formula, reused as-is here rather than introduced as a second, subtly
## different bounds formula) — at worst a fraction of one gridunit for an
## odd extent, never enough to misclassify a real interior source as
## boundary-adjacent. A source sitting exactly at the boundary (within
## epsilon) is treated as OUTSIDE (no taper) — the conservative default per
## the ruling: "behaves conservatively (no taper)".
const CROP_EDGE_EPSILON_M: float = 1.0
## PR #207 findings 2 (Hoshe, verified numerically — ribbon self-intersects
## at turn radii below half-width) and 3 (Hoshe, verified — the OLD averaged-
## unit-normal joint under-widened the ribbon by cos(theta/2), a 29% pinch
## at a right-angle bend) — ONE FIX, per the lead ruling. A proper mitre:
## the offset length along the averaged (bisector) direction must be
## `half_w / cos(theta/2)` to restore the TRUE perpendicular width on both
## adjacent segments (the averaged-unit-normal approach implicitly used
## `half_w` unscaled, which is only correct for theta=0 — a straight run).
## Two clamps, both required (the ruling: "ONE FIX FOR BOTH"):
## 1. MITRE_LIMIT_FACTOR caps the offset at ~2x half_w for sharp angles
## (the classic mitre-limit / bevel-fallback threshold — an
## unclamped 1/cos(theta/2) blows up toward a near-180-degree fold-back).
## 2. An ADDITIONAL clamp to `HAIRPIN_SEGMENT_FACTOR` of the SHORTER
## adjacent segment length — this is the piece that actually prevents
## the self-intersecting bowtie at a tight hairpin (turn radius <
## half-width): capping by half_w alone still lets the offset exceed
## the segment's own length when the segment is shorter than half_w,
## which is exactly the self-crossing condition Hoshe's numeric check
## caught.
## Guarantee restated (the old doc's "always simple" claim was FALSE, per
## Hoshe): this ribbon is a simple (non-self-intersecting) polygon up to the
## mitre limit; beyond it, the offset is clamped — a bounded, visible
## flattening at an extreme hairpin, never a bowtie fold.
const MITRE_LIMIT_FACTOR: float = 2.0
const HAIRPIN_SEGMENT_FACTOR: float = 0.45
const MOUTH_RING_RADIUS_PX: float = 5.0
const MOUTH_HALO_RADIUS_PX: float = 8.0
const MOUTH_HALO_ALPHA: float = 0.30
@@ -114,10 +205,15 @@ func _draw_one_course(course: Dictionary) -> void:
return
var screen_pts := PackedVector2Array()
# First WELL-FORMED point's world position — the crop-edge gate (finding
# 1) reads THIS, not screen_pts[0], which is already screen-projected.
var first_world_m: Variant = null
for pt: Variant in points_raw:
if not (pt is Array and pt.size() >= 2):
continue
var world_m := Vector2(float(pt[0]), float(pt[1]))
if first_world_m == null:
first_world_m = world_m
screen_pts.append(_world_to_local(world_m))
if screen_pts.size() < 2:
return
@@ -125,13 +221,257 @@ func _draw_one_course(course: Dictionary) -> void:
var width: float = float(COURSE_CLASS_WIDTH_PX.get(cls, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM]))
var opacity: float = float(COURSE_CLASS_OPACITY.get(cls, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM]))
var color := Color(COLOR_RIVER.r, COLOR_RIVER.g, COLOR_RIVER.b, COLOR_RIVER.a * opacity)
draw_polyline(screen_pts, color, width, true)
# Finding 1 (PR #207, Araminta, blocking): only taper when points[0] is
# the course's TRUE source (inside this canvas's own declared bounds) —
# see _is_true_source_in_canvas()'s own doc for the crop-overhang gate.
var is_true_source: bool = _is_true_source_in_canvas(first_world_m)
_draw_tapered_course(screen_pts, width, color, is_true_source)
var terminus: String = str(course.get("terminus", ""))
if terminus == COURSE_TERMINUS_MOUTH:
_draw_mouth_ring(screen_pts[screen_pts.size() - 1])
## PR #207 finding 1 — the crop-overhang detection gate. `canvas_rect` is
## reconstructed client-side from the held frame (`_world_center`/`_rung`/
## `_extent_cells`) using the SAME symmetric half-extent formula
## `world_m_to_canvas_local()` already uses to project every point in this
## file (see CROP_EDGE_EPSILON_M's own doc for why this — not the server's
## asymmetric integer split — is the right formula to mirror here). A point
## strictly outside those bounds (beyond the epsilon) is the one-station
## crop overhang (`crop_course_to_window`'s `lo = first_in.saturating_sub(1)`)
## — a passthrough course, not a real headwater. `null` (a course with no
## well-formed points at all, already unreachable by the caller's own
## `screen_pts.size() < 2` guard, but defensive here too) is conservatively
## NOT a true source.
func _is_true_source_in_canvas(world_m: Variant) -> bool:
if not world_m is Vector2:
return false
var p: Vector2 = world_m
var half_w_m: float = float(_extent_cells.x) * 0.5 * StepCanvasTransport.spacing_for_rung(_rung)
var half_h_m: float = float(_extent_cells.y) * 0.5 * StepCanvasTransport.spacing_for_rung(_rung)
var lo_x: float = _world_center.x - half_w_m + CROP_EDGE_EPSILON_M
var hi_x: float = _world_center.x + half_w_m - CROP_EDGE_EPSILON_M
var lo_y: float = _world_center.y - half_h_m + CROP_EDGE_EPSILON_M
var hi_y: float = _world_center.y + half_h_m - CROP_EDGE_EPSILON_M
return p.x >= lo_x and p.x <= hi_x and p.y >= lo_y and p.y <= hi_y
## PR #207 finding 4 (both reviewers, blocking) — ANTIALIASING. A flat
## `draw_polygon()` ribbon for the WHOLE course silently dropped the AA
## `draw_polyline(..., antialiased=true)` shipped with (a hard-rasterized
## 0.6px stream at 0.8 alpha stairsteps). LEAD RULING'S HYBRID: only the
## TAPER HEAD (source -> the point where width first reaches full class
## width, i.e. TAPER_ARC_FRACTION of the run) draws as a ribbon — width
## varies there, and `draw_polyline()` has no per-vertex-width primitive, so
## the ribbon is unavoidable for that span. The remaining ~85% of the run
## (the visually dominant part, constant full width) draws via the ORIGINAL
## `draw_polyline()` call, AA intact, unchanged from pre-T-1175 behaviour.
## The two pieces meet at a BUTT joint: the ribbon's own last cross-section
## is exactly `full_width` wide at exactly the polyline's first point — same
## width, same position, no gap or overlap by construction (see
## _split_course_at_arc_length()'s own doc for how that shared point is
## derived). Only applies when `taper` is true; a crop-passthrough course
## (finding 1) skips the ribbon path entirely and draws as a single
## constant-width AA polyline, matching the pre-taper flat-cut look
## Araminta already prefers as the default for that case.
func _draw_tapered_course(
screen_pts: PackedVector2Array, full_width: float, color: Color, taper: bool
) -> void:
if not taper:
draw_polyline(screen_pts, color, full_width, true)
return
var split := _split_course_at_arc_length(screen_pts, TAPER_ARC_FRACTION)
var head_pts: PackedVector2Array = split[0]
var tail_pts: PackedVector2Array = split[1]
_draw_ribbon_head(head_pts, full_width, color)
if tail_pts.size() >= 2:
draw_polyline(tail_pts, color, full_width, true)
## Splits `screen_pts` into a HEAD (source through the arc-length fraction
## `t_fraction` of the total run, inclusive of an INTERPOLATED point exactly
## at that fraction) and a TAIL (that same interpolated point through the
## mouth) — the shared interpolated point is the butt-joint seam
## `_draw_tapered_course()` relies on for a gapless/overlapless hybrid.
## Snapping to the nearest EXISTING vertex instead (no interpolation) was
## rejected: station spacing is a rung's own gridunit spacing (2048 m at
## District down to 64 m at Chunk) — coarse enough, relative to a canvas's
## screen footprint, that a single segment can span the ENTIRE taper
## fraction, which would make an index-snapped seam land far from the
## intended taper length rather than close to it.
##
## Returns `[head, tail]`; `tail` is empty (size 0) when the course is
## shorter than `t_fraction` of itself, i.e. the taper fraction as measured
## would run past the mouth — the caller's `tail_pts.size() >= 2` guard
## then draws nothing beyond the ribbon head, which itself already reaches
## `full_width` by its own last point in that case (`_head_widths_by_arc_
## length()`'s own ramp always ends at `full_width` at the head's last
## vertex) — a course too short for a meaningful post-taper span still ends
## up at full width, just without a separate polyline tail.
func _split_course_at_arc_length(screen_pts: PackedVector2Array, t_fraction: float) -> Array:
var n := screen_pts.size()
if n < 2:
return [screen_pts, PackedVector2Array()]
var cumulative := PackedFloat32Array()
cumulative.resize(n)
cumulative[0] = 0.0
for i in range(1, n):
cumulative[i] = cumulative[i - 1] + screen_pts[i].distance_to(screen_pts[i - 1])
var total_len: float = cumulative[n - 1]
if total_len <= 0.0:
# Degenerate (coincident-point) course — nothing meaningful to
# split; the whole thing is the "head" (drawn at full width per
# _course_widths_by_arc_length()'s own degenerate-course fallback).
return [screen_pts, PackedVector2Array()]
var taper_len: float = total_len * t_fraction
if taper_len >= total_len:
# The taper fraction covers the whole course (a very short course) —
# no constant-width tail exists; the ribbon head IS the whole course.
return [screen_pts, PackedVector2Array()]
# Find the segment straddling taper_len and interpolate the exact split
# point along it.
var split_idx := n - 1
for i in range(1, n):
if cumulative[i] >= taper_len:
split_idx = i
break
var seg_start_len: float = cumulative[split_idx - 1]
var seg_len: float = cumulative[split_idx] - seg_start_len
var seg_t: float = 0.0 if seg_len <= 0.0 else (taper_len - seg_start_len) / seg_len
var split_point: Vector2 = screen_pts[split_idx - 1].lerp(screen_pts[split_idx], seg_t)
var head := PackedVector2Array()
for i in range(split_idx):
head.append(screen_pts[i])
head.append(split_point)
var tail := PackedVector2Array()
tail.append(split_point)
for i in range(split_idx, n):
tail.append(screen_pts[i])
return [head, tail]
## Draws the tapered ribbon for the HEAD span only (source through the
## constant-full-width handoff point, `head_pts`'s own last point) —
## `full_width` is the width AT the handoff (the head's own last vertex),
## matching the tail polyline's constant width exactly (the butt joint).
func _draw_ribbon_head(head_pts: PackedVector2Array, full_width: float, color: Color) -> void:
var widths := _head_widths_by_arc_length(head_pts, full_width)
var left := PackedVector2Array()
var right := PackedVector2Array()
for i in range(head_pts.size()):
var half_w: float = widths[i] * 0.5
var offset: Vector2 = _mitred_offset(head_pts, i, half_w)
left.append(head_pts[i] + offset)
right.append(head_pts[i] - offset)
var ribbon := PackedVector2Array()
ribbon.append_array(left)
for i in range(right.size() - 1, -1, -1):
ribbon.append(right[i])
if ribbon.size() < 3:
return
# Finding 5 (Hoshe, trivial) — draw_polygon() accepts a 1-element color
# array for a flat fill; no need to replicate `color` across every
# ribbon vertex.
draw_polygon(ribbon, PackedColorArray([color]))
## Per-vertex width for the RIBBON HEAD ONLY — linear ramp from
## TAPER_MIN_WIDTH_PX at the source (index 0) to `full_width` at the head's
## own LAST point (the butt-joint handoff, always exactly `full_width` by
## construction: `_split_course_at_arc_length()` places that point at
## exactly `t_fraction` of the FULL course's arc length, and this function
## is handed only the head span, so the head's own last point is always the
## ramp's 100% mark).
func _head_widths_by_arc_length(head_pts: PackedVector2Array, full_width: float) -> PackedFloat32Array:
var n := head_pts.size()
var widths := PackedFloat32Array()
widths.resize(n)
if n == 0:
return widths
if n == 1:
widths[0] = full_width
return widths
var cumulative := PackedFloat32Array()
cumulative.resize(n)
cumulative[0] = 0.0
for i in range(1, n):
cumulative[i] = cumulative[i - 1] + head_pts[i].distance_to(head_pts[i - 1])
var head_len: float = cumulative[n - 1]
if head_len <= 0.0:
widths.fill(full_width)
return widths
for i in range(n):
var t: float = cumulative[i] / head_len
widths[i] = lerpf(TAPER_MIN_WIDTH_PX, full_width, t)
return widths
## PR #207 findings 2/3 — see the MITRE_LIMIT_FACTOR/HAIRPIN_SEGMENT_FACTOR
## const doc (top of file) for the full rationale; this is the function that
## consumes them.
func _mitred_offset(points: PackedVector2Array, i: int, half_w: float) -> Vector2:
var n := points.size()
var incoming: Vector2 = Vector2.ZERO
var outgoing: Vector2 = Vector2.ZERO
var incoming_len: float = 0.0
var outgoing_len: float = 0.0
if i > 0:
var seg: Vector2 = points[i] - points[i - 1]
incoming_len = seg.length()
if incoming_len > 0.0:
incoming = seg / incoming_len
if i < n - 1:
var seg: Vector2 = points[i + 1] - points[i]
outgoing_len = seg.length()
if outgoing_len > 0.0:
outgoing = seg / outgoing_len
var dir: Vector2 = incoming + outgoing
if dir == Vector2.ZERO:
return Vector2.ZERO
dir = dir.normalized()
var normal: Vector2 = dir.orthogonal()
# cos(theta/2) where theta is the turn angle between incoming and
# outgoing tangents — the bisector-normal `normal` sits exactly
# theta/2 off each segment's own perpendicular, so the dot product
# against EITHER segment's unit normal recovers cos(theta/2) directly
# (no explicit angle/trig call needed).
var half_angle_cos: float = normal.dot(incoming.orthogonal()) if incoming_len > 0.0 else 1.0
if outgoing_len > 0.0:
var alt_cos: float = normal.dot(outgoing.orthogonal())
half_angle_cos = maxf(absf(half_angle_cos), absf(alt_cos))
half_angle_cos = maxf(absf(half_angle_cos), 0.05) # guard near-180 fold-back (cos -> 0)
var mitre_len: float = half_w / half_angle_cos
mitre_len = minf(mitre_len, half_w * MITRE_LIMIT_FACTOR)
var shortest_adjacent: float = INF
if incoming_len > 0.0:
shortest_adjacent = minf(shortest_adjacent, incoming_len)
if outgoing_len > 0.0:
shortest_adjacent = minf(shortest_adjacent, outgoing_len)
if is_finite(shortest_adjacent):
mitre_len = minf(mitre_len, shortest_adjacent * HAIRPIN_SEGMENT_FACTOR)
return normal * mitre_len
func _draw_mouth_ring(local_pt: Vector2) -> void:
var halo_color := Color(COLOR_MOUTH.r, COLOR_MOUTH.g, COLOR_MOUTH.b, MOUTH_HALO_ALPHA)
draw_arc(local_pt, MOUTH_HALO_RADIUS_PX, 0.0, TAU, 24, halo_color, 3.0)
+5 -1
View File
@@ -4,7 +4,11 @@ name: The Settled Reach
# 0.4.1-tagged step canvases carried lake_margin_q's flat absolute-ceiling
# semantics (pre-per-basin-normalization) and were written to real disk
# caches during T-1188 eyeball runs — 0.4.2 forces those entries to miss.
version: 0.4.2
# 0.4.2-tagged Global/Region-rung step canvases sampled the coastline
# position raw (no coast_warp_px) — T-1160 applies the warp at orbital
# sampling, changing orbital-rung canvas bytes (elev_q/temperature_dc/
# moisture_q shift near coasts) — 0.4.3 forces those cached entries to miss.
version: 0.4.3
repository: settled-reach
Binary file not shown.
+255 -2
View File
@@ -215,6 +215,120 @@ pub fn handle_city_names_request(
}
}
// ---------------------------------------------------------------------------
// Feature names proxy (T-1169)
// ---------------------------------------------------------------------------
/// A client request for a body's reserved geographic feature names (T-1169) —
/// mirrors [`CityNamesRequest`] exactly (D-236 pattern), its own message type
/// per the same Inbound-demux discriminator discipline the module doc
/// describes: outside the [`crate::atlas::layer_proxy::AtlasLayerResponse`]
/// ceilings, since a name pool is unrelated to the dense per-cell wire
/// arrays those ceilings budget for.
///
/// `feature_names` is the Inbound discriminator (see module doc): always `true`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureNamesRequest {
pub feature_names: bool,
pub body_id: String,
}
/// Status of a [`FeatureNamesResponse`] — mirrors [`CityNamesStatus`] exactly.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FeatureNamesStatus {
/// Names are ready (`features` is populated; legitimately empty for a
/// body with no reserved feature names).
Ready,
/// D-236: `body_id` is a Sol body. Sol is permanently out of the
/// generation cascade and every DB-derived Atlas path — the client must
/// keep its legacy authored `markers.json` read for Sol. `features` is empty.
SolExcluded,
/// DB/IO failure reading `atlas_feature_names` (message for the client log).
Error(String),
}
/// One reserved feature-name entry (T-1169) — see
/// [`crate::atlas::city_context_reader::FeatureNameRow`] for the reader-side
/// row this is built from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureNameEntry {
pub feature_id: u64,
pub name: String,
/// `"river"` | `"mountain"` (T-1169 scope — see
/// [`crate::atlas::city_context_reader::FeatureNameRow::feature_type`]).
pub feature_type: String,
}
/// A feature-names response: the body's reserved geographic feature names, or
/// a non-ready status (T-1169). Mirrors [`CityNamesResponse`] exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureNamesResponse {
pub body_id: String,
pub status: FeatureNamesStatus,
pub features: Vec<FeatureNameEntry>,
}
/// Serve one feature-names request (T-1169): D-236 Sol check first (same
/// reader method [`handle_city_names_request`] uses), then the names-only
/// `atlas_feature_names` read. `city_reader` absent (no DB opened at startup)
/// is reported as `Error`, matching `handle_city_names_request`'s convention.
///
/// This is a POOL read, not a position-assignment lookup — it does not read
/// `layer1::attach_feature_names`'s cascade output (which attaches these
/// names to computed river-mouth/alpine-peak positions at generation time,
/// not at DB-read time). The client pairs this name list with position data
/// it already has from the cascade layer response.
pub fn handle_feature_names_request(
req: &FeatureNamesRequest,
city_reader: Option<&CityContextReader>,
) -> FeatureNamesResponse {
let Some(reader) = city_reader else {
return FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Error("city context reader unavailable".to_string()),
features: Vec::new(),
};
};
match reader.is_sol_body(&req.body_id) {
Ok(true) => {
return FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::SolExcluded,
features: Vec::new(),
};
}
Ok(false) => {}
Err(e) => {
return FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Error(e.to_string()),
features: Vec::new(),
};
}
}
match reader.read_body_feature_names(&req.body_id) {
Ok(rows) => FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Ready,
features: rows
.into_iter()
.map(|r| FeatureNameEntry {
feature_id: r.feature_id,
name: r.name,
feature_type: r.feature_type,
})
.collect(),
},
Err(e) => FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Error(e.to_string()),
features: Vec::new(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -312,8 +426,9 @@ mod tests {
assert!(decoded.cities[0].is_capital);
}
/// Minimal db mirroring what `is_sol_body` + `read_body_city_names` need:
/// `bodies`, `system_history`, `atlas_city_names`.
/// Minimal db mirroring what `is_sol_body` + `read_body_city_names` +
/// `read_body_feature_names` need: `bodies`, `system_history`,
/// `atlas_city_names`, `atlas_feature_names`.
fn make_db(body_id: &str, system_id: &str, settlement_wave: Option<&str>) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_adp_{}_{n}.db", std::process::id()));
@@ -330,6 +445,12 @@ mod tests {
body_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'city'
);
CREATE TABLE atlas_feature_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
name TEXT NOT NULL,
feature_type TEXT NOT NULL
);",
)
.expect("create tables");
@@ -358,6 +479,15 @@ mod tests {
.expect("insert city");
}
fn insert_feature(db: &Path, body_id: &str, name: &str, feature_type: &str) {
let conn = Connection::open(db).expect("reopen");
conn.execute(
"INSERT INTO atlas_feature_names (body_id, name, feature_type) VALUES (?1, ?2, ?3)",
rusqlite::params![body_id, name, feature_type],
)
.expect("insert feature");
}
#[test]
fn handle_city_names_request_no_reader_is_error() {
let resp = handle_city_names_request(
@@ -448,4 +578,127 @@ mod tests {
assert_eq!(resp.status, CityNamesStatus::Ready);
assert!(resp.cities.is_empty());
}
// ─── FeatureNamesRequest/Response (T-1169) ───────────────────────────────
#[test]
fn feature_names_request_round_trips_msgpack() {
let req = FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
};
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
let decoded: FeatureNamesRequest = rmp_serde::from_slice(&bytes).expect("decode");
assert!(decoded.feature_names);
assert_eq!(decoded.body_id, "GJ1c");
}
#[test]
fn feature_names_response_round_trips_msgpack() {
let resp = FeatureNamesResponse {
body_id: "GJ1c".into(),
status: FeatureNamesStatus::Ready,
features: vec![FeatureNameEntry {
feature_id: 1,
name: "Serra Verde".into(),
feature_type: "mountain".into(),
}],
};
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
let decoded: FeatureNamesResponse = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(decoded.status, FeatureNamesStatus::Ready);
assert_eq!(decoded.features[0].name, "Serra Verde");
assert_eq!(decoded.features[0].feature_type, "mountain");
}
#[test]
fn handle_feature_names_request_no_reader_is_error() {
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
},
None,
);
assert!(matches!(resp.status, FeatureNamesStatus::Error(_)));
assert!(resp.features.is_empty());
}
#[test]
fn handle_feature_names_request_sol_body_is_excluded() {
// D-236: system_id = GJ-0 → SolExcluded, no DB row read for features.
let db = make_db("Earth", "GJ-0", None);
insert_feature(&db, "Earth", "Thames", "river");
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "Earth".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::SolExcluded);
assert!(
resp.features.is_empty(),
"Sol-excluded response must carry no features even though the row exists"
);
}
#[test]
fn handle_feature_names_request_sol_body_via_settlement_wave_is_excluded() {
// D-236's second signal: settlement_wave = 'origin' excludes even a
// non-GJ-0 system_id.
let db = make_db("Weirdbody", "GJ-999", Some("origin"));
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "Weirdbody".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::SolExcluded);
}
#[test]
fn handle_feature_names_request_ordinary_body_returns_names() {
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
insert_feature(&db, "GJ1c", "Wiesenbach", "mountain");
insert_feature(&db, "GJ1c", "Kaltfluss", "river");
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::Ready);
assert_eq!(resp.features.len(), 2);
assert_eq!(resp.features[0].name, "Wiesenbach");
assert_eq!(resp.features[0].feature_type, "mountain");
assert_eq!(resp.features[1].name, "Kaltfluss");
assert_eq!(resp.features[1].feature_type, "river");
}
#[test]
fn handle_feature_names_request_unknown_body_is_ready_with_empty_list() {
// Matches handle_city_names_request's existing convention: unknown
// body → empty list under Ready, not a distinct NotFound status.
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "ghost".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::Ready);
assert!(resp.features.is_empty());
}
}
+43
View File
@@ -582,6 +582,7 @@ pub fn cascade_snapshot_for_body(
.read_body_params(body_id)
.map_err(|e| format!("read body params: {e:?}"))?;
let cities = read_cities(&db, body_id)?;
let (river_names, mountain_names) = read_feature_names(&db, body_id)?;
let hm = load_heightmap_png(&hm_path, body_id, DEFAULT_SEA_LEVEL)
.map_err(|e| format!("load heightmap: {e:?}"))?;
@@ -597,6 +598,8 @@ pub fn cascade_snapshot_for_body(
&cities,
None,
Some(&params),
&river_names,
&mountain_names,
CascadeLayer::Region,
);
Ok((snapshot, params))
@@ -658,6 +661,46 @@ fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
Ok(rows)
}
/// Read a body's reserved river/mountain name pools from `atlas_feature_names`
/// (T-1169, D-223), mirroring `read_cities`' own lightweight raw-SQL
/// discipline (this module reads `systems.db` directly rather than going
/// through `CityContextReader` — keep the two independent, matching the
/// existing `read_cities` precedent). Returns `(river_names, mountain_names)`.
fn read_feature_names(db: &PathBuf, body_id: &str) -> Result<(Vec<String>, Vec<String>), String> {
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT name, feature_type FROM atlas_feature_names
WHERE body_id = ?1 ORDER BY id",
)
.map_err(|e| format!("prepare feature name query: {e}"))?;
let rows = stmt
.query_map([body_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})
.map_err(|e| format!("feature name query: {e}"))?;
let mut river_names = Vec::new();
let mut mountain_names = Vec::new();
for row in rows {
let (name, feature_type) = match row {
Ok(r) => r,
Err(e) => {
eprintln!(
"[believability] skipped a malformed feature name row for {body_id}: {e}"
);
continue;
}
};
match feature_type.as_str() {
"river" => river_names.push(name),
"mountain" => mountain_names.push(name),
_ => {}
}
}
Ok((river_names, mountain_names))
}
/// Glob `*/bodies/<body>/heightmap.png` under the committed wiki tree (either CWD).
fn find_heightmap(body_id: &str) -> Option<PathBuf> {
for base in ["wiki/star-systems", "../wiki/star-systems"] {
+8
View File
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::district_profile::DistrictProfile;
use crate::atlas::layer1::FeatureNameAssignment;
use crate::atlas::region_profile::RegionProfile;
use crate::atlas::road_graph::RoadGraph;
use crate::atlas::scale::{RegionPos, SurveyCellPos};
@@ -182,6 +183,12 @@ pub struct BodyWorldState {
pub drainage_basins: Vec<DrainageBasin>,
/// Geographic attractors (D-195, D-209). Empty until attractor task completes.
pub attractors: Vec<GeographicAttractor>,
/// Named-feature position assignments (T-1169, D-223) — river-mouth and
/// alpine-peak attractors paired with reserved pool names, via
/// `layer1::attach_feature_names`. Empty until the Topography task
/// completes (mirrors `attractors`' own "empty until" convention), or if
/// the body has no reserved names in `atlas_feature_names`.
pub feature_names: Vec<FeatureNameAssignment>,
/// Settlement placements (D-211, #955). Attractor-matched city positions.
/// Empty until the Layer-3 placement task completes.
pub placements: Vec<CityPlacement>,
@@ -332,6 +339,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: RoadGraph::default(),
quarters: BTreeMap::new(),
+141 -3
View File
@@ -147,9 +147,14 @@ impl CascadeSnapshot {
/// `terrain_analysis` (transient, ~2 MB) is **dropped here** — it is not
/// persisted on `BodyWorldState` per the D-203/T-1048 size budget.
pub fn into_body_world_state(self) -> BodyWorldState {
let (river_network, drainage_basins, attractors) = match self.layer1 {
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
let (river_network, drainage_basins, attractors, feature_names) = match self.layer1 {
Some(l1) => (
l1.river_network,
l1.drainage_basins,
l1.attractors,
l1.feature_names,
),
None => (RiverNetwork::default(), Vec::new(), Vec::new(), Vec::new()),
};
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
let districts = self
@@ -170,6 +175,7 @@ impl CascadeSnapshot {
river_network,
drainage_basins,
attractors,
feature_names,
placements,
road_graph,
quarters: std::collections::BTreeMap::new(),
@@ -224,12 +230,20 @@ fn run_layer3(
/// character (#956). `None` → `FrontierUnclaimed`.
/// `body_params` supplies the physical parameters needed for the DistrictProfile
/// layer (T-1023); `None` → district layer skips (empty `districts` map).
/// `river_names`/`mountain_names` are the body's reserved-name pools (T-1169,
/// D-223, from `atlas_feature_names`), supplied by the caller — mirrors
/// `cities`' own pre-resolved, DB-free-cascade pattern. Empty slices are the
/// correct input for a body with no reserved names, or a caller (tests,
/// `aliveness_probe`) that hasn't pre-resolved them; `attach_feature_names`
/// degrades gracefully (every attractor position simply gets no name).
pub fn run_cascade_from_heightmap(
body_seed: SeedChain,
heightmap: BodyHeightmap,
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> CascadeSnapshot {
let mut snapshot = CascadeSnapshot {
@@ -272,6 +286,29 @@ pub fn run_cascade_from_heightmap(
for basin in &mut l1.drainage_basins {
basin.territorial_status = territorial_status.clone();
}
// T-1169: attach reserved names (D-223) to the strongest river-mouth
// and alpine-peak attractors. Cheap (two sorts + zips over the
// already-computed attractor list, no new terrain work) and
// deterministic given the caller-supplied pools — mirrors the
// TerritorialStatus stamp above in running once, right after Layer 1
// produces the attractors this reads.
let (river_assignments, mountain_assignments) =
layer1::attach_feature_names(&l1, river_names, mountain_names);
l1.feature_names = river_assignments
.into_iter()
.map(|(position, name)| layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::River,
})
.chain(mountain_assignments.into_iter().map(|(position, name)| {
layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::Mountain,
}
}))
.collect();
snapshot.layer1 = Some(l1);
snapshot.terrain_analysis = Some(ta);
}
@@ -456,6 +493,8 @@ pub fn run_cascade(
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> Result<CascadeSnapshot, HeightmapLoadError> {
// Layer 0 — the cascade's input; always loaded.
@@ -466,6 +505,8 @@ pub fn run_cascade(
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
up_to,
))
}
@@ -508,6 +549,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert_eq!(snap.body_id, "test_body");
@@ -525,12 +568,89 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
assert_eq!(l1.body_id, "test_body");
}
/// T-1169: `run_cascade_from_heightmap` attaches reserved names to the
/// strongest river-mouth/alpine attractors when the caller supplies name
/// pools — proves the cascade wiring (`attach_feature_names` call site
/// inside the Topography block), not just the function in isolation
/// (`layer1::tests` already covers `attach_feature_names` itself).
#[test]
fn topography_layer_attaches_feature_names_when_pools_supplied() {
let river_names = vec!["Kaltfluss".to_string(), "Silberbach".to_string()];
let mountain_names = vec!["Wiesenbach".to_string()];
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&river_names,
&mountain_names,
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
// The test heightmap's `0.6r + 0.4c` ramp crosses sea_level=0.3,
// producing real river-mouth/coastal attractors — assert against
// WHATEVER attach_feature_names actually paired, not a hardcoded
// count (the exact attractor set is an implementation detail of
// feature extraction, not this test's concern).
let river_attractor_count = l1
.attractors
.iter()
.filter(|a| a.attractor_type == crate::simulation::generator::AttractorType::RiverMouth)
.count();
let expected_river_assignments = river_attractor_count.min(river_names.len());
let actual_river_assignments = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.count();
assert_eq!(
actual_river_assignments, expected_river_assignments,
"every river mouth (up to pool size) must get a name"
);
if expected_river_assignments > 0 {
let names: std::collections::BTreeSet<&str> = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.map(|f| f.name.as_str())
.collect();
assert!(
names.iter().all(|n| river_names.contains(&n.to_string())),
"assigned names must come from the supplied pool"
);
}
// No pools supplied -> no assignments (the pre-wiring default).
let snap_no_pools = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None,
&[],
&[],
CascadeLayer::Topography,
);
assert!(
snap_no_pools
.layer1
.expect("Layer 1 should have run")
.feature_names
.is_empty(),
"empty pools must yield zero assignments"
);
}
#[test]
fn cascade_is_deterministic() {
let extract = |s: CascadeSnapshot| {
@@ -546,6 +666,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
let b = extract(run_cascade_from_heightmap(
@@ -554,6 +676,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
assert_eq!(
@@ -586,6 +710,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert!(res.is_err(), "missing heightmap must Err, not panic");
@@ -609,6 +735,8 @@ mod tests {
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::DistrictProfile,
)
};
@@ -668,6 +796,8 @@ mod tests {
&cities,
Some("concord_assembly"),
None, // body_params
&[],
&[],
CascadeLayer::Settlement,
)
};
@@ -756,6 +886,8 @@ mod tests {
&cities,
Some("independent"),
None, // body_params — road graph needs none
&[],
&[],
CascadeLayer::RoadGraph,
)
};
@@ -860,6 +992,8 @@ mod tests {
&cities,
Some("independent"),
None,
&[],
&[],
CascadeLayer::RoadGraph,
);
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
@@ -927,6 +1061,8 @@ mod tests {
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::Region,
)
};
@@ -980,6 +1116,8 @@ mod tests {
&[],
None,
None,
&[],
&[],
CascadeLayer::Region,
);
assert!(no_params.layer_region.is_none());
+121
View File
@@ -467,6 +467,58 @@ impl CityContextReader {
}
Ok(out)
}
/// Read every reserved geographic feature **name** on `body_id` from
/// `atlas_feature_names` (T-1169 — mirrors [`read_body_city_names`]
/// exactly, D-236 pattern, over `atlas_feature_names` instead of
/// `atlas_city_names`). Returns the id/name/feature_type triple — this is
/// the raw reserved-name POOL, not a position assignment (positions come
/// from `layer1::attach_feature_names` at cascade generation time, not
/// from this reader). Ordered by `id`. An unknown body yields an empty
/// list (matches `read_body_city_names`'s convention); only a DB/mutex
/// error fails. **No Sol check here** — unlike city names, this is a raw
/// pool read with no per-body caller-facing status enum; the proxy
/// handler (`atlas_data_proxy::handle_feature_names_request`) applies the
/// same D-236 Sol exclusion `handle_city_names_request` does, BEFORE
/// calling this method, so Sol bodies never reach this query in practice.
pub fn read_body_feature_names(
&self,
body_id: &str,
) -> Result<Vec<FeatureNameRow>, CityContextReadError> {
let conn = self
.conn
.lock()
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, name, feature_type
FROM atlas_feature_names
WHERE body_id = ?1
ORDER BY id",
)
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let rows = stmt
.query_map([body_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
let (id, name, feature_type) =
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
out.push(FeatureNameRow {
feature_id: id as u64,
name,
feature_type,
});
}
Ok(out)
}
}
/// One row of the T-949 names-only read (see
@@ -478,6 +530,20 @@ pub struct CityNameRow {
pub is_capital: bool,
}
/// One row of the T-1169 names-only read (see
/// [`CityContextReader::read_body_feature_names`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeatureNameRow {
pub feature_id: u64,
pub name: String,
/// `atlas_feature_names.feature_type` — `"river"` | `"mountain"` (the two
/// pools `import_economics`/`atlas.py::populate_atlas_feature_names`
/// currently populates, T-1169 scope). Carried as a raw string, not an
/// enum — mirrors `CityNameRow.is_capital`'s discipline of staying a thin
/// passthrough of the DB row, no server-side vocabulary gate here.
pub feature_type: String,
}
// ---------------------------------------------------------------------------
// prosperity_baseline_bps derivation (D-197, partial — integer basis points)
// ---------------------------------------------------------------------------
@@ -1355,6 +1421,61 @@ mod tests {
);
}
// ─── read_body_feature_names (T-1169) ────────────────────────────────────
/// Minimal db for `read_body_feature_names`: just `atlas_feature_names`.
fn make_features_db(rows: &[(&str, &str)]) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_ctxfeat_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE atlas_feature_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
name TEXT NOT NULL,
feature_type TEXT NOT NULL
);",
)
.expect("create table");
for (name, feature_type) in rows {
conn.execute(
"INSERT INTO atlas_feature_names (body_id, name, feature_type)
VALUES ('PlanetX', ?1, ?2)",
rusqlite::params![name, feature_type],
)
.expect("insert feature");
}
drop(conn);
path
}
#[test]
fn read_body_feature_names_returns_id_name_type() {
let db = make_features_db(&[("Wiesenbach", "mountain"), ("Kaltfluss", "river")]);
let reader = CityContextReader::open(&db).expect("open");
let names = reader.read_body_feature_names("PlanetX").expect("read");
assert_eq!(names.len(), 2);
// Ordered by id == insertion order.
assert_eq!(names[0].name, "Wiesenbach");
assert_eq!(names[0].feature_type, "mountain");
assert_eq!(names[1].name, "Kaltfluss");
assert_eq!(names[1].feature_type, "river");
}
#[test]
fn read_body_feature_names_unknown_body_is_empty() {
let db = make_features_db(&[("Solo Peak", "mountain")]);
let reader = CityContextReader::open(&db).expect("open");
assert!(
reader
.read_body_feature_names("Ghost")
.expect("read")
.is_empty(),
"unknown body yields no names, matching read_body_city_names' convention"
);
}
// ─── is_sol_body (T-949, D-236) ──────────────────────────────────────────
/// Minimal db for `is_sol_body`: one `bodies` row + an optional
+335 -31
View File
@@ -1928,26 +1928,43 @@ fn derive_at_metres_with_riparian(
}
/// The orbital-rung derivation (T-1152, zoom ladder design doc §2/§4): the
/// coarse-granularity twin of [`derive_at_metres`] that skips [`invent_primitives`]
/// entirely — **no coastline warp, no detail-scatter octave sum, no classification
/// noise call of any kind**. Per the design doc's orbital row: "`region_baseline_at_district`
/// only — bilinear blend of 4 region baselines, no `invent_primitives`, no
/// classification [driver]." Orbital sample spacing (≥205 km, D-243's region rung
/// and coarser) sits below `detail_scatter`'s own octave floor
/// (`OCTAVE_WAVELENGTHS_M`'s coarsest entry is 32,768 m ≈ 32.8 km — an order of
/// magnitude finer than a region), so the invented terrain has nothing left to
/// contribute at this spacing; calling it would burn cycles synthesizing detail
/// no orbital pixel can resolve. What DOES vary at orbital spacing is the
/// **envelope** the heightmap itself carries (the `TerrainAnalysis` continental
/// shape) and the **region climate baseline** — this function samples exactly
/// those two, nothing else.
/// coarse-granularity twin of [`derive_at_metres`] that skips MOST of
/// [`invent_primitives`] — **no detail-scatter octave sum, no classification
/// noise call of any kind** — but DOES apply the coast-warp position shift
/// (T-1160). Per the design doc's orbital row: "`region_baseline_at_district`
/// only — bilinear blend of 4 region baselines, no [detail-scatter]
/// invention, no classification [driver]." Orbital sample spacing (≥205 km,
/// D-243's region rung and coarser) sits below `detail_scatter`'s own octave
/// floor (`OCTAVE_WAVELENGTHS_M`'s coarsest entry is 32,768 m ≈ 32.8 km — an
/// order of magnitude finer than a region), so the invented RELIEF texture has
/// nothing left to contribute at this spacing; running it would burn cycles
/// synthesizing detail no orbital pixel can resolve. The coastline WARP is
/// different: `WARP_OCTAVE_WAVELENGTHS_M`'s coarsest entries run up to 262,144 m
/// (262 km) — coarser than a region — so the warp is NOT below this rung's
/// resolution floor, and (T-1160 audit) skipping it produced a structurally
/// different coastline position than every finer rung (5.0% land/ocean
/// classification disagreement at coastal-band cells, 267 real bodies; mean
/// displacement ~6.0 km / max ~18.9 km at region spacing — see
/// `derive_orbital_coastline_divergence_audit`). What varies at orbital
/// spacing is therefore the **envelope** the heightmap carries (the
/// `TerrainAnalysis` continental shape), sampled at the **coast-warped
/// position**, plus the **region climate baseline** — this function samples
/// exactly those, nothing else.
///
/// **Cost model (design doc R1 — measure first):** one `bilinear` (elevation),
/// one `bilinear_bool` (ocean mask), one `region_baseline_at_district` call (its
/// own cost is 4×`derive_region_baseline_c` on a cache miss, O(1) on a cache hit)
/// — no octave sum, no coast-warp trig, no character/envelope computation. See
/// `server/tests/zoom_ladder_bench.rs`'s `bench_derive_orbital_at_metres` for the
/// measured per-cell figure this claim rests on.
/// **Cost model (T-1160 audit, measured on 267 real bodies, release build):**
/// the added driver-tier climate + `coast_character_at` + `coast_warp_px` work
/// costs ~784 ns/cell (`coast_warp_px` alone: ~528 ns/cell) — roughly half of
/// what `invent_primitives`' FULL pipeline costs on top of this function's own
/// ~903 ns/cell baseline (`bench_derive_orbital_at_metres_region_spacing`:
/// orbital 902.6 ns/cell vs. full `derive_at_metres` 1940.6 ns/cell at the same
/// spacing — a 2.15x gap, of which detail-scatter/classification-noise is the
/// remainder this function still skips). At the D-255(a) fixed-rung ceiling
/// (3,840×2,160 = 8.3M cells) the added cost is ~6.5 s single-threaded / low
/// hundreds of ms on the row-chunked parallel serving path (T-1151); at a
/// realistic Global-rung Earth-sized-body canvas (195×97 ≈ 18.9K cells) it is
/// ~15 ms. Accepted: coastline correctness at the orbital/Region seam is worth
/// a sub-millisecond-to-low-second cost that scales with (and is bounded by)
/// the same canvas-size ceiling every other rung's cost already respects.
///
/// Produces the SAME six-field tail every other rung produces (`morphology_zone`,
/// `elev_q`, `temperature_c`, `moisture_q`, `vegetation_class`, `glaciation_grade`)
@@ -2011,12 +2028,55 @@ pub fn derive_orbital_at_metres(
..body_params.clone()
};
// The envelope only — no coast-warp, no detail-scatter. This is exactly
// `invent_primitives`' step-1 "driver tier" raw bilinear reads, promoted to
// be the FINAL primitives instead of a one-step-stale input to invention.
let elev_q =
// T-1160: coast-warp the sampling POSITION (steps 1-3 of
// `invent_primitives`), but skip step 4 (slope-independent detail-scatter)
// — the envelope carries no per-cell slope signal at orbital spacing (see
// the function doc's note on `slope_q`, unchanged). Before this fix, this
// function sampled `ta.elev_pct`/`ta.ocean_mask` RAW while District/Quarter
// sampled the SAME arrays at the coast-warped position — a structurally
// different coastline at the orbital-to-district seam (audit: 5.0%
// land/ocean classification disagreement at coastal-band cells across 267
// real bodies, mean displacement ~6.0 km / max ~18.9 km at region
// spacing). Applying the warp here makes the orbital rung sample the SAME
// invented coastline every finer rung already draws — coarser density,
// same line, not a different line (T-1160).
//
// Driver tier: raw-bilinear reads feed the coast character exactly as
// `invent_primitives` step 1 does (one-step-stale climate driving the
// coast personality, never circular on its own warped output).
let raw_elev_q =
((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100);
let ocean_fraction_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0)
let raw_ocean_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0).round()
as i32)
.clamp(0, 100);
let driver_params = BodyParams {
elevation_km: (raw_elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
..params.clone()
};
let driver_temp = derive_temperature_c(&driver_params, climate, seed.seed());
let driver_moisture = derive_moisture_q(&driver_params, raw_elev_q, raw_ocean_q, climate);
let driver_glaciation = derive_glaciation_grade_from_climate(driver_temp, driver_moisture);
// Character + invented coastline (steps 2-3): same body-personality
// envelope + position character + warp displacement District/Quarter use,
// applied to the SAME `(px, py)` this function already resolved.
let tectonic = derive_tectonic_class(body_params);
let envelope = coast_invention::body_coast_envelope(body_params, tectonic);
let ch = coast_invention::coast_character_at(
&envelope,
seed.seed(),
world_x_m,
world_y_m,
lat_deg,
driver_glaciation,
driver_moisture,
);
let (wdx, wdy) = coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch, 0.0);
let (spx, spy) = (px + wdx, py + wdy);
let elev_q = ((bilinear(&ta.elev_pct, ta.w, ta.h, spx, spy) as f64 * 100.0).round() as i32)
.clamp(0, 100);
let ocean_fraction_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, spx, spy) as f64 * 100.0)
.round() as i32)
.clamp(0, 100);
// No invented ruggedness at orbital spacing (see the function doc's note
@@ -3384,13 +3444,14 @@ mod tests {
assert_district_profiles_eq(&a, &b);
}
/// The orbital path must NOT run `invent_primitives` — the design doc's
/// central constraint (§2: "no invent_primitives at orbital wavelengths").
/// Direct proof: `slope_q` is always exactly 0 (invention is the only
/// source of nonzero slope_q at this call depth — see
/// `derive_orbital_at_metres`'s doc on why slope_q is fixed), sampled
/// across enough distinct positions that a nonzero value appearing even
/// once would falsify the claim.
/// The orbital path must NOT run `invent_primitives`'s detail-scatter/
/// relief step (step 4) — the design doc's central constraint (§2: "no
/// [relief] invention at orbital wavelengths"), even though it DOES now
/// run steps 1-3 (coast-warp, T-1160). Direct proof: `slope_q` is always
/// exactly 0 (detail-scatter is the only source of nonzero slope_q at
/// this call depth — see `derive_orbital_at_metres`'s doc on why slope_q
/// is fixed), sampled across enough distinct positions that a nonzero
/// value appearing even once would falsify the claim.
#[test]
fn derive_orbital_at_metres_never_invents_slope() {
let hm = test_hm();
@@ -3505,6 +3566,249 @@ mod tests {
let _ = prof.glaciation_grade;
}
/// T-1160 audit (historical record — the fix landed; this test documents
/// the numbers that justified it and stays as a standing measurement, not
/// a regression gate with a hardcoded threshold). Quantifies the
/// orbital-vs-district coastline positional divergence that existed
/// BEFORE this ticket: `derive_orbital_at_metres` used to sample
/// `ta.ocean_mask` raw (no `coast_warp_px`) while District/Quarter warped
/// the same mask first (`invent_primitives` step 3). This test
/// independently reimplements both the pre-fix raw read (a) and the
/// District-style warped read (b) — deliberately NOT calling
/// `derive_orbital_at_metres` itself, so the measurement stays valid as a
/// historical baseline regardless of that function's current
/// implementation — loads every real `heightmap.png` under
/// `wiki/star-systems/`, and for a grid of region-spacing sample points on
/// each body reports how often (a) and (b) disagree on land/ocean
/// classification, the warp displacement converted to real metres, and
/// the per-cell cost of computing (b). These are the numbers T-1160's
/// fix (applying the warp at orbital sampling, see
/// `derive_orbital_at_metres`'s doc) is based on. `#[ignore]`d — reads
/// ~267 real wiki heightmap files, run explicitly:
/// `cargo test --release -p settled-reach-server derive_orbital_coastline_divergence_audit -- --ignored --nocapture`
#[test]
#[ignore]
fn derive_orbital_coastline_divergence_audit() {
use std::path::Path;
use std::time::Instant;
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../wiki/star-systems");
let mut heightmaps: Vec<std::path::PathBuf> = Vec::new();
collect_heightmaps(&root, &mut heightmaps);
heightmaps.sort();
assert!(
!heightmaps.is_empty(),
"expected to find real heightmap.png files under {:?}",
root
);
let climate = ClimateConstants::default();
let seed = test_seed();
let region_m = scale::REGION_M as f64;
let mut bodies_sampled = 0usize;
let mut total_cells = 0u64;
let mut disagreements = 0u64;
let mut max_displacement_m = 0.0f64;
let mut sum_displacement_m = 0.0f64;
let mut displacement_samples = 0u64;
let mut warp_evals = 0u64;
let mut warp_only_nanos = 0u64;
let mut warp_plus_character_nanos = 0u64;
for hm_path in &heightmaps {
let Ok(file) = std::fs::File::open(hm_path) else {
continue;
};
let Ok(hm) = crate::atlas::heightmap::load_heightmap_reader(file, "audit", 0.4) else {
continue;
};
if hm.width == 0 || hm.height == 0 {
continue;
}
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta = TerrainAnalysis::analyze(&hm, &dr);
let body_params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
};
let circumference_m = std::f64::consts::TAU * 6371.0 * 1000.0;
let meridian_m = std::f64::consts::PI * 6371.0 * 1000.0;
let tectonic = derive_tectonic_class(&body_params);
let envelope = coast_invention::body_coast_envelope(&body_params, tectonic);
// Sample on a region-spacing grid across the whole body — same
// spacing the orbital/Region rung actually renders at.
let cols = (circumference_m / region_m).floor() as i64;
let rows = (meridian_m / region_m).floor() as i64;
if cols < 1 || rows < 1 {
continue;
}
bodies_sampled += 1;
for row in 0..rows {
for col in 0..cols {
let wx = col as f64 * region_m;
let wy = row as f64 * region_m;
let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64;
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5);
let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64;
let lat_deg = -lat_frac * 180.0;
// (a) raw orbital read — today's derive_orbital_at_metres.
let raw_ocean_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64
* 100.0)
.round() as i32)
.clamp(0, 100);
// Only worth measuring displacement near a coastline — a
// cell deep in open ocean or deep inland can't flip
// classification no matter the (sub-pixel) warp, so
// restrict the expensive per-cell warp eval + metre
// conversion to the coastal band (0 < raw_ocean_q < 100
// OR any 4-neighbor disagrees) — this is also exactly
// the "coastal cell" population the ticket's cost
// estimate ("~one warp eval per coastal cell") means.
let is_coastal_band = {
let n = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py - 1.0) > 0.5;
let s = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py + 1.0) > 0.5;
let e = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px + 1.0, py) > 0.5;
let w = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px - 1.0, py) > 0.5;
let here = raw_ocean_q > 50;
here != n || here != s || here != e || here != w
};
if !is_coastal_band {
continue;
}
total_cells += 1;
// (b) District-style warped read at the SAME position —
// mirrors invent_primitives' driver-tier + character +
// warp steps, using the real per-body envelope. Timed
// separately from the file-I/O/drainage/TerrainAnalysis
// setup above — this is the actual per-cell cost T-1160
// would add at orbital sampling, not body-load overhead.
let cell_start = Instant::now();
let raw_elev_q = ((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0)
.round() as i32)
.clamp(0, 100);
let driver_params = BodyParams {
elevation_km: (raw_elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
latitude_deg: lat_deg,
..body_params.clone()
};
let driver_temp = derive_temperature_c(&driver_params, &climate, seed.seed());
let driver_moisture =
derive_moisture_q(&driver_params, raw_elev_q, raw_ocean_q, &climate);
let driver_glaciation =
derive_glaciation_grade_from_climate(driver_temp, driver_moisture);
let ch = coast_invention::coast_character_at(
&envelope,
seed.seed(),
wx,
wy,
lat_deg,
driver_glaciation,
driver_moisture,
);
warp_plus_character_nanos += cell_start.elapsed().as_nanos() as u64;
warp_evals += 1;
let warp_call_start = Instant::now();
let (wdx, wdy) = coast_invention::coast_warp_px(seed.seed(), wx, wy, &ch, 0.0);
warp_only_nanos += warp_call_start.elapsed().as_nanos() as u64;
warp_plus_character_nanos += warp_call_start.elapsed().as_nanos() as u64;
let (spx, spy) = (px + wdx, py + wdy);
let warped_ocean_q =
((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, spx, spy) as f64 * 100.0)
.round() as i32)
.clamp(0, 100);
if (raw_ocean_q > 50) != (warped_ocean_q > 50) {
disagreements += 1;
}
// Displacement converted to real metres at this body's
// equator-circumference-derived px scale (constant across
// latitude for an equirectangular map's column spacing;
// using the equatorial scale is the conservative /
// largest-metres-per-pixel case for row spacing too since
// meridian_m/ta.h <= circumference_m/ta.w for ta.h<=ta.w/2).
let m_per_px = circumference_m / ta.w as f64;
let disp_px = (wdx * wdx + wdy * wdy).sqrt();
let disp_m = disp_px * m_per_px;
sum_displacement_m += disp_m;
displacement_samples += 1;
if disp_m > max_displacement_m {
max_displacement_m = disp_m;
}
}
}
}
let mean_displacement_m = if displacement_samples > 0 {
sum_displacement_m / displacement_samples as f64
} else {
0.0
};
let disagreement_pct = if total_cells > 0 {
100.0 * disagreements as f64 / total_cells as f64
} else {
0.0
};
let ns_per_warp_call = if warp_evals > 0 {
warp_only_nanos as f64 / warp_evals as f64
} else {
0.0
};
let ns_per_full_cell_cost = if warp_evals > 0 {
warp_plus_character_nanos as f64 / warp_evals as f64
} else {
0.0
};
eprintln!("=== T-1160 orbital coastline divergence audit ===");
eprintln!("bodies sampled: {bodies_sampled}");
eprintln!("coastal-band cells sampled: {total_cells}");
eprintln!("land/ocean disagreements: {disagreements} ({disagreement_pct:.2}%)");
eprintln!("mean warp displacement: {mean_displacement_m:.1} m");
eprintln!("max warp displacement: {max_displacement_m:.1} m");
eprintln!(
"coast_warp_px call cost: {ns_per_warp_call:.1} ns/call ({warp_evals} calls, {warp_only_nanos} ns total)"
);
eprintln!(
"full added cost/cell: {ns_per_full_cell_cost:.1} ns/cell (driver temp/moisture/glaciation + coast_character_at + coast_warp_px)"
);
// Sanity floor — this audit is meaningless if it silently sampled
// zero real bodies or zero coastal cells (e.g. a bad path).
assert!(bodies_sampled > 10, "too few bodies sampled — check path");
assert!(total_cells > 0, "no coastal-band cells found — check masks");
}
fn collect_heightmaps(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_heightmaps(&path, out);
} else if path.file_name().and_then(|n| n.to_str()) == Some("heightmap.png") {
out.push(path);
}
}
}
#[test]
fn derive_district_profile_is_deterministic() {
let hm = test_hm();
+15
View File
@@ -91,6 +91,15 @@ pub enum GenWorkItem {
/// Boxed: `BodyParams` is large relative to other variants (clippy
/// large_enum_variant) — boxing keeps `GenWorkItem` compact.
body_params: Option<Box<BodyParams>>,
/// The body's reserved river-name pool (`atlas_feature_names`,
/// `feature_type = 'river'`), pre-resolved at dispatch time (T-1169,
/// D-223) — mirrors `cities`' own DB-free-cascade pattern. Empty if
/// the body has no reserved river names.
river_names: Vec<String>,
/// The body's reserved mountain-name pool (`atlas_feature_names`,
/// `feature_type = 'mountain'`), pre-resolved at dispatch time
/// (T-1169, D-223). Empty if the body has no reserved mountain names.
mountain_names: Vec<String>,
},
/// Generate a Phase 1 QuarterSkeleton for this city.
///
@@ -974,6 +983,8 @@ fn run_work_item(
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
Ok(hm) => {
// Layer 1 runs at the GRID_W×GRID_H working resolution (D-202):
@@ -997,6 +1008,8 @@ fn run_work_item(
cities,
dominant_faction.as_deref(),
body_params.as_deref(),
river_names,
mountain_names,
up_to,
);
GenCompletion::BodyAnalyzed {
@@ -1296,6 +1309,8 @@ mod tests {
cities: vec![],
dominant_faction: None,
body_params: None, // T-1023: no body params in queue-mechanic unit tests
river_names: vec![],
mountain_names: vec![],
}
}
+34
View File
@@ -71,6 +71,35 @@ pub struct Layer1Output {
/// after the cascade consumes it.
#[serde(skip)]
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
/// Named-feature position assignments (T-1169, D-223): river-mouth and
/// alpine-peak attractors paired with a reserved name from the
/// `atlas_feature_names` pool, via [`attach_feature_names`]. Empty when
/// the caller supplied no name pools (e.g. `run_layer1`'s two-arg
/// convenience form, or a body with no reserved names) — never populated
/// automatically inside `run_layer1`/`run_layer1_with_moisture`
/// themselves, since those have no DB access (D-225 DB-free-worker
/// discipline); the cascade caller pre-resolves the pools and calls
/// [`attach_feature_names`] itself (`cascade::run_cascade_from_heightmap`).
#[serde(default)]
pub feature_names: Vec<FeatureNameAssignment>,
}
/// One reserved-name-to-position pairing (T-1169) — see [`attach_feature_names`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureNameAssignment {
pub position: (u16, u16),
pub name: String,
pub feature_type: FeatureNameType,
}
/// The two pools [`attach_feature_names`] currently draws from (T-1169
/// scope — mirrors `atlas_feature_names.feature_type`'s `"river"` |
/// `"mountain"` values, but typed rather than a raw string on this
/// server-internal cascade carrier).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FeatureNameType {
River,
Mountain,
}
/// Body-wide moisture ceiling fallback for [`run_layer1`]'s hydrology solve
@@ -216,6 +245,11 @@ pub fn run_layer1_with_moisture(
grid_w: hm.width,
grid_h: hm.height,
survey_basin_dirs,
// T-1169: run_layer1/run_layer1_with_moisture have no DB access
// (D-225 DB-free-worker discipline) — feature-name attachment
// happens one level up, in the cascade caller that pre-resolved the
// name pools (see attach_feature_names's own doc).
feature_names: Vec::new(),
};
(l1, ta)
}
+116 -223
View File
@@ -83,46 +83,22 @@ pub const DISTRICT_WINDOW_MAX_N_REGION: u32 =
const WIRE_CAP_CELLS_SQRT: u32 = 64;
const _: () = assert!(WIRE_CAP_CELLS_SQRT * WIRE_CAP_CELLS_SQRT == WIRE_CAP_CELLS);
/// [`AtlasLayerRequest::window_granularity`] encoding (T-1150, zoom ladder
/// design doc §3/§5): the number of derived cells per district side. `0` on
/// the wire (the `#[serde(default)]` absent case) and `1` both mean district
/// spacing (2,048 m/cell, [`DISTRICT_WINDOW_MAX_N`]'s existing behavior,
/// byte-compatible with every pre-T-1150 caller). `4` means quarter spacing
/// (512 m/cell, D-243) — Option B from the design doc: full reclassification
/// at the finer spacing via `derive_at_metres`, not a coarser-cell
/// interpolation. No other values are legal; `resolve_window_granularity`
/// clamps unrecognized values down to district (never trust the wire, same
/// discipline as `window_n`).
/// Finer-than-district spacing multipliers (T-1150, zoom ladder design doc
/// §3/§5): the number of derived cells per district side. `1` = district
/// spacing (2,048 m/cell, [`DISTRICT_WINDOW_MAX_N`]'s existing behavior).
/// `4` = quarter spacing (512 m/cell, D-243) — Option B from the design doc:
/// full reclassification at the finer spacing via `derive_at_metres`, not a
/// coarser-cell interpolation.
///
/// **T-1159:** these used to also be legal VALUES of the wire-facing
/// `AtlasLayerRequest::window_granularity: u32` field (resolved via the
/// now-removed `resolve_window_granularity`) — that field is retired, fully
/// shadowed by [`WindowGranularity`] since T-1152. These constants remain as
/// internal spacing-multiplier values (see [`WindowGranularity::spacing_multiplier`],
/// [`clamp_window_n`]).
pub const WINDOW_GRANULARITY_DISTRICT: u32 = 1;
pub const WINDOW_GRANULARITY_QUARTER: u32 = 4;
/// The `granularity: u32` value [`DistrictWindowLayer`]'s echo (and the
/// internal cache/coalescing keys) use for [`WindowGranularity::Region`] —
/// **a key-space value, never a legal WIRE INPUT.** [`resolve_window_granularity`]
/// (the legacy field's resolver) never produces this value, and a client
/// sending it on the wire in the legacy `window_granularity` field is
/// indistinguishable from any other unrecognized value — it still resolves
/// to `District` (§ `resolve_window_granularity`'s exhaustive fallback), NOT
/// `Region`. The only way to actually request `Region` is
/// `window_granularity_v2 = Some(WindowGranularity::Region)`.
///
/// **Why this exists at all, given `Region` has no legacy representation:**
/// the aliasing discipline (T-1150 design doc §3, the mandatory
/// granularity-4-vs-1 test) requires that every distinct [`WindowGranularity`]
/// occupy a distinct slot in [`DistrictWindowKey`]/the coalescing key, both of
/// which carry a `u32` granularity component for wire back-compat. Reusing
/// `0` (today's "absent" sentinel, mapped to `District`) or any value
/// `resolve_window_granularity` could legally receive would silently alias a
/// `Region` window onto a `District` or `Quarter` cache slot depending on
/// what a future caller happened to pass — exactly the bug class §3 exists
/// to close. `u32::MAX` can never collide with a real multiplier (multipliers
/// are small integers by construction — 1, 4, and any future finer rung),
/// so it's the natural "this is a key-space tag, not a spacing multiplier"
/// value. A T-1152-aware client reads `granularity_v2` and never looks at
/// this number for `Region` responses; it exists purely so the legacy `u32`
/// slot in the key tuple stays total and never lies about aliasing.
pub const WINDOW_GRANULARITY_REGION_KEY: u32 = u32::MAX;
/// Server-side wire-size ceiling (T-1150, design doc §3 "Cell-count cap"):
/// `window_n² × granularity² ≤ WIRE_CAP_CELLS`. At `WIRE_CAP_CELLS = 4,096`,
/// district `n=64` (the existing [`DISTRICT_WINDOW_MAX_N`] cap) sits exactly
@@ -161,20 +137,19 @@ pub const WIRE_CAP_CELLS: u32 = 4_096;
/// reasoning `MorphologyZone`'s exhaustive-match discipline already
/// established for this codebase (D-239 §6).
///
/// **Precedence over the legacy `u32` field (documented here, the single
/// place both fields are reconciled):** [`AtlasLayerRequest::window_granularity_v2`]
/// wins whenever present and non-`None`; the legacy `u32`
/// [`AtlasLayerRequest::window_granularity`] is consulted ONLY when
/// `window_granularity_v2` is absent (`#[serde(default)]`, every pre-T-1152
/// client). This is a strict either/or, not a merge — a client sending BOTH
/// fields (a mixed old/new build, or a future client hedging compatibility)
/// gets the `v2` field's answer, silently ignoring the legacy `u32`. See
/// [`resolve_window_granularity_v2`], the single widening point for this
/// enum (mirroring `resolve_window_granularity`'s role for the legacy field).
/// **Resolution (T-1152, simplified T-1159):** [`AtlasLayerRequest::window_granularity_v2`]
/// resolves directly via [`resolve_window_granularity_v2`], the single
/// widening point for this enum. Absent (`#[serde(default)]`, `None`)
/// resolves to `District`.
///
/// **T-1159:** this used to also reconcile against a legacy
/// `AtlasLayerRequest::window_granularity: u32` field (whenever THIS field
/// was absent) — that field is retired, fully shadowed since T-1152 and
/// never sent as anything but its byte-compatible default by any caller in
/// this codebase (no external client exists, single-repo client/server pair).
///
/// **Unknown → District** at every resolution boundary (never trust the
/// wire) — same posture as the legacy `u32` path and every other wire-decoded
/// enum in this module.
/// wire) — same posture as every other wire-decoded enum in this module.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum WindowGranularity {
/// 512 m/cell (D-243 `QUARTER_M`) — finer than district, T-1150 Option B.
@@ -207,14 +182,18 @@ impl WindowGranularity {
}
}
/// The legacy `window_granularity: u32` value this variant maps to/from
/// for finer-than-district rungs — `None` for `Region`, which the legacy
/// field cannot express by construction (Tyre's note; this is precisely
/// the gap the enum exists to close). Used only by
/// [`resolve_window_granularity_v2`]'s legacy-fallback branch and by
/// [`DistrictWindowLayer`]'s echo, which still carries the legacy `u32`
/// unchanged for wire back-compat (see that struct's doc).
fn legacy_u32(self) -> Option<u32> {
/// The finer-than-district spacing multiplier ([`WINDOW_GRANULARITY_DISTRICT`]
/// / [`WINDOW_GRANULARITY_QUARTER`]) this variant corresponds to — `None`
/// for `Region`, which has no such multiplier (its spacing is coarser,
/// not a finer subdivision of a district). Used only by
/// [`clamp_window_n_v2`]'s District/Quarter branch to reuse
/// [`clamp_window_n`]'s per-axis-cap math rather than re-deriving it.
///
/// **T-1159:** this used to double as the wire-back-compat value for the
/// now-retired `window_granularity: u32` echo (`DistrictWindowLayer.granularity`)
/// — that role, and the `key_u32()` method that served it, are gone; this
/// is purely an internal spacing-multiplier lookup now.
fn spacing_multiplier(self) -> Option<u32> {
match self {
WindowGranularity::District => Some(WINDOW_GRANULARITY_DISTRICT),
WindowGranularity::Quarter => Some(WINDOW_GRANULARITY_QUARTER),
@@ -222,19 +201,6 @@ impl WindowGranularity {
}
}
/// The `u32` this variant occupies in [`DistrictWindowKey`]/the
/// coalescing key/`DistrictWindowLayer.granularity`'s echo — total over
/// every variant (unlike [`Self::legacy_u32`], which is partial). Finer-
/// than-district variants echo their real legacy multiplier (so a
/// T-1150-only client — one that reads `granularity` but has never heard
/// of `granularity_v2` — still sees the correct, meaningful number for
/// District/Quarter); `Region` echoes the reserved
/// [`WINDOW_GRANULARITY_REGION_KEY`] key-space tag (see that const's doc
/// for why `0` or any real multiplier would be unsafe here).
fn key_u32(self) -> u32 {
self.legacy_u32().unwrap_or(WINDOW_GRANULARITY_REGION_KEY)
}
/// The derived cell-grid side length (in CELLS, at this granularity) for
/// a window whose extent is `n` DISTRICTS (T-1152 step 4: "work out what
/// n means at region granularity against D-243's region=100-district
@@ -276,55 +242,23 @@ impl WindowGranularity {
}
}
/// Resolve a wire-supplied `window_granularity` value to one of the two legal
/// granularities, clamping anything else down to district spacing — **never
/// trust the wire** (same posture as `window_n`/`normalize_window_center`).
///
/// **This is THE single widening point (Tyre C2, PR #191 review) for the
/// LEGACY `u32` field only.** The field only ever expresses finer-than-district
/// integer multiples (see [`AtlasLayerRequest::window_granularity`]'s doc for
/// the full type-seam contract); adding a future finer rung means adding its
/// legal value here and nowhere else. Do NOT add a value < 1 or attempt to
/// encode coarser-than-district rungs (region/orbital) through this function
/// — that direction is [`WindowGranularity::Region`]'s job via
/// [`resolve_window_granularity_v2`], never a new magic `u32` value (the R5
/// redesign this enum performs is EXACTLY the alternative to doing that).
fn resolve_window_granularity(raw: u32) -> u32 {
if raw == WINDOW_GRANULARITY_QUARTER {
WINDOW_GRANULARITY_QUARTER
} else {
WINDOW_GRANULARITY_DISTRICT
}
}
/// Resolve a request's granularity to a [`WindowGranularity`] — **the single
/// widening point for the full (finer- and coarser-than-district) vocabulary**
/// (T-1152, mirroring [`resolve_window_granularity`]'s role for the legacy
/// `u32` alone). Precedence (documented once, here — see
/// [`WindowGranularity`]'s struct doc for the rationale):
/// (T-1152). Absent (`None`, `#[serde(default)]`) resolves to
/// [`WindowGranularity::District`] — unknown/malformed variants can't reach
/// this function at all (`rmp_serde` rejects an unrecognized enum variant
/// name at decode time, so "unknown" for this field means "absent", never
/// "present but garbage").
///
/// 1. `window_granularity_v2` present → resolved directly (unknown/malformed
/// variants can't reach this function at all — `rmp_serde` rejects an
/// unrecognized enum variant name at decode time, so "unknown" for THIS
/// field means "absent", not "present but garbage"; the legacy `u32`
/// path is what actually needs the value-level fallback because a `u32`
/// has no closed vocabulary).
/// 2. `window_granularity_v2` absent → fall back to the legacy `u32` path via
/// [`resolve_window_granularity`], mapped onto the two variants it can
/// express.
///
/// A future coarser-than-region rung is added by widening this function's
/// match AND [`WindowGranularity`]'s variant list together — never by
/// smuggling a new value through the legacy `u32` (that field's ceiling is
/// permanent per Tyre's note, not a restriction this function works around).
/// **T-1159:** this function used to fall back to the legacy `window_granularity: u32`
/// field (via the now-removed `resolve_window_granularity`) when
/// `window_granularity_v2` was absent — that fallback is retired along with
/// the field itself (fully shadowed since T-1152, no pre-T-1152 client
/// exists). A future coarser-than-region rung is added by widening this
/// function's match AND [`WindowGranularity`]'s variant list together.
fn resolve_window_granularity_v2(req: &AtlasLayerRequest) -> WindowGranularity {
match req.window_granularity_v2 {
Some(g) => g,
None => match resolve_window_granularity(req.window_granularity) {
WINDOW_GRANULARITY_QUARTER => WindowGranularity::Quarter,
_ => WindowGranularity::District,
},
}
req.window_granularity_v2
.unwrap_or(WindowGranularity::District)
}
/// Clamp `window_n` against BOTH the existing per-axis cap
@@ -392,7 +326,7 @@ fn clamp_window_n_v2(raw_n: u32, granularity: WindowGranularity) -> u32 {
WindowGranularity::District | WindowGranularity::Quarter => clamp_window_n(
raw_n,
granularity
.legacy_u32()
.spacing_multiplier()
.unwrap_or(WINDOW_GRANULARITY_DISTRICT),
),
WindowGranularity::Region => {
@@ -520,36 +454,19 @@ pub struct AtlasLayerRequest {
/// from the wire** (D-226 T-1124 amendment §4).
#[serde(default)]
pub window_n: u32,
/// Window derivation granularity (T-1150, zoom ladder design doc §3/§5).
/// `0` (absent, `#[serde(default)]`) or `1` = district spacing (2,048 m,
/// today's behavior, byte-compatible with every pre-T-1150 caller); `4` =
/// quarter spacing (512 m, D-243). See [`WINDOW_GRANULARITY_DISTRICT`] /
/// [`WINDOW_GRANULARITY_QUARTER`]. Resolved via
/// [`resolve_window_granularity`] — **never trusted from the wire**,
/// unrecognized values fall back to district.
/// Window derivation granularity (T-1152, R5 redesign — see
/// [`WindowGranularity`]'s doc for the full rationale). `#[serde(default)]`
/// (`None`) resolves to [`WindowGranularity::District`] (today's default
/// behavior, byte-compatible with every pre-T-1150 caller) via
/// [`resolve_window_granularity_v2`] — **never trusted from the wire**,
/// unrecognized/absent values fall back to district.
///
/// **Type seam (Tyre C2, PR #191 review):** this field expresses ONLY
/// finer-than-district integer multiples of the district spacing — `1`
/// and `4` are legal today, and each new finer rung (e.g. a future
/// block/tile value) is a deliberate widening of
/// [`resolve_window_granularity`]'s whitelist, the single point where
/// that widening happens. It CANNOT express coarser-than-district rungs
/// (region/orbital, granularity < 1) — reusing this field for those is
/// explicitly out of scope; design doc §5/§9 R5 requires a
/// signed/log-scale value or an explicit rung enum instead. Do not smuggle
/// a "granularity 0 means region" convention into this `u32` — that is
/// the redesign R5 already flags, not a value to add here.
#[serde(default)]
pub window_granularity: u32,
/// The R5-redesigned granularity vocabulary (T-1152), able to express
/// coarser-than-district rungs the legacy `window_granularity: u32`
/// cannot (Tyre's wire-contract note — see [`WindowGranularity`]'s doc
/// for the full rationale). `#[serde(default)]` (`None`) is the absent
/// case: every pre-T-1152 client (and every T-1150 client that only ever
/// sends the legacy `u32`) omits this field entirely and is byte-compatible
/// — [`resolve_window_granularity_v2`] falls back to the legacy field
/// when this is `None`. When BOTH fields are present, THIS field wins
/// (documented once, on [`WindowGranularity`], not duplicated here).
/// **T-1159:** the legacy `window_granularity: u32` field this superseded
/// (T-1150's finer-than-district-only encoding) is retired — this enum
/// fully shadowed it since T-1152 landed, and no pre-T-1152 client exists
/// (single-repo client/server pair). See D-255(c): the `district_window`
/// carrier itself stays alive byte-unchanged for its existing consumer;
/// only the redundant `u32` alongside this enum is gone.
#[serde(default)]
pub window_granularity_v2: Option<WindowGranularity>,
/// Octave cutoff for the invented-terrain scatter (T-1149's
@@ -805,25 +722,14 @@ pub struct DistrictWindowLayer {
/// (T-1150 design doc §2: "the window's `n` stays the DISTRICT extent").
/// The derived cell grid's actual side length is `n * granularity`.
pub n: u32,
/// Derivation granularity (T-1150): [`WINDOW_GRANULARITY_DISTRICT`] (1)
/// or [`WINDOW_GRANULARITY_QUARTER`] (4) for finer-than-district rungs;
/// [`WINDOW_GRANULARITY_REGION_KEY`] (a reserved key-space tag, NOT a
/// spacing multiplier) when [`Self::granularity_v2`] is `Region` — see
/// that constant's doc. Kept unchanged (never repurposed or removed) for
/// wire back-compat with every pre-T-1152 client, which reads only this
/// field and has no concept of `granularity_v2`. Echoed so the client's
/// cache key and staleness guard can distinguish windows at different
/// finer-than-district rungs requested at the identical `(center, n)`.
pub granularity: u32,
/// The R5-redesigned granularity (T-1152) — see [`WindowGranularity`]'s
/// doc. Always populated (never `None`): the server always resolves a
/// concrete rung internally via [`resolve_window_granularity_v2`]
/// regardless of which wire field the request used, so the response
/// always carries the enum echo alongside the legacy `u32` one. A
/// T-1152-aware client reads THIS field for staleness/cache-key
/// comparison; `granularity` (above) exists only for pre-T-1152 clients,
/// who never see `Region` responses in the first place (they have no way
/// to request one).
/// concrete rung internally via [`resolve_window_granularity_v2`].
///
/// **T-1159:** the legacy `granularity: u32` echo this field used to sit
/// alongside (a wire-back-compat value for pre-T-1152 clients, which do
/// not exist — single-repo client/server pair) is retired. This enum
/// echo has been the sole granularity signal since T-1152.
pub granularity_v2: WindowGranularity,
/// The `min_wavelength_m` octave cutoff (T-1149) this window was derived
/// with, in whole metres (`0` = no cutoff). Echoed for the same reason as
@@ -1616,7 +1522,6 @@ pub fn build_district_window_layer(
DistrictWindowLayer {
center,
n,
granularity: granularity.key_u32(),
granularity_v2: granularity,
min_wl_m,
morphology,
@@ -1713,7 +1618,6 @@ fn build_district_window_layer_serial(
DistrictWindowLayer {
center,
n,
granularity: granularity.key_u32(),
granularity_v2: granularity,
min_wl_m,
morphology,
@@ -2295,6 +2199,11 @@ pub fn handle_atlas_request(
// in DistrictProfile.basin_direction (BodyWorldState.districts) and is
// not needed again here. Supply an empty map.
survey_basin_dirs: std::collections::BTreeMap::new(),
// T-1169: mirrors state.attractors.clone() above — feature_names
// is stored on BodyWorldState (see cascade::into_body_world_state)
// and cloned back out here, same reconstruction discipline as
// every other Layer1Output field on this cache-hit path.
feature_names: state.feature_names.clone(),
};
let district_grid = build_district_grid(state);
let road_graph = build_road_graph_layer(state);
@@ -2346,6 +2255,36 @@ pub fn handle_atlas_request(
}
None => (Vec::new(), None),
};
// Pre-resolve this body's reserved river/mountain name pools so
// the Rayon work item stays DB-free (T-1169, D-223, same D-225
// pattern as `cities` above). Read failure is non-fatal: log and
// fall back to no names (attach_feature_names degrades gracefully
// — every attractor simply gets no name).
let (river_names, mountain_names) = match city_reader {
Some(reader) => match reader.read_body_feature_names(&req.body_id) {
Ok(rows) => {
let mut rivers = Vec::new();
let mut mountains = Vec::new();
for row in rows {
match row.feature_type.as_str() {
"river" => rivers.push(row.name),
"mountain" => mountains.push(row.name),
_ => {}
}
}
(rivers, mountains)
}
Err(e) => {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"feature name read failed; attaching no names"
);
(Vec::new(), Vec::new())
}
},
None => (Vec::new(), Vec::new()),
};
// Pre-resolve body physical params so the Rayon work item stays
// DB-free (D-225 pattern). Read failures are non-fatal: log and
// fall back to None (cascade stops at Settlement, pre-T-1032
@@ -2373,6 +2312,8 @@ pub fn handle_atlas_request(
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
},
GenPriority::Immediate,
);
@@ -2457,6 +2398,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
@@ -2636,7 +2578,6 @@ mod tests {
assert_eq!(layer.center, (10, -5));
assert_eq!(layer.n, n);
assert_eq!(layer.granularity, WINDOW_GRANULARITY_DISTRICT);
assert_eq!(layer.granularity_v2, WindowGranularity::District);
assert_eq!(layer.min_wl_m, 0);
let cells = (n * n) as usize;
@@ -3894,7 +3835,6 @@ mod tests {
let mk = |center, n| DistrictWindowLayer {
center,
n,
granularity: WINDOW_GRANULARITY_DISTRICT,
granularity_v2: WindowGranularity::District,
min_wl_m: 0,
morphology: vec![0; (n * n) as usize],
@@ -3943,7 +3883,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((0, 0)),
window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4004,8 +3943,7 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((0, 0)),
window_n: 32,
window_granularity: WINDOW_GRANULARITY_QUARTER,
window_granularity_v2: None,
window_granularity_v2: Some(WindowGranularity::Quarter),
window_min_wl_m: 0,
};
@@ -4035,7 +3973,8 @@ mod tests {
});
let layer = window_completion.expect("DeriveWindow must complete for GJ1c");
assert_eq!(
layer.granularity, WINDOW_GRANULARITY_QUARTER,
layer.granularity_v2,
WindowGranularity::Quarter,
"granularity must echo back as requested (4 is within budget on its own)"
);
assert_eq!(
@@ -4045,36 +3984,9 @@ mod tests {
}
// -------------------------------------------------------------------
// resolve_window_granularity / clamp_window_n (T-1150)
// clamp_window_n (T-1150)
// -------------------------------------------------------------------
#[test]
fn resolve_window_granularity_maps_known_values() {
assert_eq!(resolve_window_granularity(0), WINDOW_GRANULARITY_DISTRICT);
assert_eq!(
resolve_window_granularity(WINDOW_GRANULARITY_DISTRICT),
WINDOW_GRANULARITY_DISTRICT
);
assert_eq!(
resolve_window_granularity(WINDOW_GRANULARITY_QUARTER),
WINDOW_GRANULARITY_QUARTER
);
}
/// Never trust the wire: an unrecognized granularity value (garbage, or a
/// future rung not yet implemented) falls back to district, never panics
/// or propagates un-vetted.
#[test]
fn resolve_window_granularity_unknown_value_falls_back_to_district() {
for garbage in [2, 3, 5, 100, u32::MAX] {
assert_eq!(
resolve_window_granularity(garbage),
WINDOW_GRANULARITY_DISTRICT,
"unrecognized granularity {garbage} must fall back to district"
);
}
}
/// District granularity: the per-axis DISTRICT_WINDOW_MAX_N cap alone
/// governs (64² × 1² = 4,096 = WIRE_CAP_CELLS exactly, so the cap is
/// never tighter than DISTRICT_WINDOW_MAX_N at granularity 1).
@@ -4283,7 +4195,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((10, -5)),
window_n: 4,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 4_000,
};
@@ -4292,7 +4203,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((10, -5)),
window_n: 4,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 4_300,
};
@@ -4510,7 +4420,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((12276, 3021)),
window_n: 4,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4567,7 +4476,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((4, 383)), // the hand-computed canonical twin
window_n: 4,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4650,7 +4558,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4659,8 +4566,7 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
window_granularity: WINDOW_GRANULARITY_QUARTER,
window_granularity_v2: None,
window_granularity_v2: Some(WindowGranularity::Quarter),
window_min_wl_m: 0,
};
@@ -4750,8 +4656,6 @@ mod tests {
.district_window
.expect("quarter request must hit its own cached entry");
assert_eq!(district_layer.granularity, WINDOW_GRANULARITY_DISTRICT);
assert_eq!(quarter_layer.granularity, WINDOW_GRANULARITY_QUARTER);
assert_eq!(district_layer.granularity_v2, WindowGranularity::District);
assert_eq!(quarter_layer.granularity_v2, WindowGranularity::Quarter);
// n echoes the DISTRICT extent unchanged at both granularities
@@ -4810,7 +4714,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4819,9 +4722,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
// Legacy field is irrelevant here — window_granularity_v2 takes
// precedence per resolve_window_granularity_v2's documented rule.
window_granularity: 0,
window_granularity_v2: Some(WindowGranularity::Region),
window_min_wl_m: 0,
};
@@ -4911,12 +4811,7 @@ mod tests {
assert_eq!(district_layer.granularity_v2, WindowGranularity::District);
assert_eq!(region_layer.granularity_v2, WindowGranularity::Region);
// The legacy u32 echo must NEVER collide with a real multiplier —
// WINDOW_GRANULARITY_REGION_KEY is the reserved key-space tag (see
// that constant's doc), distinct from both WINDOW_GRANULARITY_DISTRICT
// (1) and WINDOW_GRANULARITY_QUARTER (4).
assert_eq!(region_layer.granularity, WINDOW_GRANULARITY_REGION_KEY);
assert_ne!(region_layer.granularity, district_layer.granularity);
assert_ne!(region_layer.granularity_v2, district_layer.granularity_v2);
// n echoes the DISTRICT extent unchanged (design doc §2), same as
// every other rung — the derived CELL GRID is what differs.
@@ -4959,7 +4854,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((0, 0)),
window_n: DISTRICT_WINDOW_MAX_N_REGION * 10,
window_granularity: 0,
window_granularity_v2: Some(WindowGranularity::Region),
window_min_wl_m: 0,
};
@@ -5019,7 +4913,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((12276, 3021)), // raw, out-of-range
window_n: 4,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -5100,7 +4993,6 @@ mod tests {
let window = DistrictWindowLayer {
center: (10, -5),
n: 2,
granularity: WINDOW_GRANULARITY_DISTRICT,
granularity_v2: WindowGranularity::District,
min_wl_m: 0,
morphology: vec![0, 8, 14, 16],
@@ -5161,8 +5053,8 @@ mod tests {
assert_eq!(decoded.window_center, None);
assert_eq!(decoded.window_n, 0);
assert_eq!(
decoded.window_granularity, 0,
"T-1150: absent window_granularity decodes to 0 (district), byte-compatible"
decoded.window_granularity_v2, None,
"T-1152/T-1159: absent window_granularity_v2 decodes to None (district), byte-compatible"
);
assert_eq!(
decoded.window_min_wl_m, 0,
@@ -5514,6 +5406,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
@@ -5784,7 +5677,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
}
@@ -5861,6 +5753,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
+60 -3
View File
@@ -15,7 +15,8 @@ use std::collections::BTreeMap;
use std::sync::Arc;
use crate::atlas::atlas_data_proxy::{
handle_city_names_request, handle_star_map_request, StarMapDataPath,
handle_city_names_request, handle_feature_names_request, handle_star_map_request,
StarMapDataPath,
};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::body_params_reader::BodyParamsReaderResource;
@@ -52,7 +53,8 @@ use crate::atlas::trait_swerve::{
};
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BrowseRequestBuffer, BrowseResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, FeatureNamesRequestBuffer,
FeatureNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
StepCanvasRequestBuffer, StepCanvasResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
@@ -83,6 +85,10 @@ impl Plugin for GenerationPlugin {
Update,
serve_city_names_requests.in_set(TickPhase::PreInput),
)
.add_systems(
Update,
serve_feature_names_requests.in_set(TickPhase::PreInput),
)
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput))
.add_systems(
Update,
@@ -201,6 +207,26 @@ fn serve_city_names_requests(
}
}
/// Drain inbound feature-names requests and serve each through the proxy
/// (T-1169): D-236 Sol check, then the names-only `atlas_feature_names` read.
/// Mirrors [`serve_city_names_requests`] exactly.
fn serve_feature_names_requests(
mut requests: ResMut<FeatureNamesRequestBuffer>,
mut responses: ResMut<FeatureNamesResponseBuffer>,
city_reader: Option<Res<CityContextReaderResource>>,
) {
if requests.0.is_empty() {
return;
}
let reader = city_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for (conn_id, req) in pending {
responses
.0
.push((conn_id, handle_feature_names_request(&req, reader)));
}
}
/// Drain inbound data-browser requests and serve each through the proxy
/// (D-254 §4, T-1131): one of the six v1 registry-tier entity kinds, dispatched
/// to `BrowseReader` by `(kind, query)`.
@@ -1043,6 +1069,8 @@ mod tests {
cities: vec![],
dominant_faction: None,
body_params: None, // T-1023: no DB params in this unit test
river_names: vec![],
mountain_names: vec![],
},
GenPriority::Immediate,
);
@@ -1079,7 +1107,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
},
@@ -1193,6 +1220,36 @@ mod tests {
assert!(world.resource::<CityNamesRequestBuffer>().0.is_empty());
}
#[test]
fn serve_feature_names_without_reader_is_error() {
use crate::atlas::atlas_data_proxy::{FeatureNamesRequest, FeatureNamesStatus};
let mut world = World::new();
world.insert_resource(FeatureNamesRequestBuffer(vec![(
ConnectionId(0),
FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".to_string(),
},
)]));
world.insert_resource(FeatureNamesResponseBuffer::default());
// No CityContextReaderResource.
let mut sched = Schedule::default();
sched.add_systems(serve_feature_names_requests);
sched.run(&mut world);
let responses = world.resource::<FeatureNamesResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.body_id, "GJ1c");
assert!(matches!(
responses.0[0].1.status,
FeatureNamesStatus::Error(_)
));
assert!(world.resource::<FeatureNamesRequestBuffer>().0.is_empty());
}
fn sample_read_set() -> CityEconomicReadSet {
use crate::simulation::generator::SettlementClass;
CityEconomicReadSet {
+11 -1
View File
@@ -3,7 +3,7 @@
// Deterministic client-server communication via Unix domain sockets
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, FeatureNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::atlas::step_canvas::StepCanvasResponse;
@@ -169,6 +169,16 @@ impl SimBridge for LocalBridge {
Ok(())
}
fn send_feature_names_response(&self, resp: &FeatureNamesResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
write_framed(writer.get_mut(), &payload)?;
Ok(())
}
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
+145 -12
View File
@@ -7,7 +7,8 @@ use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::atlas::atlas_data_proxy::{
CityNamesRequest, CityNamesResponse, StarMapRequest, StarMapResponse,
CityNamesRequest, CityNamesResponse, FeatureNamesRequest, FeatureNamesResponse, StarMapRequest,
StarMapResponse,
};
use crate::atlas::browse_proxy::{BrowseRequest, BrowseResponse};
use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse};
@@ -80,7 +81,11 @@ pub enum BridgeError {
/// exactly this kind of required marker field — there is no natural ceiling
/// on the DEMUX mechanism itself, only a discipline reminder that a new
/// shape should justify why it can't ride an existing one (as
/// `StepCanvasRequest` does, D-255(c)).
/// `StepCanvasRequest` does, D-255(c)). [`FeatureNamesRequest`] (T-1169) is
/// the SEVENTH shape, riding the identical discriminator convention
/// (`feature_names: bool` + `body_id`, D-236 pattern mirroring
/// `CityNamesRequest` exactly) — confirming the "no natural ceiling, just
/// justify the new shape" discipline this note predicted.
#[derive(Debug)]
pub enum Inbound {
/// A batch of player inputs (the gameplay path).
@@ -97,6 +102,9 @@ pub enum Inbound {
/// A D-255(a) step-canvas data-canvas request (T-1181, the D-225
/// tagged-envelope migration, executed).
StepCanvasRequest(StepCanvasRequest),
/// A per-body reserved-feature-names request (T-1169, D-236 pattern —
/// mirrors [`Self::CityNamesRequest`]).
FeatureNamesRequest(FeatureNamesRequest),
}
/// Key-presence probe for the defensive multi-shape check in
@@ -111,14 +119,17 @@ struct ShapeProbe {
city_names: Option<serde::de::IgnoredAny>,
browse: Option<serde::de::IgnoredAny>,
step_canvas: Option<serde::de::IgnoredAny>,
feature_names: Option<serde::de::IgnoredAny>,
}
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131,
/// T-1181). Tries, in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest`
/// (map, `body_id`+`up_to`) → `StarMapRequest` (map, `star_map`
/// discriminator) → `CityNamesRequest` (map, `city_names` discriminator +
/// `body_id`) → `BrowseRequest` (map, `browse` discriminator) →
/// `StepCanvasRequest` (map, `step_canvas` discriminator).
/// T-1181, T-1169). Tries, in order: `Vec<PlayerInput>` (array) →
/// `AtlasLayerRequest` (map, `body_id`+`up_to`) → `StarMapRequest` (map,
/// `star_map` discriminator) → `CityNamesRequest` (map, `city_names`
/// discriminator + `body_id`) → `BrowseRequest` (map, `browse`
/// discriminator) → `FeatureNamesRequest` (map, `feature_names`
/// discriminator + `body_id`) → `StepCanvasRequest` (map, `step_canvas`
/// discriminator).
///
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
@@ -126,7 +137,7 @@ struct ShapeProbe {
/// more than one shape — e.g. a buggy encoder emitting
/// `{"star_map": true, "city_names": true, ...}` — instead of silently
/// routing it to whichever shape is tried first (PR #176 review H1). A frame
/// satisfying none of the six shapes is a genuinely malformed input frame.
/// satisfying none of the seven shapes is a genuinely malformed input frame.
pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
return Ok(Inbound::Inputs(inputs));
@@ -141,21 +152,24 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
let city_names = probe.city_names.is_some();
let browse = probe.browse.is_some();
let step_canvas = probe.step_canvas.is_some();
let feature_names = probe.feature_names.is_some();
let shapes = usize::from(atlas)
+ usize::from(star_map)
+ usize::from(city_names)
+ usize::from(browse)
+ usize::from(step_canvas);
+ usize::from(step_canvas)
+ usize::from(feature_names);
if shapes > 1 {
let dump_len = payload.len().min(256);
tracing::error!(
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}, step_canvas={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}, step_canvas={}, feature_names={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
shapes,
atlas,
star_map,
city_names,
browse,
step_canvas,
feature_names,
dump_len,
payload.len(),
&payload[..dump_len]
@@ -178,6 +192,9 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(req) = rmp_serde::from_slice::<BrowseRequest>(payload) {
return Ok(Inbound::BrowseRequest(req));
}
if let Ok(req) = rmp_serde::from_slice::<FeatureNamesRequest>(payload) {
return Ok(Inbound::FeatureNamesRequest(req));
}
match rmp_serde::from_slice::<StepCanvasRequest>(payload) {
Ok(req) => Ok(Inbound::StepCanvasRequest(req)),
Err(e) => {
@@ -229,6 +246,9 @@ pub trait SimBridge: Send + Sync {
/// Send a city-names response to the client (T-949b).
fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError>;
/// Send a feature-names response to the client (T-1169).
fn send_feature_names_response(&self, resp: &FeatureNamesResponse) -> Result<(), BridgeError>;
/// Send a browse response to the client (T-1131).
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError>;
@@ -479,6 +499,27 @@ impl BridgeResource {
}
}
/// Send a feature-names response to exactly the connection that
/// requested it (T-1169 — mirrors [`Self::send_city_names_response_to`]
/// exactly).
pub fn send_feature_names_response_to(
&self,
id: ConnectionId,
resp: &FeatureNamesResponse,
) -> Result<(), BridgeError> {
match self.connection(id) {
Some(c) => c.bridge.send_feature_names_response(resp),
None => {
tracing::debug!(
"feature names response for {:?} dropped — connection {:?} no longer present",
resp.body_id,
id
);
Ok(())
}
}
}
/// Send a browse response to exactly the connection that requested it
/// (T-1131 — same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names).
@@ -573,6 +614,7 @@ pub fn receive_bridge_inputs(
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
mut browse_requests: ResMut<BrowseRequestBuffer>,
mut step_canvas_requests: ResMut<StepCanvasRequestBuffer>,
mut feature_names_requests: ResMut<FeatureNamesRequestBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(mut bridge) = bridge else { return };
@@ -621,6 +663,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_requests.0.push((player_id, req));
}
Ok(Some(Inbound::FeatureNamesRequest(req))) => {
feature_names_requests.0.push((player_id, req));
}
// No complete frame ready — the backlog is drained.
Ok(None) => break,
Err(BridgeError::Disconnected) => {
@@ -738,6 +783,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_requests.0.push((reader_id, req));
}
Ok(Some(Inbound::FeatureNamesRequest(req))) => {
feature_names_requests.0.push((reader_id, req));
}
Ok(None) => break,
Err(BridgeError::Disconnected) => {
tracing::info!("Reader connection {:?} disconnected", reader_id);
@@ -952,6 +1000,37 @@ pub fn send_city_names_responses(
}
}
/// Inbound feature-names requests routed off the bridge (T-1169), drained by
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct FeatureNamesRequestBuffer(pub Vec<(ConnectionId, FeatureNamesRequest)>);
/// Outbound feature-names responses, filled by the proxy serve system and
/// flushed to the client in `PostSnapshot` (T-1169). Connection-tagged
/// (D-254 §2).
#[derive(Resource, Default)]
pub struct FeatureNamesResponseBuffer(pub Vec<(ConnectionId, FeatureNamesResponse)>);
/// Flush buffered feature-names responses to their requesting connections
/// (T-1169; D-254 §2 per-connection routing). A failed send is logged but not
/// fatal. Mirrors [`send_city_names_responses`] exactly.
pub fn send_feature_names_responses(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<FeatureNamesResponseBuffer>,
) {
let Some(bridge) = bridge else { return };
for (id, resp) in buffer.0.drain(..) {
if let Err(e) = bridge.send_feature_names_response_to(id, &resp) {
tracing::warn!(
"failed to send feature names response for {} to {:?}: {}",
resp.body_id,
id,
e
);
}
}
}
/// Inbound data-browser requests routed off the bridge (T-1131), drained by
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
@@ -1170,6 +1249,8 @@ impl Plugin for BridgePlugin {
.init_resource::<StarMapResponseBuffer>()
.init_resource::<CityNamesRequestBuffer>()
.init_resource::<CityNamesResponseBuffer>()
.init_resource::<FeatureNamesRequestBuffer>()
.init_resource::<FeatureNamesResponseBuffer>()
.init_resource::<BrowseRequestBuffer>()
.init_resource::<BrowseResponseBuffer>()
.init_resource::<StepCanvasRequestBuffer>()
@@ -1198,6 +1279,10 @@ impl Plugin for BridgePlugin {
Update,
send_city_names_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_feature_names_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_browse_responses.in_set(TickPhase::PostSnapshot),
@@ -1288,7 +1373,6 @@ mod inbound_tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -1324,6 +1408,56 @@ mod inbound_tests {
));
}
/// T-1169: `FeatureNamesRequest` mirrors `CityNamesRequest`'s demux test
/// exactly — the seventh shape rides the same discriminator convention.
#[test]
fn demux_routes_feature_names_requests() {
let req = FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
};
let frame = rmp_serde::to_vec_named(&req).unwrap();
assert!(matches!(
decode_inbound(&frame),
Ok(Inbound::FeatureNamesRequest(r)) if r.body_id == "GJ1c"
));
// Cross-check: a CityNamesRequest frame must NOT decode as
// FeatureNamesRequest even though both key on `body_id` — the
// missing `feature_names` discriminator makes that a hard failure.
let city_names_frame = rmp_serde::to_vec_named(&CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(rmp_serde::from_slice::<FeatureNamesRequest>(&city_names_frame).is_err());
assert!(rmp_serde::from_slice::<CityNamesRequest>(&frame).is_err());
}
/// PR #176 review H1's union-frame rejection extended to the seventh
/// shape (T-1169): a frame carrying BOTH `city_names` and
/// `feature_names` discriminators must be rejected, not silently routed
/// to whichever shape `decode_inbound` tries first.
#[test]
fn ambiguous_city_and_feature_names_union_frame_is_rejected() {
#[derive(serde::Serialize)]
struct CityAndFeature {
city_names: bool,
feature_names: bool,
body_id: String,
}
let frame = rmp_serde::to_vec_named(&CityAndFeature {
city_names: true,
feature_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"city_names+feature_names union frame must be rejected"
);
}
/// T-949: the array-vs-map trick (D-225) still separates `Inputs` from
/// everything else, and the three map shapes' discriminator fields keep
/// them mutually exclusive — each of the four frame shapes decodes to
@@ -1336,7 +1470,6 @@ mod inbound_tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
})
+15 -1
View File
@@ -4,7 +4,7 @@
// Used for Godot client which lacks Unix socket support
use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, FeatureNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::atlas::step_canvas::StepCanvasResponse;
@@ -316,6 +316,20 @@ impl SimBridge for TcpBridge {
Ok(())
}
fn send_feature_names_response(&self, resp: &FeatureNamesResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
let stream = writer.get_mut();
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
let result = write_framed(stream, &payload);
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
result?;
Ok(())
}
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
+29 -12
View File
@@ -342,21 +342,23 @@ fn single_tick_drains_all_ready_inbound_frames() {
use settled_reach_server::atlas::layer_proxy::AtlasLayerRequest;
use settled_reach_server::bridge::{
receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, BrowseRequestBuffer,
CityNamesRequestBuffer, HandshakeState, ServerRunning, StarMapRequestBuffer,
StepCanvasRequestBuffer,
CityNamesRequestBuffer, FeatureNamesRequestBuffer, HandshakeState, ServerRunning,
StarMapRequestBuffer, StepCanvasRequestBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
let server_addr = listener.local_addr().expect("failed to get local address");
// Client: five frames back-to-back in one tick window — two input
// batches plus one of EACH request shape (atlas, star-map, city-names:
// the full D-225/T-949 demux surface over the real framing/poll path —
// PR #176 review H6). Returns the stream so it stays open until
// assertions complete (no EOF race).
// Client: six frames back-to-back in one tick window — two input
// batches plus one of EACH request shape (atlas, star-map, city-names,
// feature-names: the full D-225/T-949/T-1169 demux surface over the real
// framing/poll path — PR #176 review H6). Returns the stream so it stays
// open until assertions complete (no EOF race).
let client_handle = thread::spawn(move || {
use settled_reach_server::atlas::atlas_data_proxy::{CityNamesRequest, StarMapRequest};
use settled_reach_server::atlas::atlas_data_proxy::{
CityNamesRequest, FeatureNamesRequest, StarMapRequest,
};
let mut stream = TcpStream::connect(server_addr).expect("failed to connect");
for tick in [20u64, 21] {
@@ -372,7 +374,6 @@ fn single_tick_drains_all_ready_inbound_frames() {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -387,6 +388,12 @@ fn single_tick_drains_all_ready_inbound_frames() {
};
let payload = rmp_serde::to_vec_named(&cn).expect("failed to serialize city names");
write_framed(&mut stream, &payload).expect("write city names frame");
let fn_req = FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
};
let payload = rmp_serde::to_vec_named(&fn_req).expect("failed to serialize feature names");
write_framed(&mut stream, &payload).expect("write feature names frame");
stream
});
@@ -406,6 +413,7 @@ fn single_tick_drains_all_ready_inbound_frames() {
world.init_resource::<CityNamesRequestBuffer>();
world.init_resource::<BrowseRequestBuffer>();
world.init_resource::<StepCanvasRequestBuffer>();
world.init_resource::<FeatureNamesRequestBuffer>();
world
.run_system_once(receive_bridge_inputs)
@@ -433,6 +441,13 @@ fn single_tick_drains_all_ready_inbound_frames() {
"the city-names request must drain in the same tick (H6: real wire path)"
);
assert_eq!(city_names[0].1.body_id, "GJ1c");
let feature_names = &world.resource::<FeatureNamesRequestBuffer>().0;
assert_eq!(
feature_names.len(),
1,
"the feature-names request must drain in the same tick (T-1169: real wire path)"
);
assert_eq!(feature_names[0].1.body_id, "GJ1c");
assert!(
world.resource::<ServerRunning>().0,
"draining must not shut the server down"
@@ -502,8 +517,9 @@ fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr
use settled_reach_server::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, BrowseRequestBuffer,
BrowseResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer, ConnectionListener,
HandshakeState, PendingConnections, ServerRunning, SnapshotBuffer, StarMapRequestBuffer,
StarMapResponseBuffer, StepCanvasRequestBuffer, StepCanvasResponseBuffer,
FeatureNamesRequestBuffer, FeatureNamesResponseBuffer, HandshakeState, PendingConnections,
ServerRunning, SnapshotBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
StepCanvasRequestBuffer, StepCanvasResponseBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
@@ -531,6 +547,8 @@ fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr
world.init_resource::<BrowseResponseBuffer>();
world.init_resource::<StepCanvasRequestBuffer>();
world.init_resource::<StepCanvasResponseBuffer>();
world.init_resource::<FeatureNamesRequestBuffer>();
world.init_resource::<FeatureNamesResponseBuffer>();
world.init_resource::<SnapshotBuffer>();
(world, addr)
}
@@ -1515,7 +1533,6 @@ mod connection_zero_window_delivery {
up_to: CascadeLayer::Topography,
window_center: Some(center),
window_n: 4,
window_granularity: 0,
window_granularity_v2: Some(WindowGranularity::Region),
window_min_wl_m: 0,
}
+18 -2
View File
@@ -112,6 +112,14 @@
//! additive at the position level, exactly as designed. No duplicate
//! `(row, col)` positions exist in the final array (verified — the
//! `edge_ids_are_unique` invariant `build_edges` depends on holds).
//!
//! **Fourth deliberate re-pin (T-1169, D-223):** `Layer1Output` gained an
//! additive `feature_names: Vec<FeatureNameAssignment>` field (river-mouth/
//! alpine-peak name assignments from the new `attach_feature_names` cascade
//! wiring). This fixture calls `run_cascade_from_heightmap` with empty
//! `river_names`/`mountain_names` pools (no DB access from this test), so
//! the field serializes as `[]` — a pure additive-shape change, zero effect
//! on every pre-existing field.
use std::path::PathBuf;
@@ -149,8 +157,16 @@ fn cascade_layer0_to_1_matches_golden() {
// ── Layer 1 — downsample, then run the cascade to topography. ───────────
let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1);
let body_seed = SeedChain::for_body(WORLD_SEED, "GJ1c");
let snapshot =
run_cascade_from_heightmap(body_seed, small, &[], None, None, CascadeLayer::Topography);
let snapshot = run_cascade_from_heightmap(
body_seed,
small,
&[],
None,
None,
&[],
&[],
CascadeLayer::Topography,
);
let layer1 = snapshot.layer1.expect("Layer 1 ran");
let actual = json!({
+1 -2
View File
@@ -7,7 +7,6 @@ use settled_reach_server::atlas::layer_proxy::{
AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry,
QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode,
SettlementEntry, SettlementLayer, SettlementSizeClass, WindowGranularity, REGION_TEMP_NONE_DC,
WINDOW_GRANULARITY_DISTRICT,
};
use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState};
use settled_reach_server::atlas::road_graph::RoadNodeKind;
@@ -579,6 +578,7 @@ fn generate_atlas_layer_response_fixtures() {
grid_w: 512,
grid_h: 256,
survey_basin_dirs: std::collections::BTreeMap::new(),
feature_names: vec![],
};
// T-960 §1/§2: a small populated RoadGraphLayer + SettlementLayer, one
// settlement (a capital) connected to one waypoint-free short edge.
@@ -729,7 +729,6 @@ fn generate_atlas_layer_response_fixtures() {
let window = DistrictWindowLayer {
center: (10, -5),
n: 2,
granularity: WINDOW_GRANULARITY_DISTRICT,
granularity_v2: WindowGranularity::District,
min_wl_m: 0,
morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland
+1
View File
@@ -7761,6 +7761,7 @@
"territorial_status": "FrontierUnclaimed"
}
],
"feature_names": [],
"grid_h": 128,
"grid_w": 256,
"river_network": {
@@ -95,10 +95,10 @@
"glaciation": 0,
"precipitation": 3,
"slope_q": 0,
"elev_q": 26,
"elev_q": 27,
"ocean_fraction_q": 0,
"temperature_dc": 127,
"moisture_q": 57,
"temperature_dc": 122,
"moisture_q": 56,
"vegetation": 3
},
{
@@ -248,7 +248,7 @@
"glaciation": 2,
"precipitation": 1,
"slope_q": 0,
"elev_q": 62,
"elev_q": 63,
"ocean_fraction_q": 0,
"temperature_dc": -120,
"moisture_q": 43,
@@ -452,7 +452,7 @@
"glaciation": 0,
"precipitation": 0,
"slope_q": 0,
"elev_q": 41,
"elev_q": 42,
"ocean_fraction_q": 0,
"temperature_dc": -2147483648,
"moisture_q": 0,
@@ -503,7 +503,7 @@
"glaciation": 0,
"precipitation": 0,
"slope_q": 0,
"elev_q": 42,
"elev_q": 43,
"ocean_fraction_q": 0,
"temperature_dc": -2147483648,
"moisture_q": 0,
@@ -707,9 +707,9 @@
"glaciation": 0,
"precipitation": 3,
"slope_q": 0,
"elev_q": 28,
"elev_q": 29,
"ocean_fraction_q": 0,
"temperature_dc": 728,
"temperature_dc": 723,
"moisture_q": 59,
"vegetation": 3
},
@@ -809,9 +809,9 @@
"glaciation": 0,
"precipitation": 2,
"slope_q": 0,
"elev_q": 23,
"elev_q": 24,
"ocean_fraction_q": 0,
"temperature_dc": 697,
"temperature_dc": 691,
"moisture_q": 49,
"vegetation": 3
},
@@ -860,10 +860,10 @@
"glaciation": 0,
"precipitation": 2,
"slope_q": 0,
"elev_q": 66,
"elev_q": 67,
"ocean_fraction_q": 0,
"temperature_dc": 300,
"moisture_q": 30,
"moisture_q": 29,
"vegetation": 2
},
{
@@ -164,6 +164,93 @@ def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int:
return len(rows)
# markers.json `names` key -> atlas_feature_names.feature_type value. Only
# rivers/mountain_ranges are populated here (T-1169 scope — the two pools
# server/src/atlas/layer1.rs's `attach_feature_names` actually consumes,
# river_names/mountain_names). `oceans`/`passes`/`regions` pools are NOT
# wired to any production consumer yet (no `attach_feature_names`-equivalent
# reads them) — populating rows for them here would be dead data with
# nothing to keep it honest, the same D-223 discipline that keeps
# atlas_city_names to names an actual placement pipeline consumes.
_FEATURE_NAME_POOL_KEYS: dict[str, str] = {
"rivers": "river",
"mountain_ranges": "mountain",
}
def populate_atlas_feature_names(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Populate atlas_feature_names from the names-only markers.json pools
(D-223, T-1169) — mirrors `populate_atlas_city_names` exactly, over the
`rivers`/`mountain_ranges` marker keys instead of `cities`:
- body_id : directory name (e.g. GJ0e)
- name : pooled feature name
- feature_type : 'river' | 'mountain' (see `_FEATURE_NAME_POOL_KEYS`)
- priority : 0 — no authored ranking signal in markers.json; the
server-side name attachment (`layer1::attach_feature_names`)
ranks by computed mouth/peak strength at assignment time,
not by a priority stored on the pool row.
markers.json is a names-only flavoured pool (D-223): it carries no
geometry or position. `attach_feature_names` (Phase 4 cascade) attaches
these names to computed river mouths / alpine peaks at generation time;
this importer just loads the pool, exactly as `populate_atlas_city_names`
loads the city pool for the settlement placement pipeline.
Deterministic rebuild: clears atlas_feature_names first, so re-runs are
idempotent — there is no UNIQUE(body_id, name, feature_type), so without
the clear a re-run would accumulate duplicates. Skips body directories not
found in the bodies table (missing FK), and Sol bodies (D-223 permanent
exemption — same as `populate_atlas_city_names`, Sol keeps authored
geometry-bearing markers.json via sol_import.py, not the names pool).
"""
valid_body_ids: set[str] = {
r[0] for r in conn.execute("SELECT body_id FROM bodies").fetchall()
}
sol_body_ids: set[str] = {
r[0] for r in conn.execute(
"SELECT body_id FROM bodies WHERE system_id = 'GJ 0'"
).fetchall()
}
rows: list[tuple] = []
skipped_bodies: list[str] = []
pattern = str(WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "markers.json")
for markers_path in sorted(glob.glob(pattern)):
body_id = markers_path.split("/bodies/")[1].split("/")[0]
if body_id not in valid_body_ids or body_id in sol_body_ids:
if body_id not in valid_body_ids:
skipped_bodies.append(body_id)
continue
with open(markers_path) as fh:
data = json.load(fh)
names = data.get("names") or {}
for pool_key, feature_type in _FEATURE_NAME_POOL_KEYS.items():
for raw_name in names.get(pool_key) or []:
name = (raw_name or "").strip()
if not name:
continue
rows.append((body_id, name, feature_type, 0))
if skipped_bodies:
unique = sorted(set(skipped_bodies))
print(f" warning: {len(unique)} body dirs not in DB — skipped: {unique[:5]}")
if not dry_run:
conn.execute("DELETE FROM atlas_feature_names")
if rows:
conn.executemany(
"""INSERT INTO atlas_feature_names
(body_id, name, feature_type, priority)
VALUES (?, ?, ?, ?)""",
rows,
)
return len(rows)
# body_type rank for the most_populated_body_in_system LAST-RESORT tiebreak —
# lower sorts first. This is NOT "belts can't host settlements" (they can and
# DO in this world: GJ845-belt hosts Orkney Ceramics, GJ268-belt hosts Jeju
+9
View File
@@ -228,6 +228,15 @@ def main() -> None:
n_cities = atlas.populate_atlas_city_names(conn, args.dry_run)
print(f" {n_cities} city name rows")
# 12b. atlas_feature_names (rivers/mountains) from wiki markers.json
# (D-223, T-1169) — mirrors step 12's pool-load shape exactly, over a
# different names-only markers.json key set. Independent of the
# settlement pool, so order relative to step 13 doesn't matter; placed
# here to stay adjacent to its sibling pool-load step.
print(" [12b/13] Populating atlas_feature_names from wiki content...")
n_features = atlas.populate_atlas_feature_names(conn, args.dry_run)
print(f" {n_features} feature name rows")
# 13. Standalone-HQ settlements + CityTenant city links (D-242, T-1074) — Phase B.
# SUPERSEDES the retired corp-HQ cross-reference (D-207, #909) — that
# step inserted one atlas_city_names row per corp HQ with no