Files
settled-reach/docs/workshops/planet-down-cascade/tyre-round2.md
T
jpmschweitzerandClaude Opus 4.6 b9fd75b840 docs(workshops): planet-down cascade workshop + misc stray files
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>
2026-05-03 20:18:30 +02:00

1077 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.01.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.350.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.350.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.53km (varies by city size) |
| 4 | Province | 1 cell on the 64×32 regional grid | ~540km × 270km |
| 5 | Area | K contiguous same-class cells | ~10005000km 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,
&current_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.*