diff --git a/server/src/atlas/features.rs b/server/src/atlas/features.rs index 58dda1dcf..97ef70f63 100644 --- a/server/src/atlas/features.rs +++ b/server/src/atlas/features.rs @@ -424,16 +424,45 @@ pub fn extract_attractors( claim(&mut out, &mut claimed, r, c, AttractorType::PlainCenter, s); } - // Cap by keeping the strongest across ALL types (so a coast-heavy body - // doesn't starve ValleyFloor/PassEntrance/etc.), then sort the survivors - // deterministically by (attractor_type, row, col). + // Cap to MAX_ATTRACTORS while preserving type diversity. D-209 calls + // RiverMouth "always high-value", but its *normalized* strength + // (accum / max_accum) is tiny for all but the largest river, so a pure + // global-strength cap lets abundant ValleyFloor/CoastalAccess crowd every + // RiverMouth out. Instead: group by type, sort each group strongest-first, + // then round-robin across types so every present type keeps representation. + // Deterministic (BTreeMap type order, integer strength key, fixed rotation). if out.len() > MAX_ATTRACTORS { - out.sort_by(|a, b| { - let sa = (a.strength * 1e6) as i64; - let sb = (b.strength * 1e6) as i64; - sb.cmp(&sa).then(a.row.cmp(&b.row)).then(a.col.cmp(&b.col)) - }); - out.truncate(MAX_ATTRACTORS); + let mut by_type: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for a in out.drain(..) { + by_type.entry(a.attractor_type as u8).or_default().push(a); + } + for group in by_type.values_mut() { + group.sort_by(|a, b| { + let sa = (a.strength * 1e6) as i64; + let sb = (b.strength * 1e6) as i64; + sb.cmp(&sa).then(a.row.cmp(&b.row)).then(a.col.cmp(&b.col)) + }); + } + let mut kept: Vec = Vec::with_capacity(MAX_ATTRACTORS); + let mut depth = 0usize; + 'fill: loop { + let mut progressed = false; + for group in by_type.values() { + if let Some(a) = group.get(depth) { + kept.push(*a); + progressed = true; + if kept.len() >= MAX_ATTRACTORS { + break 'fill; + } + } + } + if !progressed { + break; + } + depth += 1; + } + out = kept; } out.sort_by(|a, b| { (a.attractor_type as u8, a.row, a.col).cmp(&(b.attractor_type as u8, b.row, b.col)) @@ -612,4 +641,40 @@ mod tests { assert!(ta.elev_pct.iter().all(|&p| (0.0..=1.0).contains(&p))); assert_eq!(ta.slope_deg.len(), 32 * 16); } + + /// Multi-octave sine terrain (continents + many small coastal streams) — + /// produces > MAX_ATTRACTORS candidates with plenty of river mouths. + fn sine_grid(w: u32, h: u32) -> Vec { + use std::f32::consts::{PI, TAU}; + (0..(w * h)) + .map(|i| { + let r = (i / w) as f32; + let c = (i % w) as f32; + let x = c / w as f32 * TAU; + let y = r / h as f32 * PI; + (0.5 + 0.25 * (x * 3.0).sin() * (y * 2.0).sin() + + 0.15 * (x * 7.0).cos() * (y * 5.0).sin() + + 0.08 * (x * 13.0).sin() * (y * 11.0).cos() + + 0.05 * (x * 23.0).cos() * (y * 19.0).sin()) + .clamp(0.0, 1.0) + }) + .collect() + } + + #[test] + fn river_mouths_survive_cap() { + // D-209 + the type-aware cap: even though RiverMouth normalized strength + // is tiny, a body full of mouths must still keep RiverMouth attractors + // (a global-strength cap would drop all of them — the bug Hoshe caught). + let h = hm(sine_grid(512, 256), 512, 256, 0.40); + let dr = drainage::analyze(&h.data, 512, 256, 0.40); + assert!(!dr.river_network.mouths.is_empty(), "fixture must have mouths"); + let ta = TerrainAnalysis::analyze(&h, &dr); + let a = extract_attractors(&h, &dr, &ta); + assert!(a.len() <= MAX_ATTRACTORS); + assert!( + a.iter().any(|x| x.attractor_type == AttractorType::RiverMouth), + "RiverMouth attractors must survive the cap when mouths exist" + ); + } }