Planet-down cascade workshop (3 rounds, 5 agents): layer-by-layer generation from empty world through population overlay, city planning, and street rendering. Includes consultant review by Troblum. Also commits: pre-Sprint-35 DB backup, Claude Code team-mode tmux test log (team-test.md). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1077 lines
47 KiB
Markdown
1077 lines
47 KiB
Markdown
---
|
||
title: "Tyre — Round 2: Algorithm Proposals"
|
||
description: "Concrete algorithm proposals for all four cascade layers: attractor-matching with geographic plausibility guarantees, four blocker schemas, spatial hierarchy confirmation, three-component district mix in Rust, and background thread architecture."
|
||
type: workshop
|
||
status: active
|
||
workshop: planet-down-cascade
|
||
agent: tyre
|
||
round: 2
|
||
created: 2026-05-01
|
||
---
|
||
|
||
# Planet-Down Cascade — Round 2: Algorithm Proposals
|
||
|
||
**Tyre — Technical Architect**
|
||
|
||
Round 1 framed the problems. Round 2 proposes the concrete algorithms. I'm applying four lead decisions (district mix, spatial rotation, explicit arrangement, threshold cache) as locked facts and moving directly to implementation-level specification.
|
||
|
||
---
|
||
|
||
## 1. Attractor-Matching Algorithm
|
||
|
||
**The problem from Round 1:** Four algorithm proposals (Gestalt, Tyre, Burnelli-Sheldon, Paula) with different structures. Ozzie's hard requirement: no fishing port in the mountains.
|
||
|
||
**My proposal: Hungarian assignment on a scored compatibility matrix, with synthetic attractor fallback.**
|
||
|
||
This synthesizes all four Round 1 proposals into one concrete design. Hungarian instead of greedy because at N ≤ 30 cities it's O(N³) ≈ 27,000 operations — trivially fast — and it maximizes global plausibility rather than locally optimal greedy choices. Paula's priority ordering is preserved via the HQ-lock constraint. Burnelli-Sheldon's comparative advantage framing becomes the score matrix. Ozzie's "no fishing ports in mountains" is enforced through score shape, not binary gates.
|
||
|
||
### Data structures
|
||
|
||
```rust
|
||
pub struct GeographicAttractor {
|
||
pub position: (u8, u8), // (grid_row, grid_col) in 64×32 regional grid
|
||
pub attractor_type: AttractorType,
|
||
pub quality_score: f32, // 0.0–1.0; higher = more capable of supporting settlement
|
||
pub is_synthetic: bool, // generated by seed when naturals are exhausted
|
||
}
|
||
|
||
pub enum AttractorType {
|
||
RiverConfluence,
|
||
CoastalHarbor,
|
||
MountainPass,
|
||
ArablePlain,
|
||
ResourceConcentration,
|
||
Defensible,
|
||
RiverMouth, // CoastalHarbor + RiverConfluence combined
|
||
SecondaryRiver, // seed-derived overflow attractor; lower quality
|
||
}
|
||
|
||
pub struct NameReservation {
|
||
pub city_name: String,
|
||
pub economic_role: EconomicRole,
|
||
pub population: u32,
|
||
pub political_archetype_override: Option<PoliticalArchetype>,
|
||
pub hq_for_corp: Option<CorporationId>,
|
||
pub hq_corp_primary_operation: Option<CommodityClass>,
|
||
}
|
||
|
||
pub struct PlacedSettlement {
|
||
pub city_name: String,
|
||
pub economic_role: EconomicRole,
|
||
pub population: u32,
|
||
pub position: (u8, u8), // 64×32 grid cell
|
||
pub founding_orientation: FoundingOrientation,
|
||
pub geographically_triggered: bool, // Paula's flag: false for OrganicGrowth/overflow
|
||
pub placement_score: f32, // for debugging / lead review flagging
|
||
pub flagged_for_review: bool, // score < 0.15: placement is implausible
|
||
}
|
||
```
|
||
|
||
### Compatibility score matrix
|
||
|
||
This is the operative mechanism for Ozzie's constraint. No entry is zero — even a bad match is possible if the body has no better option — but implausible matches score so low they only happen when all good options are exhausted.
|
||
|
||
```
|
||
| RiverConf | CoastalHarbor | MtPass | ArablePlain | ResourceConc | Defensible | SecondaryRiver
|
||
transit | 0.70 | 0.90 | 0.20 | 0.30 | 0.10 | 0.30 | 0.50
|
||
commercial | 0.70 | 0.90 | 0.20 | 0.50 | 0.10 | 0.25 | 0.50
|
||
agricultural | 0.60 | 0.50 | 0.10 | 0.90 | 0.10 | 0.20 | 0.40
|
||
extraction | 0.40 | 0.30 | 0.70 | 0.10 | 0.90 | 0.40 | 0.40
|
||
mining | 0.30 | 0.20 | 0.80 | 0.10 | 0.95 | 0.50 | 0.30
|
||
manufacturing | 0.60 | 0.70 | 0.20 | 0.40 | 0.30 | 0.20 | 0.60
|
||
research | 0.40 | 0.40 | 0.60 | 0.40 | 0.30 | 0.80 | 0.50
|
||
service_mixed | 0.70 | 0.60 | 0.20 | 0.60 | 0.10 | 0.30 | 0.70
|
||
frontier | 0.40 | 0.40 | 0.50 | 0.40 | 0.40 | 0.60 | 0.60
|
||
energy | 0.30 | 0.40 | 0.40 | 0.20 | 0.70 | 0.30 | 0.40
|
||
```
|
||
|
||
**Reading this table for Ozzie's constraint:** Transit and Commercial at MountainPass score 0.20. That's not zero — a small transit waypoint at a pass is historically real. But a major transit hub (large population, high `economic_tier`) will almost never land there because every other attractor scores higher for transit roles, and the Hungarian algorithm takes the global best assignment. The "fishing port in mountains" failure case requires: (a) no CoastalHarbor or RiverConfluence attractors exist on the body, AND (b) the transit city's population/economic_tier is low enough to land at an overflow position. If that happens, `placement_score < 0.25` triggers `flagged_for_review = true`. Lead gets the flag; the world doesn't silently lie.
|
||
|
||
**HQ override to score matrix:**
|
||
```
|
||
if res.hq_for_corp.is_some() {
|
||
if !is_compatible_corp_operation(res.hq_corp_primary_operation, attr.attractor_type) {
|
||
score = 0.0; // hard zero: corporate cross-reference must resolve geographically
|
||
}
|
||
}
|
||
```
|
||
|
||
This is the only hard zero. Everything else is a continuous score.
|
||
|
||
### The algorithm
|
||
|
||
```rust
|
||
fn assign_settlements_to_attractors(
|
||
reservations: &[NameReservation],
|
||
attractors: &mut Vec<GeographicAttractor>,
|
||
body: &BodyDefinition,
|
||
body_seed: u64,
|
||
) -> Vec<PlacedSettlement> {
|
||
let n_cities = reservations.len();
|
||
|
||
// Step 1: Augment with synthetic attractors if natural ones are exhausted.
|
||
// Synthetics are seeded river-bend or coastal positions — lower quality (0.35–0.45)
|
||
// but plausible secondary sites. Manufacturing and service_mixed prefer these
|
||
// because they follow infrastructure, not terrain (Burnelli-Sheldon's observation).
|
||
if n_cities > attractors.len() {
|
||
let needed = n_cities - attractors.len();
|
||
generate_synthetic_attractors(attractors, needed, body, body_seed);
|
||
}
|
||
|
||
// Step 2: Build compatibility score matrix [n_cities × n_attractors].
|
||
let n_attractors = attractors.len();
|
||
let mut scores = vec![0.0f32; n_cities * n_attractors];
|
||
for (i, res) in reservations.iter().enumerate() {
|
||
for (j, attr) in attractors.iter().enumerate() {
|
||
let base = COMPATIBILITY[res.economic_role][attr.attractor_type];
|
||
let quality_mult = attr.quality_score; // better attractors score higher
|
||
let hq_override = if hq_incompatible(res, attr) { 0.0 } else { 1.0 };
|
||
scores[i * n_attractors + j] = base * quality_mult * hq_override;
|
||
}
|
||
}
|
||
|
||
// Step 3: Hungarian algorithm on the score matrix.
|
||
// Maximizes the sum of assignment scores across all cities.
|
||
// At N ≤ 30: O(N³) ≈ 27,000 operations. Negligible.
|
||
let assignment = hungarian_maximize(&scores, n_cities, n_attractors);
|
||
|
||
// Step 4: Derive PlacedSettlement per assignment.
|
||
let mut placements = Vec::with_capacity(n_cities);
|
||
for (city_idx, attr_idx) in assignment.iter().enumerate() {
|
||
let score = scores[city_idx * n_attractors + attr_idx];
|
||
let attr = &attractors[*attr_idx];
|
||
let res = &reservations[city_idx];
|
||
|
||
placements.push(PlacedSettlement {
|
||
city_name: res.city_name.clone(),
|
||
economic_role: res.economic_role,
|
||
population: res.population,
|
||
position: attr.position,
|
||
founding_orientation: derive_founding_orientation(attr.attractor_type),
|
||
geographically_triggered: score >= 0.15,
|
||
placement_score: score,
|
||
flagged_for_review: score < 0.15,
|
||
});
|
||
}
|
||
|
||
placements
|
||
}
|
||
```
|
||
|
||
### Synthetic attractor generation
|
||
|
||
```rust
|
||
fn generate_synthetic_attractors(
|
||
attractors: &mut Vec<GeographicAttractor>,
|
||
needed: usize,
|
||
body: &BodyDefinition,
|
||
seed: u64,
|
||
) {
|
||
// Synthetics are seeded positions that represent secondary settlements:
|
||
// river bends, coastal plains not quite dramatic enough to be CoastalHarbor,
|
||
// flat terrain along existing attractor corridors.
|
||
// Quality range: 0.35–0.45 (below all natural attractors, above 0.30 floor).
|
||
for i in 0..needed {
|
||
let s = child_seed(seed, 0xDEAD_CAFE ^ (i as u64));
|
||
let (row, col) = synthetic_position(attractors, body, s);
|
||
attractors.push(GeographicAttractor {
|
||
position: (row, col),
|
||
attractor_type: AttractorType::SecondaryRiver,
|
||
quality_score: 0.35 + (rng_01(s) * 0.10),
|
||
is_synthetic: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
fn synthetic_position(
|
||
existing: &[GeographicAttractor],
|
||
body: &BodyDefinition,
|
||
seed: u64,
|
||
) -> (u8, u8) {
|
||
// Place synthetic near a natural attractor (within 4 cells) but not on top of it.
|
||
// If no natural attractors exist (extreme edge case), place on navigable land cell.
|
||
let anchor = existing[rng_range(seed, 0, existing.len()) % existing.len()];
|
||
let dr = (rng_range(child_seed(seed, 1), 0, 9) as i32) - 4;
|
||
let dc = (rng_range(child_seed(seed, 2), 0, 9) as i32) - 4;
|
||
let row = (anchor.position.0 as i32 + dr).clamp(0, 31) as u8;
|
||
let col = (anchor.position.1 as i32 + dc).clamp(0, 63) as u8;
|
||
(row, col)
|
||
}
|
||
```
|
||
|
||
### FoundingOrientation derivation
|
||
|
||
```rust
|
||
fn derive_founding_orientation(attr_type: AttractorType) -> FoundingOrientation {
|
||
match attr_type {
|
||
AttractorType::CoastalHarbor | AttractorType::RiverMouth => FoundingOrientation::PortFacing,
|
||
AttractorType::Defensible => FoundingOrientation::DefenseFacing,
|
||
AttractorType::ResourceConcentration => FoundingOrientation::ExtractionFacing,
|
||
AttractorType::MountainPass => FoundingOrientation::CorridorFacing,
|
||
AttractorType::ArablePlain => FoundingOrientation::AgrarianFacing,
|
||
AttractorType::RiverConfluence => FoundingOrientation::RiverFacing,
|
||
AttractorType::SecondaryRiver | AttractorType::NaturalBarrier => {
|
||
FoundingOrientation::AdminFacing // OrganicGrowth default (Paula NEW-Q2)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### OrganicGrowth disambiguation (NEW-Q2)
|
||
|
||
Paula's `geographically_triggered: bool` flag resolves this cleanly:
|
||
- `geographically_triggered = true`: settlement landed at a natural attractor with score ≥ 0.15. `FoundingOrientation` derived from attractor type.
|
||
- `geographically_triggered = false`: overflow placement at synthetic position, OR score < 0.15 despite assignment. `FoundingOrientation = AdminFacing`. This is what OrganicGrowth looks like — the settlement exists because people were there, not because a river or resource brought them.
|
||
|
||
The flag is set at Layer 2 generation time and never changes. Confirmed as the right mechanism.
|
||
|
||
---
|
||
|
||
## 2. Four Technical Blockers — Concrete Schemas
|
||
|
||
### ARCH-1: Heightmap Storage
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id),
|
||
grid_width INTEGER NOT NULL DEFAULT 512,
|
||
grid_height INTEGER NOT NULL DEFAULT 256,
|
||
-- float32 little-endian, row-major, grid_height × grid_width values
|
||
-- row 0 = north pole; col 0 = 180°W
|
||
elevation_f32_le BLOB NOT NULL,
|
||
sea_level REAL NOT NULL, -- elevation threshold; below = surface water
|
||
body_seed INTEGER NOT NULL -- simulation seed (for regression validation)
|
||
);
|
||
```
|
||
|
||
**Size calculation:**
|
||
- 512 × 256 × 4 bytes = 512KB per body
|
||
- ~400 inhabited bodies × 512KB = ~200MB total
|
||
- SQLite handles this fine; no application-layer compression needed
|
||
|
||
**Write path** (Python, in `generate_atlas.py` after simulation):
|
||
```python
|
||
import struct
|
||
def store_heightmap(conn, body_id, terrain, body_seed):
|
||
elev = terrain["elevation"].astype(">f4") # native float32
|
||
blob = elev.tobytes() # raw LE bytes on x86
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO atlas_body_heightmaps "
|
||
"(body_id, elevation_f32_le, sea_level, body_seed) VALUES (?,?,?,?)",
|
||
(body_id, blob, float(terrain["sea_level"]), body_seed)
|
||
)
|
||
```
|
||
|
||
**Read path** (Rust, in Layer 1 background generation):
|
||
```rust
|
||
pub struct HeightmapData {
|
||
pub width: usize,
|
||
pub height: usize,
|
||
pub elevation: Vec<f32>, // row-major, [row * width + col]
|
||
pub sea_level: f32,
|
||
}
|
||
|
||
fn load_heightmap(conn: &Connection, body_id: &str) -> Result<HeightmapData> {
|
||
let (w, h, blob, sea_level): (i64, i64, Vec<u8>, f64) = conn.query_row(
|
||
"SELECT grid_width, grid_height, elevation_f32_le, sea_level
|
||
FROM atlas_body_heightmaps WHERE body_id = ?1",
|
||
[body_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||
)?;
|
||
let floats: Vec<f32> = blob
|
||
.chunks_exact(4)
|
||
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||
.collect();
|
||
Ok(HeightmapData {
|
||
width: w as usize,
|
||
height: h as usize,
|
||
elevation: floats,
|
||
sea_level: sea_level as f32,
|
||
})
|
||
}
|
||
```
|
||
|
||
Loading 512KB from a local SQLite file: ~5ms. Within the performance budget.
|
||
|
||
---
|
||
|
||
### ARCH-2: BodyWorldState Design
|
||
|
||
The key revision from Round 1: **do not store the full 512×256 drainage accumulation grid in the cache.** Run it at full resolution during generation; keep only the derived 64×32 regional summary. This brings per-body memory from ~750KB down to ~220KB.
|
||
|
||
```rust
|
||
// session-only — never serialized, fully reproducible from systems.db + world_seed
|
||
pub struct BodyWorldState {
|
||
pub body_id: String,
|
||
pub generation_seed: u64,
|
||
pub status: GenerationStatus,
|
||
|
||
// Layer 1 (coarse summary only — full resolution discarded after generation)
|
||
pub regional_cells: Vec<RegionalCell>, // 64×32 = 2048 cells
|
||
|
||
// Layer 2
|
||
pub settlements: Vec<PlacedSettlement>, // active + latent
|
||
pub road_graph: RoadGraph,
|
||
pub territorial_grid: Vec<TerritorialStatus>, // 2048 entries, one per cell
|
||
|
||
// Layer 3 (populated lazily when player approaches a city)
|
||
pub city_contexts: HashMap<SettlementIndex, CityGenerationContext>,
|
||
}
|
||
|
||
pub struct RegionalCell {
|
||
pub grid_row: u8,
|
||
pub grid_col: u8,
|
||
pub biome_class: u8,
|
||
pub sub_biome_variant: u8, // 0-3
|
||
pub terrain_roughness: f32,
|
||
pub terrain_modification_cost: f32,
|
||
pub is_coastal: bool,
|
||
pub water_fraction: f32,
|
||
pub river_flow_fraction: f32, // 0.0 = no river; 1.0 = major river (from D8 summary)
|
||
pub geographic_feature: Option<GeographicFeatureTag>,
|
||
pub land_use: LandUse, // set by Layer 2
|
||
}
|
||
|
||
pub enum GeographicFeatureTag {
|
||
RiverConfluence,
|
||
CoastalHarbor,
|
||
MountainPass,
|
||
ArablePlain,
|
||
ResourceConcentration,
|
||
Defensible,
|
||
NaturalBarrier,
|
||
}
|
||
|
||
pub enum LandUse {
|
||
Urban, Agricultural, Industrial, Wilderness,
|
||
Corridor, Ruins, Ocean, Impassable,
|
||
}
|
||
|
||
pub enum TerritorialStatus {
|
||
CoreTerritory,
|
||
FrontierTerritory,
|
||
ExtractiveZone,
|
||
ContestZone,
|
||
WildernessBuffer,
|
||
AbandonedZone,
|
||
Ocean,
|
||
Impassable,
|
||
}
|
||
```
|
||
|
||
**Cache structure (Bevy Resource):**
|
||
```rust
|
||
pub struct GenerationCache {
|
||
pub bodies: HashMap<String, BodyWorldState>,
|
||
pub access_order: VecDeque<String>, // LRU tracking
|
||
pub max_cached: usize, // default 50 bodies
|
||
}
|
||
|
||
impl GenerationCache {
|
||
pub fn insert(&mut self, body_id: String, state: BodyWorldState) {
|
||
if self.bodies.len() >= self.max_cached {
|
||
// Evict least recently used
|
||
if let Some(evicted) = self.access_order.pop_front() {
|
||
self.bodies.remove(&evicted);
|
||
}
|
||
}
|
||
self.access_order.push_back(body_id.clone());
|
||
self.bodies.insert(body_id, state);
|
||
}
|
||
|
||
pub fn get(&mut self, body_id: &str) -> Option<&BodyWorldState> {
|
||
if self.bodies.contains_key(body_id) {
|
||
// Refresh LRU position
|
||
self.access_order.retain(|id| id != body_id);
|
||
self.access_order.push_back(body_id.to_string());
|
||
}
|
||
self.bodies.get(body_id)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Memory estimate (50 bodies):**
|
||
- RegionalCell: 2048 × ~36 bytes = ~73KB per body
|
||
- PlacedSettlement: 30 × ~128 bytes = ~4KB
|
||
- RoadGraph: ~100 nodes × 64B + ~200 edges × 96B = ~26KB
|
||
- TerritorialStatus: 2048 × 1 byte = 2KB
|
||
- Total: ~105KB per body × 50 = **~5MB total cache**
|
||
|
||
5MB is negligible. The LRU cap of 50 can be raised substantially with no memory concern.
|
||
|
||
---
|
||
|
||
### ARCH-3: atlas_city_names Schema
|
||
|
||
This replaces the authored-position role of `atlas_cities`. The `atlas_cities` table continues to exist and is populated at build time by `generate_atlas.py` for the atlas UI, but the source of truth for "what cities must exist" is now `atlas_city_names`.
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||
name_id TEXT PRIMARY KEY, -- "{body_id}/{city_local_id}"
|
||
body_id TEXT NOT NULL REFERENCES bodies(body_id),
|
||
city_local_id TEXT NOT NULL, -- slug from Gemma naming pipeline
|
||
city_name TEXT NOT NULL,
|
||
population INTEGER NOT NULL,
|
||
-- economic_role: NULL means inherit from body; override for cities with distinct role
|
||
economic_role TEXT,
|
||
-- political_archetype: NULL = runtime-derived from geography + economics
|
||
political_archetype_override TEXT,
|
||
prosperity_override REAL, -- NULL = runtime-derived
|
||
hq_for_corp TEXT REFERENCES corporations(corp_id),
|
||
hq_corp_primary_operation TEXT, -- commodity class; needed for attractor compat check
|
||
is_capital INTEGER NOT NULL DEFAULT 0,
|
||
UNIQUE(body_id, city_local_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_city_names_body ON atlas_city_names(body_id);
|
||
```
|
||
|
||
**Rust load query:**
|
||
```rust
|
||
fn load_name_reservations(
|
||
conn: &Connection,
|
||
body_id: &str,
|
||
) -> Result<Vec<NameReservation>> {
|
||
let mut stmt = conn.prepare(
|
||
"SELECT city_name, COALESCE(economic_role, b.economic_role), population,
|
||
political_archetype_override, hq_for_corp, hq_corp_primary_operation
|
||
FROM atlas_city_names n
|
||
JOIN bodies b ON n.body_id = b.body_id
|
||
WHERE n.body_id = ?1
|
||
ORDER BY population DESC"
|
||
)?;
|
||
// ... map rows to NameReservation structs
|
||
}
|
||
```
|
||
|
||
**What happens to `atlas_cities`:**
|
||
`atlas_cities` continues to hold the positions that `generate_atlas.py` outputs — but those positions are now the OUTPUT of the runtime generator (stored there for atlas UI display), not the INPUT. This is a reversal of data flow: the generator reads from `atlas_city_names`, writes to `atlas_cities` (or equivalently, the atlas UI reads from `BodyWorldState.settlements` at runtime). Positions in `atlas_cities` become a materialized view, not a source of truth.
|
||
|
||
For Phase 3 of the cascade roadmap, `atlas_cities` can be repopulated offline by running a headless generation pass on all bodies. For Phase 5, the runtime generator produces positions on-demand.
|
||
|
||
---
|
||
|
||
### ARCH-4: Body Physical Size Field
|
||
|
||
After review of `generate_atlas.py`, the `bodies` table does NOT currently have a radius field. The wiki frontmatter has surface gravity and planet class but not radius.
|
||
|
||
**Schema addition:**
|
||
```sql
|
||
ALTER TABLE bodies ADD COLUMN body_radius_km REAL;
|
||
-- NULL acceptable; Rust reads with fallback by planet_class
|
||
```
|
||
|
||
**Python population** (in `import_economics.py` or `generate_atlas.py`):
|
||
Wiki authors should provide `body_radius_km` in frontmatter. For bodies without it, a `planet_class`-based default applies in Rust.
|
||
|
||
**Rust area-count with fallback:**
|
||
```rust
|
||
fn area_count_for_body(body: &BodyRecord) -> u8 {
|
||
let radius_km = body.body_radius_km.unwrap_or_else(|| {
|
||
// Fallback by planet_class — rough but adequate for area-count purposes
|
||
match body.planet_class.as_str() {
|
||
c if c.contains("super_terrestrial") => 9_000.0,
|
||
c if c.contains("terrestrial") || c.contains("garden") => 5_500.0,
|
||
c if c.contains("moon") => 1_200.0,
|
||
c if c.contains("dwarf") => 400.0,
|
||
"station" | "orbital_only" => return 1, // stations: 1 area always
|
||
_ => 3_000.0,
|
||
}
|
||
});
|
||
let surface_area_km2 = 4.0 * std::f64::consts::PI * (radius_km as f64).powi(2);
|
||
let count = (surface_area_km2 / 6_000_000.0_f64).sqrt().round() as u8;
|
||
count.max(1)
|
||
}
|
||
```
|
||
|
||
Planet-class fallbacks cover all cases that aren't stations. Wiki authors adding `body_radius_km` for any body improves that body's area count accuracy.
|
||
|
||
---
|
||
|
||
## 3. Spatial Hierarchy Dimensions — Confirmed with One Correction
|
||
|
||
### The correction from Round 1
|
||
|
||
Round 1 described Province as "a drainage basin on the 64×32 grid." Paula's vocabulary mapping (Round 1 notes §6) correctly identified that the 64×32 grid cell IS the Province-level unit. These are compatible: a Province is one 64×32 regional cell. Province boundaries are the cell edges, which naturally align with the biome transition points baked into the downsampling.
|
||
|
||
Watershed analysis to find province boundaries is therefore **not needed** — the 64×32 grid already provides the Province grid. This simplifies the implementation significantly.
|
||
|
||
### Confirmed dimension table
|
||
|
||
| Tier | Name | Base unit | Physical scale (ref. body, r=5500km) |
|
||
|------|------|-----------|---------------------------------------|
|
||
| 0 | Chunk | 64×64 sim tiles | ~32m × 32m |
|
||
| 1 | Block | 128×128 sim tiles | ~64m × 64m |
|
||
| 2 | District | 512×512 sim tiles | ~256m × 256m |
|
||
| 3 | Region | city district bounding box | ~0.5–3km (varies by city size) |
|
||
| 4 | Province | 1 cell on the 64×32 regional grid | ~540km × 270km |
|
||
| 5 | Area | K contiguous same-class cells | ~1000–5000km across |
|
||
| 6 | Body | full planetary surface | ~35,000km circumference |
|
||
| 7 | System | star system | — |
|
||
|
||
**Reference body calibration:** radius 5500km → circumference ≈ 34,600km → 512 pixels → ~67km/pixel at equator → regional cell (8×8 pixels) ≈ 540km × 270km.
|
||
|
||
**Province count:** 64×32 = 2048 total cells per body; roughly 600-800 inhabited (non-ocean, non-ice) depending on the body. Manageable.
|
||
|
||
**Area count (body-size formula, from ARCH-4):**
|
||
```
|
||
area_count = round(sqrt(surface_area_km2 / 6_000_000))
|
||
```
|
||
- r=5500km (reference): area_count ≈ 7
|
||
- r=500km (small moon): area_count ≈ 1
|
||
- r=10,000km (super-terrestrial): area_count ≈ 14
|
||
|
||
**Region (tier 3) clarification:** Region is a semantic tier for the "city + hinterland" concept, not a fixed grid cell. A Region's spatial extent is computed from `CityGenerationContext.footprint_radius_km` and is not stored — it's a lookup concept used in TerritorialStatus derivation and atlas UI. Tiers 0-4 are the generation grid hierarchy; Region is conceptual.
|
||
|
||
**Sim-tile physical scale:** 0.5m/sim-tile. Confirmed from prior workshop: "District spans ~256m" at 512 sim tiles → 0.5m/tile. A 4-tile street corridor = 2m (narrow alley); a 12-tile boulevard = 6m (comfortable road). Playable.
|
||
|
||
---
|
||
|
||
## 4. Three-Component District Mix — Rust Implementation
|
||
|
||
### founding_age_years derivation
|
||
|
||
`founding_age_years` is not currently in systems.db. Options: (a) authored wiki field, (b) derived from WorldTier + economic_tier.
|
||
|
||
**My recommendation: derive with authored override.** Wiki authors shouldn't need to provide founding years for every settlement; the derivation is good enough for the age modifier's purpose (character, not structure). Add a `founding_age_years_override` column to `atlas_city_names` for the rare case where lore demands specificity.
|
||
|
||
```rust
|
||
fn derive_founding_age_years(
|
||
world_tier: WorldTier,
|
||
economic_tier: u8,
|
||
seed: u64,
|
||
) -> u32 {
|
||
let (base_min, base_max): (u32, u32) = match world_tier {
|
||
WorldTier::Epicenter => (150, 400),
|
||
WorldTier::Regional => (80, 200),
|
||
WorldTier::Backwater => (30, 100),
|
||
WorldTier::Passage => (20, 60),
|
||
WorldTier::Waypoint => (5, 20),
|
||
};
|
||
// Wealthier economies tend to be more established
|
||
let tier_bonus = (economic_tier.saturating_sub(1) as u32) * 20;
|
||
let variation = (rng_01(seed) * (base_max - base_min) as f32) as u32;
|
||
base_min + tier_bonus.min(base_max - base_min) + variation
|
||
}
|
||
```
|
||
|
||
BS-Q2 resolution: age modifier only activates at Town tier (population ≥ 1,000) and above. A Waypoint's founding age doesn't meaningfully increase its service variety.
|
||
|
||
### Revised weight table (min 0.2 enforced)
|
||
|
||
The original 10×9 table had zeros. Every cell in the revised table has a floor of 0.2. This is Burnelli-Sheldon's non-negotiable: economic role modifies proportion, not presence.
|
||
|
||
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|
||
|--------------|-----|-----|-----|-----|-----|-----|-----|-----|-----|
|
||
| manufacturing | 0.80 | 0.40 | 1.00 | 0.40 | 0.80 | 0.20 | 0.40 | 0.40 | 0.20 |
|
||
| agricultural | 0.90 | 0.60 | 0.30 | 0.50 | 0.80 | 0.20 | 0.50 | 0.30 | 0.20 |
|
||
| extraction | 0.70 | 0.30 | 0.90 | 0.30 | 0.90 | 0.20 | 0.30 | 0.40 | 0.20 |
|
||
| transit | 0.50 | 0.70 | 0.30 | 0.40 | 0.80 | 0.40 | 0.60 | 1.00 | 0.20 |
|
||
| research | 0.70 | 0.30 | 0.30 | 0.70 | 0.30 | 0.30 | 0.50 | 0.20 | 1.00 |
|
||
| commercial | 0.60 | 1.00 | 0.30 | 0.40 | 0.50 | 0.60 | 0.60 | 0.20 | 0.20 |
|
||
| service_mixed | 0.80 | 0.70 | 0.30 | 0.50 | 0.30 | 0.60 | 0.80 | 0.20 | 0.20 |
|
||
| mining | 0.70 | 0.30 | 0.80 | 0.20 | 1.00 | 0.20 | 0.50 | 0.40 | 0.20 |
|
||
| frontier | 1.00 | 0.40 | 0.40 | 0.30 | 0.70 | 0.20 | 0.60 | 0.20 | 0.20 |
|
||
| energy | 0.50 | 0.20 | 0.70 | 0.30 | 0.90 | 0.20 | 0.30 | 0.40 | 0.80 |
|
||
|
||
**Reading mining/Ent=0.20:** This is the floor, not zero. A mining town with 4+ districts will draw from all 9 DistrictType bins, with Entertainment least likely but not impossible. The guarantee tier doesn't need to add Entertainment because the weight table already makes it reachable at larger settlements.
|
||
|
||
**BS-Q3 (energy/Ent=0.20 may not produce dedicated Entertainment districts):** Correct observation. At small district counts, 0.20 weight against higher competitors means Entertainment might never win a slot from the weight table. The guarantee tier handles it: energy cities above 10,000 population get the Entertainment guarantee regardless. Below 10,000, their entertainment is part of MixedUse (single-district settlement). This is correct behavior: a small energy extraction outpost doesn't have a dedicated entertainment district; a large energy hub does.
|
||
|
||
### The Rust implementation
|
||
|
||
```rust
|
||
pub fn generate_district_type_distribution(
|
||
city: &CityGenerationContext,
|
||
founding_age_years: u32,
|
||
rng: &mut SimRng,
|
||
) -> Vec<DistrictType> {
|
||
let district_count = city.district_count as usize;
|
||
|
||
// --- Single-district settlements: always MixedUse ---
|
||
// The three-component model governs multi-district distribution only.
|
||
// A single-district settlement has all service types coexisting in miniature.
|
||
if district_count == 1 {
|
||
return vec![DistrictType::MixedUse];
|
||
}
|
||
|
||
// --- Component 1: Population tier guarantees ---
|
||
// Guarantees are satisfied first; they cannot be displaced by the weight table.
|
||
let mut guaranteed: Vec<DistrictType> = vec![DistrictType::Residential];
|
||
|
||
if city.population >= 1_000 {
|
||
guaranteed.push(DistrictType::Commercial);
|
||
}
|
||
if city.population >= 10_000 {
|
||
guaranteed.push(DistrictType::Entertainment);
|
||
}
|
||
if city.population >= 50_000
|
||
|| matches!(city.world_tier, WorldTier::Epicenter | WorldTier::Regional) {
|
||
guaranteed.push(DistrictType::Administrative);
|
||
}
|
||
// Self-contained: any settlement with road access gets at least one Transit node
|
||
// (bus station, road terminus, gate hub). Only if we have room.
|
||
if !city.road_entry_directions.is_empty()
|
||
&& !guaranteed.contains(&DistrictType::Transit)
|
||
&& guaranteed.len() < district_count
|
||
{
|
||
guaranteed.push(DistrictType::Transit);
|
||
}
|
||
|
||
// Cap guarantees to district_count (guarantees cannot exceed district count)
|
||
guaranteed.truncate(district_count);
|
||
|
||
// --- Component 2: Economic role weight table ---
|
||
let remaining_slots = district_count - guaranteed.len();
|
||
let weights = get_economic_role_weights(city.economic_role, city.political_archetype);
|
||
|
||
let mut additional: Vec<DistrictType> = (0..remaining_slots)
|
||
.map(|_| weighted_sample_district(&weights, rng))
|
||
.collect();
|
||
|
||
// --- Component 3: Founding age modifier ---
|
||
// Age adds variety: old settlements have accumulated more diverse infrastructure.
|
||
// Only at Town tier (1000+) per BS-Q2.
|
||
let age_variety = if city.population >= 1_000 && founding_age_years > 100
|
||
&& !matches!(city.world_tier, WorldTier::Waypoint) {
|
||
true
|
||
} else {
|
||
false
|
||
};
|
||
|
||
if age_variety && !additional.is_empty() {
|
||
// Find the dominant district type in `additional` and replace
|
||
// one occurrence with a less-represented type (increases variety).
|
||
promote_district_variety(&mut additional, &weights, rng);
|
||
}
|
||
|
||
// Merge and return
|
||
let mut all = guaranteed;
|
||
all.extend(additional);
|
||
all
|
||
}
|
||
|
||
// Weights lookup: applies political_archetype modifier on top of economic_role weights.
|
||
// CompanyTown: multiply dominant type by 1.5, others by 0.8.
|
||
// AdminCapital: Administrative × 1.5, Residential × 1.2.
|
||
// FreePort: MixedUse × 1.5, Transit × 1.3, flatten all by 0.9.
|
||
// Others: identity modifier.
|
||
fn get_economic_role_weights(
|
||
role: EconomicRole,
|
||
archetype: PoliticalArchetype,
|
||
) -> [f32; 9] {
|
||
let mut w = ECONOMIC_ROLE_WEIGHTS[role];
|
||
apply_archetype_modifier(&mut w, archetype);
|
||
w
|
||
}
|
||
```
|
||
|
||
**Explicit spatial arrangement for political archetypes (L3-Q2, locked):**
|
||
|
||
```rust
|
||
pub fn arrange_district_grid(
|
||
district_types: &[DistrictType],
|
||
archetype: PoliticalArchetype,
|
||
founding_orientation: FoundingOrientation,
|
||
grid_width: u8,
|
||
grid_height: u8,
|
||
) -> Vec<DistrictPlacement> {
|
||
match archetype {
|
||
PoliticalArchetype::CompanyTown => {
|
||
// Spine: dominant industrial/logistics type at one end,
|
||
// Residential fills behind it. FoundingOrientation determines which end.
|
||
spine_layout(district_types, founding_orientation, grid_width, grid_height)
|
||
}
|
||
PoliticalArchetype::AdminCapital => {
|
||
// Radial: Administrative forced to center (or closest-to-center position).
|
||
// Residential radiates outward. Prosperity gradient: center = high.
|
||
radial_layout(district_types, founding_orientation, grid_width, grid_height)
|
||
}
|
||
PoliticalArchetype::FreePort => {
|
||
// Multi-node: Transit and Commercial at all 4 corners.
|
||
// No single center. MixedUse fills interior.
|
||
multi_node_layout(district_types, founding_orientation, grid_width, grid_height)
|
||
}
|
||
PoliticalArchetype::Contested => {
|
||
// Two spatial clusters: districts 0..N/2 for faction A,
|
||
// N/2..N for faction B, meeting at a ContestZone seam.
|
||
// FoundingOrientation points at the seam.
|
||
contested_layout(district_types, founding_orientation, grid_width, grid_height)
|
||
}
|
||
PoliticalArchetype::OrganicGrowth => {
|
||
// Irregular: no fixed arrangement pattern. Districts assigned
|
||
// by local density weighting from FNV-1a seed. FoundingOrientation
|
||
// biases which quadrant has historically higher prosperity.
|
||
organic_layout(district_types, founding_orientation, grid_width, grid_height)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
This is ~5 layout functions, each 15-20 lines. Total: ~100 lines of Rust. Matches my Round 1 estimate of 30-40 lines for three archetypes; Paula's request for all five adds ~60 more lines, still trivial.
|
||
|
||
**FoundingOrientation spatial grid rotation (L3-Q1, locked):**
|
||
|
||
```rust
|
||
fn orientation_to_edge(orientation: FoundingOrientation) -> CardinalDirection {
|
||
match orientation {
|
||
FoundingOrientation::PortFacing => CardinalDirection::South, // water is south edge
|
||
FoundingOrientation::ExtractionFacing => CardinalDirection::North, // resource is north
|
||
FoundingOrientation::DefenseFacing => CardinalDirection::any_elevated(), // terrain-driven
|
||
FoundingOrientation::CorridorFacing => CardinalDirection::East, // road entry
|
||
FoundingOrientation::AgrarianFacing => CardinalDirection::South, // farmland south
|
||
FoundingOrientation::RiverFacing => CardinalDirection::West, // upstream = west
|
||
FoundingOrientation::AdminFacing => CardinalDirection::Center, // no orientation
|
||
}
|
||
}
|
||
```
|
||
|
||
The `spine_layout`, `radial_layout` etc. receive the orientation and rotate the district grid so the "face" of the city points in the founding direction. For PortFacing: Transit + LogisticsHub districts at the southern edge (water side), Residential fills north. Player approaching from sea sees the harbor face.
|
||
|
||
---
|
||
|
||
## 5. Background Thread Architecture
|
||
|
||
### Thread pool vs. async
|
||
|
||
CPU-bound work (drainage, A*): **thread pool** via `rayon`. Async (`tokio`) is for I/O-bound work; the generation pipeline is compute-bound. One `rayon::ThreadPool` with `num_cpus::get().saturating_sub(1)` threads.
|
||
|
||
SQLite reads: each background thread opens its own `Connection` to systems.db (SQLite WAL mode supports concurrent readers). No connection pool needed at this scale.
|
||
|
||
### Priority queue design
|
||
|
||
```rust
|
||
#[derive(Eq, PartialEq)]
|
||
pub enum GenerationPriority {
|
||
Immediate = 0, // player's current body — handled SYNCHRONOUSLY at session start
|
||
High = 1, // body referenced in player-facing text (news, dialogue, mission)
|
||
Medium = 2, // gate-adjacent bodies, spidering outward
|
||
Low = 3, // breadth-first background fill
|
||
}
|
||
|
||
#[derive(Eq, PartialEq)]
|
||
pub struct GenerationRequest {
|
||
pub body_id: String,
|
||
pub priority: GenerationPriority,
|
||
pub requested_at_tick: u64,
|
||
}
|
||
|
||
// Ordering: lowest priority value = highest urgency
|
||
// Within same priority: earliest request first (FIFO)
|
||
impl Ord for GenerationRequest {
|
||
fn cmp(&self, other: &Self) -> Ordering {
|
||
(self.priority as u8)
|
||
.cmp(&(other.priority as u8))
|
||
.then(self.requested_at_tick.cmp(&other.requested_at_tick))
|
||
}
|
||
}
|
||
|
||
// The queue is a BinaryHeap<Reverse<GenerationRequest>> (min-heap by urgency)
|
||
pub struct GenerationQueue {
|
||
inner: BinaryHeap<Reverse<GenerationRequest>>,
|
||
in_flight: HashSet<String>, // body_ids currently being generated
|
||
completed: HashSet<String>, // body_ids already in cache (skip check optimization)
|
||
}
|
||
|
||
impl GenerationQueue {
|
||
pub fn push(&mut self, req: GenerationRequest) {
|
||
if self.completed.contains(&req.body_id) { return; }
|
||
if self.in_flight.contains(&req.body_id) { return; }
|
||
self.inner.push(Reverse(req));
|
||
}
|
||
|
||
pub fn pop(&mut self) -> Option<GenerationRequest> {
|
||
self.inner.pop().map(|Reverse(r)| {
|
||
self.in_flight.insert(r.body_id.clone());
|
||
r
|
||
})
|
||
}
|
||
|
||
pub fn mark_complete(&mut self, body_id: &str) {
|
||
self.in_flight.remove(body_id);
|
||
self.completed.insert(body_id.to_string());
|
||
}
|
||
}
|
||
```
|
||
|
||
The `in_flight` set prevents the same body being dispatched to multiple threads simultaneously. `completed` prevents re-queueing bodies already cached.
|
||
|
||
### Worker architecture
|
||
|
||
```rust
|
||
pub fn launch_generation_workers(
|
||
db_path: PathBuf,
|
||
world_seed: u64,
|
||
queue: Arc<Mutex<GenerationQueue>>,
|
||
cache: Arc<RwLock<GenerationCache>>,
|
||
) {
|
||
let pool = rayon::ThreadPoolBuilder::new()
|
||
.num_threads(num_cpus::get().saturating_sub(1).max(1))
|
||
.thread_name(|i| format!("world-gen-{i}"))
|
||
.build()
|
||
.expect("failed to build generation thread pool");
|
||
|
||
// Coordinator: one lightweight thread that pulls from queue and dispatches to pool
|
||
std::thread::Builder::new()
|
||
.name("world-gen-coordinator".to_string())
|
||
.spawn(move || {
|
||
loop {
|
||
let request = {
|
||
let mut q = queue.lock().unwrap();
|
||
q.pop()
|
||
};
|
||
|
||
let Some(req) = request else {
|
||
// Queue empty — park briefly
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
continue;
|
||
};
|
||
|
||
let (queue, cache, db_path) = (
|
||
Arc::clone(&queue),
|
||
Arc::clone(&cache),
|
||
db_path.clone(),
|
||
);
|
||
|
||
pool.spawn(move || {
|
||
let result = generate_body(&db_path, &req.body_id, world_seed);
|
||
match result {
|
||
Ok(state) => {
|
||
cache.write().unwrap().insert(req.body_id.clone(), state);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("generation failed for {}: {e}", req.body_id);
|
||
}
|
||
}
|
||
queue.lock().unwrap().mark_complete(&req.body_id);
|
||
});
|
||
}
|
||
})
|
||
.expect("failed to spawn coordinator thread");
|
||
}
|
||
```
|
||
|
||
### Immediate priority: synchronous on session start
|
||
|
||
The player's current body cannot go to the background queue — the world must be ready before the first frame renders. This is handled synchronously in the Bevy startup system:
|
||
|
||
```rust
|
||
pub fn world_generation_startup(
|
||
db: Res<DatabaseConnection>,
|
||
world_seed: Res<WorldSeed>,
|
||
current_body: Res<PlayerCurrentBody>,
|
||
mut cache: ResMut<GenerationCache>,
|
||
mut queue: ResMut<GenerationQueue>,
|
||
) {
|
||
// Block on current body generation — ~133ms, imperceptible.
|
||
// UI shows "entering system..." diegetic loading frame during this time.
|
||
let state = generate_body(
|
||
&db.path,
|
||
¤t_body.body_id,
|
||
world_seed.seed,
|
||
).expect("current body generation failed — DB may be corrupt");
|
||
cache.insert(current_body.body_id.clone(), state);
|
||
|
||
// Queue background generation of gate-adjacent bodies (Medium priority).
|
||
for adj_body_id in current_body.gate_adjacent_bodies.iter() {
|
||
queue.push(GenerationRequest {
|
||
body_id: adj_body_id.clone(),
|
||
priority: GenerationPriority::Medium,
|
||
requested_at_tick: 0,
|
||
});
|
||
}
|
||
|
||
// Queue all remaining bodies at Low priority (breadth-first fill).
|
||
for body_id in db.all_inhabited_body_ids() {
|
||
queue.push(GenerationRequest {
|
||
body_id,
|
||
priority: GenerationPriority::Low,
|
||
requested_at_tick: 0,
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### Event-driven trigger for player-facing text
|
||
|
||
This is the mechanism for "by the time the player reads the sentence, the cascade is done."
|
||
|
||
```rust
|
||
// A lightweight name index built at startup from systems.db
|
||
// (all city names, body names → body_id)
|
||
pub struct SystemNameIndex {
|
||
// Aho-Corasick automaton for multi-pattern substring search
|
||
// At startup: build from ~5000-10000 names. Build time: ~5ms.
|
||
automaton: AhoCorasick,
|
||
patterns: Vec<String>,
|
||
body_ids: Vec<String>, // parallel to patterns: pattern[i] → body_ids[i]
|
||
}
|
||
|
||
impl SystemNameIndex {
|
||
pub fn scan(&self, text: &str) -> impl Iterator<Item = &str> {
|
||
self.automaton
|
||
.find_iter(text)
|
||
.map(|m| self.body_ids[m.pattern()].as_str())
|
||
}
|
||
}
|
||
|
||
// Bevy system: runs whenever player-facing text is produced
|
||
pub fn text_reference_detector(
|
||
mut text_events: EventReader<PlayerFacingTextEvent>,
|
||
name_index: Res<SystemNameIndex>,
|
||
cache: Res<GenerationCache>,
|
||
mut queue: ResMut<GenerationQueue>,
|
||
time: Res<SimulationTime>,
|
||
) {
|
||
for event in text_events.read() {
|
||
for body_id in name_index.scan(&event.content) {
|
||
if !cache.bodies.contains_key(body_id) {
|
||
queue.push(GenerationRequest {
|
||
body_id: body_id.to_string(),
|
||
priority: GenerationPriority::High,
|
||
requested_at_tick: time.tick,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`PlayerFacingTextEvent` is emitted by all text-producing systems: news ticker, dialogue display, mission briefing, corporate records viewer. The name detector runs before the text is displayed — by the time the player sees the name, the generation request is queued. At ~133ms generation time and background execution, the cache is populated well before the player can navigate to the referenced system.
|
||
|
||
**Aho-Corasick justification:** Single-pass multi-pattern substring search. One pass through a news ticker body with 5000 known names: O(|text| + total_match_length). For a 500-character ticker line, ~500 hash comparisons. Negligible. The `aho-corasick` crate is a standard Rust dependency.
|
||
|
||
---
|
||
|
||
## 6. TerritorialStatus — Reconciled Thresholds
|
||
|
||
Three proposals from Round 1 (Paula, Tyre, Burnelli-Sheldon). Reconciling to a single algorithm:
|
||
|
||
```rust
|
||
pub fn derive_territorial_status(
|
||
cell: &RegionalCell,
|
||
settlements: &[PlacedSettlement],
|
||
road_graph: &RoadGraph,
|
||
) -> TerritorialStatus {
|
||
// Priority order (first match wins)
|
||
|
||
// 1. Ocean / Impassable — terrain gates
|
||
if cell.water_fraction > 0.60 {
|
||
return TerritorialStatus::Ocean;
|
||
}
|
||
if cell.biome_class == BiomeClass::IceSheet || cell.terrain_modification_cost > 0.95 {
|
||
return TerritorialStatus::Impassable;
|
||
}
|
||
|
||
// 2. AbandonedZone — settlement placed at generation but corps departed
|
||
// Paula's `placed_at_generation` flag distinguishes from "never settled"
|
||
let placed_here = settlements.iter().find(|s| {
|
||
s.position == (cell.grid_row, cell.grid_col) && !s.is_active
|
||
});
|
||
if placed_here.is_some() {
|
||
return TerritorialStatus::AbandonedZone;
|
||
}
|
||
|
||
// 3. CoreTerritory — active settlement within footprint + road coverage
|
||
let nearest_active = nearest_active_settlement(cell, settlements);
|
||
if let Some((s, dist_cells)) = nearest_active {
|
||
let footprint_radius_cells = city_footprint_in_cells(s.population);
|
||
if dist_cells <= footprint_radius_cells
|
||
&& road_within_n_cells(cell, road_graph, 2)
|
||
&& matches!(s.population, p if p >= 1_000) {
|
||
return TerritorialStatus::CoreTerritory;
|
||
}
|
||
|
||
// 4. ExtractiveZone — ResourceConcentration + corporate road
|
||
if cell.geographic_feature == Some(GeographicFeatureTag::ResourceConcentration)
|
||
&& road_within_n_cells(cell, road_graph, 3)
|
||
&& nearest_corporate_road_maintenance(cell, road_graph).is_some() {
|
||
return TerritorialStatus::ExtractiveZone;
|
||
}
|
||
|
||
// 5. ContestZone — two CoreTerritory claims from different archetypes
|
||
let competing = settlements.iter().filter(|s2| {
|
||
s2.is_active
|
||
&& s2.city_name != s.city_name
|
||
&& dist_to_cell(s2, cell) <= city_footprint_in_cells(s2.population)
|
||
&& s2.political_archetype != s.political_archetype
|
||
}).count();
|
||
if competing >= 1 {
|
||
return TerritorialStatus::ContestZone;
|
||
}
|
||
|
||
// 6. FrontierTerritory — settlement exists nearby but not CoreTerritory conditions
|
||
if dist_cells <= 5 || road_within_n_cells(cell, road_graph, 5) {
|
||
return TerritorialStatus::FrontierTerritory;
|
||
}
|
||
}
|
||
|
||
// 7. WildernessBuffer — nothing within range
|
||
TerritorialStatus::WildernessBuffer
|
||
}
|
||
```
|
||
|
||
**Key decisions in this reconciliation:**
|
||
- AbandonedZone requires Paula's `placed_at_generation` flag. Without it, a WildernessBuffer cell that an abandoned settlement once occupied looks identical to a never-settled one. The flag is set at Layer 2 generation and never changes.
|
||
- ExtractiveZone condition uses `road_maintenance == Corporate` from the road graph — Burnelli-Sheldon's most concrete threshold, adopted.
|
||
- ContestZone requires at least two active settlements with different `political_archetype` AND overlapping footprints — cleaner than Paula's "road authority type" (two Corporate roads don't create a contest), cleaner than Tyre's "2 cells" (footprint-based overlap is more accurate).
|
||
- FrontierTerritory threshold: 5 cells from settlement OR 5 cells from any road. Paula's "road coverage ≥ 0.6" is hard to compute at regional grid resolution; distance-from-road is equivalent and computable.
|
||
|
||
---
|
||
|
||
## 7. Performance — Updated Estimates
|
||
|
||
All operations above are for a single body. Background thread: one body at a time per thread.
|
||
|
||
| Operation | Notes | Estimate |
|
||
|-----------|-------|----------|
|
||
| Load heightmap BLOB from SQLite | 512KB read | ~5ms |
|
||
| D8 priority-flood drainage (512×256) | Rust, in-place | ~50ms |
|
||
| River centerline extraction + 64×32 summary | Discard full grid after | ~20ms |
|
||
| Geographic attractor extraction | 64×32 scan | ~5ms |
|
||
| Regional cell tagging (sub-biome, feature tags) | 2048 cells | ~3ms |
|
||
| Load name reservations from atlas_city_names | ~30 rows | ~2ms |
|
||
| Hungarian assignment (N=30, M=100) | O(N³) ≈ 27K ops | <1ms |
|
||
| Synthetic attractor generation (if needed) | seed-derived | <1ms |
|
||
| A* road routing (MST on 64×32 grid) | N=30 nodes | ~30ms |
|
||
| Sub-settlement placement | mining camps, waypoints | ~5ms |
|
||
| TerritorialStatus propagation | 2048 cells × N settlements | ~15ms |
|
||
| **Total** | | **~136ms** |
|
||
|
||
136ms per body on background thread. With 4 cores (3 worker threads): ~45ms average throughput. 400 inhabited bodies would fully fill the cache in ~18 seconds of background time if the player sat still. In practice the queue drains to completion long before the player visits most bodies.
|
||
|
||
---
|
||
|
||
## 8. Open Items After Round 2
|
||
|
||
### Resolved by Round 2 (no further design needed)
|
||
|
||
| Item | Resolution |
|
||
|------|-----------|
|
||
| Attractor-matching algorithm | Hungarian on compatibility matrix; synthetic overflow; mismatch flag |
|
||
| NEW-Q2 (OrganicGrowth) | `geographically_triggered: bool`; false → AdminFacing (confirmed) |
|
||
| ARCH-1 (heightmap storage) | `atlas_body_heightmaps` table, float32 LE BLOB |
|
||
| ARCH-2 (BodyWorldState) | Bevy Resource; LRU 50 bodies; ~5MB total; no serialization |
|
||
| ARCH-3 (atlas_city_names) | New table schema; replaces authored position role of atlas_cities |
|
||
| ARCH-4 (body size) | `body_radius_km` column; planet_class fallbacks in Rust |
|
||
| Spatial hierarchy dimensions | Province = 1 regional cell; confirmed dimension table |
|
||
| Three-component district mix | Concrete Rust implementation above |
|
||
| founding_age_years | Derived from WorldTier + economic_tier; optional authored override |
|
||
| Background thread architecture | rayon pool + coordinator thread + Bevy event trigger |
|
||
| TerritorialStatus thresholds | Reconciled single algorithm above |
|
||
|
||
### Still open for Round 3
|
||
|
||
| Item | Notes |
|
||
|------|-------|
|
||
| `atlas_city_names` population path | How does this table get populated at build time? From existing wiki naming pipeline or new work? |
|
||
| L4-Q2 threshold values | 0.63/0.43/0.23 (Paula, avoids oscillation) vs. 0.60/0.40/0.20 (Gestalt, round numbers). Minor — pick one. |
|
||
| L4-Q5 (scatter) | Ozzie's "not optional" vs. brief deferral. Min viable question, not algorithm question. |
|
||
| L3-Q7 (port/station direction) | Paula designed the mechanism; Ozzie backs it; all others deferred. |
|
||
| `prosperity_baseline` topographic modifier | Paula: +0.05 hilltop, -0.05 flood-adjacent. Additive to Burnelli-Sheldon formula. Confirm in Round 3. |
|
||
| WorldTier enum fix | Still unaddressed in code. Ticket needed before Phase 5 can use any of this. |
|
||
|
||
---
|
||
|
||
*Tyre — Round 2. Written 2026-05-01.*
|