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

47 KiB
Raw Blame History

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Tyre — Round 2: Algorithm Proposals 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. workshop active planet-down-cascade tyre 2 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

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

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

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

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

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):

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):

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.

// 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):

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.

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:

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:

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:

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.

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

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):

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):

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

#[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

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:

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."

// 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:

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.