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>
This commit is contained in:
+145
-3
@@ -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,31 @@ 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 +495,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 +507,8 @@ pub fn run_cascade(
|
||||
cities,
|
||||
dominant_faction,
|
||||
body_params,
|
||||
river_names,
|
||||
mountain_names,
|
||||
up_to,
|
||||
))
|
||||
}
|
||||
@@ -508,6 +551,8 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::Heightmap,
|
||||
);
|
||||
assert_eq!(snap.body_id, "test_body");
|
||||
@@ -525,12 +570,91 @@ 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 +670,8 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
let b = extract(run_cascade_from_heightmap(
|
||||
@@ -554,6 +680,8 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -586,6 +714,8 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::Heightmap,
|
||||
);
|
||||
assert!(res.is_err(), "missing heightmap must Err, not panic");
|
||||
@@ -609,6 +739,8 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
Some(¶ms),
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::DistrictProfile,
|
||||
)
|
||||
};
|
||||
@@ -668,6 +800,8 @@ mod tests {
|
||||
&cities,
|
||||
Some("concord_assembly"),
|
||||
None, // body_params
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::Settlement,
|
||||
)
|
||||
};
|
||||
@@ -756,6 +890,8 @@ mod tests {
|
||||
&cities,
|
||||
Some("independent"),
|
||||
None, // body_params — road graph needs none
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::RoadGraph,
|
||||
)
|
||||
};
|
||||
@@ -860,6 +996,8 @@ mod tests {
|
||||
&cities,
|
||||
Some("independent"),
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::RoadGraph,
|
||||
);
|
||||
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
|
||||
@@ -927,6 +1065,8 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
Some(¶ms),
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::Region,
|
||||
)
|
||||
};
|
||||
@@ -980,6 +1120,8 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
CascadeLayer::Region,
|
||||
);
|
||||
assert!(no_params.layer_region.is_none());
|
||||
|
||||
Reference in New Issue
Block a user