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>
50 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Gestalt — Round 2: Algorithm Proposals | Concrete algorithm proposals for each cascade layer, with pseudocode. Attractor-matching reconciliation. TerritorialStatus threshold proposal. Three-component district mix flesh-out. Per-layer inputs, outputs, data format, Rust tier. | workshop | active | planet-down-cascade | gestalt | 2 | 2026-05-01 |
Planet-Down Cascade — Round 2 (Gestalt)
Role: Algorithm proposals. For each layer: inputs, outputs, data format, whether code is Rust runtime-background (Layers 1-2) or Rust runtime-on-demand (Layers 3-4), pseudocode for non-trivial algorithms.
Lead decisions incorporated before writing:
- District mix: Burnelli-Sheldon's three-component model selected
- L3-Q1: Spatial grid rotation locked (FoundingOrientation modifies district placement positions)
- L3-Q2: Explicit spatial arrangement for CompanyTown (spine), AdminCapital (radial), FreePort (multi-node) — locked
- L4-Q1: Threshold-crossing cache invalidation — locked
- L4-Q4: Pre-fetch interior generation — locked
- CL-Q1: Two-field prosperity (baseline + current), delta always derived — locked
- Amendment 6: Population+age baseline, economic role as modifier, no zero weights — locked
- Scatter (L4-Q5): Deferred — locked
Part 1: Reconciliation — Attractor-Matching Algorithm
The Four Positions from Round 1
| Dimension | Gestalt | Tyre | Burnelli-Sheldon | Paula |
|---|---|---|---|---|
| Assignment method | Greedy by composite score | Population-sorted greedy; Hungarian recommended | Hungarian (maximize compatibility sum) | Priority-ordered: HQ → population → unnamed → OrganicGrowth |
| Overflow | Less-ideal placements | Proximity near existing settlements | Synthetic attractors from seed | OrganicGrowth at Province centroids |
| HQ hard constraint | Score → 0.0 if incompatible | Score → 0.0 if incompatible | Must use compatible type | Fill first by economic role constraint order; flag mismatches |
| FoundingOrientation | Implicit (from attractor type) | Explicit step after assignment | Not addressed | Trivial lookup; OrganicGrowth → AdminFacing |
My Proposed Resolution
Take Tyre's Hungarian algorithm recommendation, Paula's priority ordering for the HQ phase, and Burnelli-Sheldon's synthetic attractor generation for overflow.
Rationale:
- Hungarian algorithm is correct, provably optimal for N ≤ 30 cities × M ≤ 100 attractors, and Tyre's performance estimate puts it well within the Layer 2 budget (~1ms)
- Paula's priority ordering makes the HQ constraint handling explicit — HQ-constrained cities don't compete with unconstrained cities; they are assigned first in a dedicated pass
- Burnelli-Sheldon's synthetic attractor generation is the right overflow story: it maintains the "all settlements placed at generation" determinism principle, and it produces plausible placements rather than forced-bad placements
Disagreement with Paula on one point: Paula flags mismatches for lead review rather than silently placing. I'd rather the algorithm handle the mismatch silently with a quality-degraded score, and only flag to the lead when NO compatible attractor exists at all (truly impossible placement). The silent quality-degraded path lets the generator run autonomously; the flag path requires a human in the loop for routine edge cases.
Proposed Algorithm with Pseudocode
Step 0: Input Preparation
// From systems.db at Layer 2 start
struct NamedCity {
name: String,
population: u64,
economic_role: EconomicRole,
hq_for_corp: Option<CorporationId>, // corp headquartered here (hard constraint)
world_tier: WorldTier,
}
// From Layer 1 geographic feature extraction
struct GeographicAttractor {
position: RegionalCellId,
feature_type: GeographicFeatureType,
quality_score: f32, // 0.0–1.0; larger = better attractor of its type
capacity: u8, // how many cities this attractor can support (usually 1; large harbor = 2)
}
Step 1: Compatibility Score Matrix
fn compatibility(role: EconomicRole, feature: GeographicFeatureType) -> f32 {
COMPATIBILITY_TABLE[role][feature]
}
// Full compatibility table (0.0 = incompatible, 10.0 = ideal)
// RiverConf CoastalHarbor MountainPass ArablePlain ResourceConc Defensible NaturalBarrier
// manufacturing 6.0 7.0 2.0 4.0 5.0 3.0 1.0
// agricultural 7.0 5.0 1.0 10.0 2.0 3.0 1.0
// extraction 4.0 5.0 6.0 2.0 10.0 4.0 1.0
// transit 10.0 9.0 8.0 4.0 3.0 4.0 5.0
// research 5.0 6.0 4.0 6.0 5.0 7.0 2.0
// commercial 7.0 9.0 4.0 5.0 3.0 4.0 3.0
// service_mixed 8.0 8.0 5.0 6.0 4.0 5.0 3.0
// mining 2.0 3.0 6.0 2.0 10.0 5.0 2.0
// frontier 4.0 5.0 7.0 6.0 7.0 8.0 5.0
// energy 3.0 6.0 5.0 3.0 9.0 5.0 7.0
fn score(city: &NamedCity, attractor: &GeographicAttractor) -> f32 {
let compat = compatibility(city.economic_role, attractor.feature_type);
// HQ hard constraint: if city is an HQ, verify attractor type compatibility
if let Some(corp_id) = city.hq_for_corp {
let corp = systems_db.corporation(corp_id);
if !attractor_compatible_with_corp(attractor, &corp) {
return 0.0; // hard constraint: incompatible placement is illegal
}
}
// Composite score: compatibility × quality × log-population weight
compat * attractor.quality_score * (city.population as f32 / 50_000.0).ln().max(0.5)
}
Step 2: Phase 1 — HQ-Constrained Cities
// HQ-constrained cities must use compatible attractors.
// Assign them first, before unconstrained cities compete for the same attractors.
let (hq_cities, free_cities): (Vec<_>, Vec<_>) = named_cities
.iter()
.partition(|c| c.hq_for_corp.is_some());
// Sort HQ cities by constraint tightness (extraction/mining corps = most geographically constrained)
hq_cities.sort_by_key(|c| constraint_tightness(c.economic_role));
for city in &hq_cities {
let best_available = available_attractors
.iter()
.filter(|a| score(city, a) > 0.0)
.max_by(|a, b| score(city, a).partial_cmp(&score(city, b)).unwrap());
if let Some(attractor) = best_available {
assign(city, attractor, &mut assignments);
// attractor consumed; reduce capacity or remove
} else {
// Flag to lead: no compatible attractor found for this HQ city
// This indicates a systems.db inconsistency (corp HQ with no viable planet geography)
warn!("No compatible attractor for HQ city {}: placing at nearest body centroid", city.name);
assign_synthetic(city, FoundingOrientation::AdminFacing, &mut assignments);
}
}
Step 3: Phase 2 — Unconstrained Cities (Hungarian Assignment)
let remaining_cities: Vec<_> = free_cities;
let remaining_attractors: Vec<_> = available_attractors; // after HQ phase consumes some
// Build score matrix (rows = cities, cols = attractors)
let score_matrix = remaining_cities.iter()
.map(|city| remaining_attractors.iter()
.map(|attr| score(city, attr))
.collect())
.collect();
// Hungarian algorithm: maximize total compatibility
// O(N³) where N = max(cities, attractors) ≤ 30; negligible compute
let assignment = hungarian_maximize(score_matrix);
for (city_idx, attractor_idx) in assignment {
if score_matrix[city_idx][attractor_idx] > 0.0 {
assign(&remaining_cities[city_idx], &remaining_attractors[attractor_idx], &mut assignments);
} else {
// Hungarian assigned a zero-score pair — attractor supply exhausted
overflow_cities.push(&remaining_cities[city_idx]);
}
}
Step 4: Phase 3 — Overflow (Synthetic Attractor Generation)
// More cities than compatible attractors.
// Generate synthetic attractor positions from seed.
for city in &overflow_cities {
// Find nearest already-placed city as anchor
let anchor = assignments.iter()
.min_by(|a, b| distance(a.position, city).cmp(&distance(b.position, city)));
// Seed-derived offset: deterministic but appears organic
let city_seed = child_seed(world_seed, hash(city.name));
let offset_angle = (city_seed & 0xFF) as f32 * TAU / 256.0;
let offset_distance = 0.15 + (city_seed >> 8 & 0x3F) as f32 / 256.0 * 0.25;
// offset_distance is [15%, 40%] of body scale — matches sub-settlement placement rule
let synthetic_pos = anchor.position + polar_to_regional(offset_angle, offset_distance);
assign_at(city, synthetic_pos, FoundingOrientation::AdminFacing, false /*geographically_triggered*/, &mut assignments);
}
Step 5: FoundingOrientation Derivation
fn founding_orientation_from_attractor(feature: GeographicFeatureType) -> FoundingOrientation {
match feature {
RiverConfluence => FoundingOrientation::PortFacing, // river as the original port
CoastalHarbor => FoundingOrientation::PortFacing,
MountainPass => FoundingOrientation::DefenseFacing, // controls the pass
ArablePlain => FoundingOrientation::ResourceFacing, // agricultural resource
ResourceConcentration => FoundingOrientation::ResourceFacing,
Defensible => FoundingOrientation::DefenseFacing,
NaturalBarrier => FoundingOrientation::AdminFacing, // regional capital at chokepoint
}
}
// Synthetic placements: AdminFacing (OrganicGrowth)
Handling NEW-Q2 (OrganicGrowth disambiguation):
Paula's proposal is correct: geographically_triggered: bool on the settlement record. Set true for Phases 1 and 2 (attractor-assigned); false for Phase 3 (synthetic). FoundingOrientation for synthetic = AdminFacing. No other disambiguation needed — OrganicGrowth settlements are those where the generator ran out of geographic attractors, not a designed settlement type.
Part 2: TerritorialStatus Threshold Proposal
Reconciliation Problem
Round 1 produced three threshold specifications — Paula (settlement + road coverage), Tyre (density formula), Burnelli-Sheldon (economic proxies). All three have merit; they're looking at the same thing from different angles.
My resolution: Use a priority-ordered classification algorithm. At Province (64×32) resolution, discrete category priority beats continuous threshold competition. The statuses are mutually exclusive; the order of classification determines which wins when multiple conditions are partially true.
Proposed Algorithm
Inputs per Province cell:
placed_settlements: all settlements assigned to this Province at Layer 2 generation time (active + inactive)active_settlements: settlements where economic sim says currently activeroad_edges: road segments passing through or originating in this Provinceroad_authority_types: distinct MaintenanceAuthority values on road_edgesgeographic_features: features from Layer 1 (particularly ResourceConcentration)corp_presence: corps actively operating in this Provinceplaced_at_generation: boolon each settlement (Paula's marker — set immutably at Layer 2 time)
Classification algorithm (first match wins, applied in this order):
fn classify_territorial_status(province: &Province) -> TerritorialStatus {
// 1. WildernessBuffer — simplest case, check first
if province.placed_settlements.is_empty() && province.road_edges.is_empty() {
return TerritorialStatus::WildernessBuffer;
}
// 2. AbandonedZone — was settled, now empty
// Requires Paula's placed_at_generation marker to distinguish from never-settled
if !province.placed_settlements.is_empty()
&& province.active_settlements.is_empty() {
return TerritorialStatus::AbandonedZone;
}
// 3. ExtractiveZone — corporate extraction corridor
// Characteristic: ResourceConcentration feature + extractive corp presence
// + road network dominated by Corporate/Commercial authority (extraction corridor)
let has_resource = province.geographic_features.contains(ResourceConcentration);
let has_extractive_corp = province.corp_presence.iter()
.any(|c| c.commodity_matches_resource(&province.geographic_features));
let road_is_extraction_corridor = province.road_authority_types.iter()
.all(|a| matches!(a, MaintenanceAuthority::Corporate | MaintenanceAuthority::Trade));
if has_resource && has_extractive_corp && road_is_extraction_corridor {
return TerritorialStatus::ExtractiveZone;
}
// 4. ContestZone — competing claims
// Two or more active settlements with different political_archetype or dominant_faction
// AND road networks from different authority types
if province.active_settlements.len() >= 2
&& province.road_authority_types.len() >= 2 {
let archetypes: HashSet<_> = province.active_settlements.iter()
.map(|s| s.political_archetype)
.collect();
if archetypes.len() >= 2 {
return TerritorialStatus::ContestZone;
}
}
// 5. CoreTerritory vs. FrontierTerritory
// Only difference: road coverage within the Province
if !province.active_settlements.is_empty() {
let road_coverage = province.road_coverage_fraction();
// road_coverage_fraction() = non-ocean cells within 2 road-edges of a road segment / total non-ocean cells
if road_coverage >= 0.50 {
return TerritorialStatus::CoreTerritory;
} else {
return TerritorialStatus::FrontierTerritory;
}
}
// 6. Default fallback
TerritorialStatus::WildernessBuffer
}
Threshold Values — Rationale
| Threshold | Value | Rationale |
|---|---|---|
| CoreTerritory road_coverage | ≥ 0.50 | Half the Province within road access is administratively connected. Below this, settlements exist but infrastructure is sparse. |
| FrontierTerritory road_coverage | < 0.50 | Settlements placed but infrastructure thin. The player can be here, but it's the edge of the network. |
| ExtractiveZone corp trigger | any qualifying corp in corp_presence | Even one extractive corp creates the extraction character. Additional corps amplify it, don't change the classification. |
| ContestZone archetype diversity | ≥ 2 distinct political_archetype | Two different archetypes in the same Province = genuinely contested space. |
| AbandonedZone | placed > 0, active = 0 | Requires Paula's placed_at_generation marker. |
Why the threshold values work:
The 0.50 CoreTerritory threshold maps to: in a 64×32 Province cell, about 32 cells within road access out of 64 non-ocean cells. At the road topology we're generating (MST + organic connectors), this means at least one well-connected settlement with secondary roads branching outward. Below 0.50, settlements exist as isolated points on thin roads.
AbandonedZone requires runtime evaluation. At Layer 2 generation time, all placed settlements are potentially active; the placed/active distinction is a runtime economics state. The Province-level status is therefore a live query against the economic sim's current state, not a stored value. This is consistent with CL-Q2: regional land-use classification (at Province resolution) is a runtime parameter read, not a systems.db stored value.
Disagreement with Burnelli-Sheldon's purely economic proxy approach: Using corp_financial_state.health_metric to derive TerritorialStatus directly makes sense for AbandonedZone, but isn't the right lens for CoreTerritory/FrontierTerritory. A well-funded corp can be in a FrontierTerritory (extraction corridor with minimal broader infrastructure). Road coverage is more physically accurate.
Difference from Paula's 0.6/0.3 thresholds: Paula proposes 0.6 for core, 0.3-0.6 for frontier. I'm using 0.5 as the single threshold for simplicity — one boundary, two states, no ambiguous middle range. The status is categorical, not a continuum.
Part 3: Three-Component District Mix — Fleshed Out
The Three Components
Lead selected Burnelli-Sheldon's model. My job here is to specify exact thresholds and the interaction logic.
Component 1: Population Tier Guarantees
| Settlement tier | Population | Guaranteed district types |
|---|---|---|
| Waypoint | < 50 | 1× Mixed only (no pure types at this scale) |
| Outpost | 50–999 | 1× dominant economic type (derived from economic_role) |
| Town | 1,000–9,999 | 1× Residential, 1× dominant economic type |
| Small City | 10,000–49,999 | 1× Residential, 1× Commercial, 1× Entertainment, 1× dominant economic type |
| City | 50,000–499,999 | 1× Residential, 1× Commercial, 1× Entertainment, 1× Administrative, 1× dominant economic type |
| Large City | 500,000+ | 1× all essential types (Res, Com, Ind, Adm, Log, Ent, Mix); WorldTier ceiling applies |
Resolving BS-Q1 (9,999-person Town without dedicated Entertainment):
Towns (1,000–9,999) do NOT guarantee a dedicated Entertainment district. They get Entertainment CHARACTER within their Mixed or Residential districts — the founding_age modifier and economic role determine how much entertainment is embedded. A 9,999-person mining town has bars; they're just in the Mixed district, not a dedicated Ent block. The dedicated Ent guarantee kicks in at Small City (10,000+).
This is physically correct: 10,000 people is the threshold where a permanent entertainment district becomes economically self-sustaining.
Component 2: Economic Role Multiplier Table (No Zero Weights)
All values ≥ 0.2. Values above 1.0 amplify; values below 1.0 suppress relative to the baseline.
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|---|---|---|---|---|---|---|---|---|---|
| manufacturing | 1.0 | 0.5 | 3.0 | 0.5 | 2.0 | 0.3 | 0.5 | 0.5 | 0.3 |
| agricultural | 1.2 | 0.8 | 0.3 | 0.5 | 1.5 | 0.4 | 1.2 | 0.3 | 0.2 |
| extraction | 1.2 | 0.4 | 2.5 | 0.3 | 2.5 | 0.5 | 0.5 | 0.5 | 0.2 |
| transit | 0.6 | 1.5 | 0.3 | 0.4 | 2.0 | 0.8 | 1.2 | 3.0 | 0.2 |
| research | 1.0 | 0.4 | 0.4 | 1.0 | 0.3 | 0.5 | 0.8 | 0.2 | 3.0 |
| commercial | 0.8 | 3.0 | 0.3 | 0.4 | 0.8 | 1.2 | 1.5 | 0.2 | 0.2 |
| service_mixed | 1.2 | 1.5 | 0.3 | 0.8 | 0.3 | 1.2 | 2.0 | 0.2 | 0.2 |
| mining | 1.5 | 0.4 | 2.0 | 0.2 | 3.0 | 0.5 | 1.0 | 0.4 | 0.2 |
| frontier | 2.0 | 0.8 | 0.8 | 0.3 | 1.5 | 0.5 | 1.5 | 0.2 | 0.2 |
| energy | 0.6 | 0.2 | 1.5 | 0.4 | 2.5 | 0.3 | 0.5 | 0.5 | 2.5 |
Key change from the original 10×9 table: no value is zero. Mining row gets Ent = 0.5 (not 0). Energy row gets Com = 0.2 and Ent = 0.3 (not 0). Every type is present at some weight; the guaranteed floor ensures it actually appears.
Component 3: Founding Age Modifier
Resolving BS-Q2: age modifier applies at Town tier and above only. Outposts and Waypoints don't accumulate age variety.
| Age bracket | founding_age_years |
Effect on district variety |
|---|---|---|
| New | 0–49 | No variety bonus. Dominant type is dominant. |
| Established | 50–199 | +0.1 to Entertainment and Mixed multipliers. Second economic type starts appearing. |
| Mature | 200–499 | +0.25 to Entertainment, +0.15 to Mixed, +0.10 to Commercial. Cultural layering visible. |
| Ancient | 500+ | +0.40 to Entertainment, +0.25 to Mixed, +0.15 to Specialized. High variety baseline. |
The age variety bonus is additive to the multiplier (not multiplicative — avoids compounding). An ancient extraction city's Entertainment multiplier is 0.5 + 0.40 = 0.90, which is now close to par. The mining bars are old, well-established, and culturally significant. This is physically correct.
The Interaction Algorithm
fn allocate_districts(ctx: &CityGenerationContext, seed: u64) -> Vec<DistrictType> {
let district_count = district_count_formula(ctx.population, ctx.world_tier);
// STEP 1: Guaranteed floor from population tier
let mut guaranteed = guaranteed_district_types(ctx.population);
// guaranteed is a Vec<DistrictType> with 1 of each guaranteed type
// e.g., Small City: [Residential, Commercial, Entertainment, dominant_economic_type]
// STEP 2: Compute raw weights from multiplier table
let mut weights: [f32; 9] = MULTIPLIER_TABLE[ctx.economic_role];
// STEP 3: Apply founding_age modifier (Town tier and above only)
if ctx.population >= 1_000 {
let age_bonus = age_variety_bonus(ctx.founding_age_years);
weights[DistrictType::Entertainment as usize] += age_bonus.entertainment;
weights[DistrictType::Mixed as usize] += age_bonus.mixed;
weights[DistrictType::Specialized as usize] += age_bonus.specialized;
weights[DistrictType::Commercial as usize] += age_bonus.commercial;
}
// STEP 4: Apply political_archetype modifiers
// (CompanyTown amplifies dominant type; FreePort flattens toward Mix/Transit/Commercial)
apply_archetype_modifiers(&mut weights, ctx.political_archetype);
// STEP 5: Normalize weights
let total: f32 = weights.iter().sum();
let normalized: [f32; 9] = weights.map(|w| w / total);
// STEP 6: Allocate districts
let mut districts: Vec<DistrictType> = guaranteed.clone();
let remaining_slots = district_count.saturating_sub(guaranteed.len());
// Fill remaining slots by seeded weighted selection
// Already-guaranteed types can be selected again (multiple Commercial districts in a commercial city)
let rng = child_seed(seed, DISTRICT_ALLOC_DISCRIMINANT);
for i in 0..remaining_slots {
let slot_seed = child_seed(rng, i as u64);
districts.push(weighted_select(&normalized, slot_seed));
}
districts
}
Character, not just presence: The multiplier table modifies allocation probability. The character of each allocated district is further shaped downstream by the same multiplier values — a 0.3-multiplier Entertainment district gets low density_pct and rough perimeter_treatment; a 1.2-multiplier Entertainment district gets higher density and varied character. The multiplier is a characterization signal that flows through the entire Layer 3-4 pipeline.
Part 4: Layer 1 Algorithm Proposals
Execution tier: Rust runtime-background
Inputs:
- systems.db:
atlas_body_heightmaps(body_id, elevation_f32_le BLOB, 512×256 grid, sea_level, seed) - systems.db:
atlas_regional_biomes(body_id, cell_id, biome_class, terrain_roughness, is_coastal, water_fraction) - systems.db:
bodies(body_id, economic_role, planet_class) world_seed: u64
Outputs (to BodyWorldState):
river_network: RiverNetwork(edge list with flow accumulation + confluence + mouth nodes)geographic_features: Vec<(RegionalCellId, Vec<GeographicFeatureTag>)>(Layer 2 settlement input)sub_biome_variants: Vec<(RegionalCellId, SubBiomeVariant)>terrain_mod_costs: Vec<(RegionalCellId, f32)>
No output goes to systems.db. All Layer 1 data lives in BodyWorldState (session memory, Rust, reproducible from seed). This is the Amendment 1 consequence.
Algorithm 1: D8 Drainage Routing
fn compute_river_network(heightmap: &Heightmap, sea_level: f32, seed: u64) -> RiverNetwork {
let (width, height) = (512, 256);
// Step 1: D8 flow direction (8-directional steepest descent)
let mut flow_dir: Vec<Option<CellIdx>> = vec![None; width * height];
for cell in 0..(width * height) {
if heightmap[cell] <= sea_level { continue; }
let neighbors = d8_neighbors(cell, width, height);
flow_dir[cell] = neighbors.iter()
.filter(|&&n| heightmap[n] < heightmap[cell])
.min_by(|&&a, &&b| heightmap[a].partial_cmp(&heightmap[b]).unwrap())
.copied();
}
// Step 2: Flow accumulation (DFS traversal; each upstream cell contributes 1)
let mut accumulation: Vec<u32> = vec![0; width * height];
for cell in 0..(width * height) {
let mut cursor = cell;
while let Some(downstream) = flow_dir[cursor] {
accumulation[downstream] += 1;
cursor = downstream;
}
}
// Step 3: River threshold — cells with enough upstream area are "rivers"
let river_threshold = 200u32; // 200 contributing cells → river (tunable)
let river_cells: Vec<CellIdx> = (0..(width * height))
.filter(|&c| accumulation[c] > river_threshold)
.collect();
// Step 4: Extract confluence nodes (cells with ≥2 incoming river-threshold tributaries)
let confluence_nodes: Vec<ConfluenceNode> = river_cells.iter()
.filter(|&&c| {
d8_neighbors(c, width, height).iter()
.filter(|&&n| river_cells.contains(&n) && flow_dir[n] == Some(c))
.count() >= 2
})
.map(|&c| ConfluenceNode { cell: c, inflow_count: count_inflows(c) })
.collect();
// Step 5: River mouth nodes (river cell at sea level boundary)
let river_mouths: Vec<RiverMouthNode> = river_cells.iter()
.filter(|&&c| {
d8_neighbors(c, width, height).iter()
.any(|&n| heightmap[n] <= sea_level)
})
.map(|&c| RiverMouthNode { cell: c })
.collect();
RiverNetwork { river_cells, confluence_nodes, river_mouths, flow_dir, accumulation }
}
Performance note: D8 flow direction is O(N), accumulation is O(N log N) with recursive DFS. For 512×256 = 131,072 cells, well within Tyre's ~50ms estimate.
Algorithm 2: Geographic Feature Tag Extraction
fn extract_geographic_features(
regional_grid: &RegionalBiomeGrid, // 64×32
river_network: &RiverNetwork,
heightmap: &Heightmap, // 512×256, downsampled to regional res
economic_role: EconomicRole,
sea_level: f32,
) -> Vec<(RegionalCellId, Vec<GeographicFeatureTag>)> {
let mut features: Vec<(RegionalCellId, Vec<GeographicFeatureTag>)> = Vec::new();
for cell_id in 0..(64 * 32) {
let cell = ®ional_grid[cell_id];
let mut cell_features: Vec<GeographicFeatureTag> = Vec::new();
// RiverConfluence: any confluence node within this regional cell's 8×8 heightmap bbox
if river_network.confluence_nodes.iter()
.any(|c| regional_cell_contains(cell_id, c.cell)) {
cell_features.push(GeographicFeatureTag::RiverConfluence);
}
// CoastalHarbor: coastal cell with low roughness and river mouth nearby
if cell.is_coastal && cell.terrain_roughness < 0.3 {
let has_river_mouth = river_network.river_mouths.iter()
.any(|m| regional_cell_contains_or_adjacent(cell_id, m.cell));
cell_features.push(GeographicFeatureTag::CoastalHarbor);
// Note: coastal cells without river mouth are harbors too, just lower quality
}
// MountainPass: high-roughness cell with lower cells on two opposing sides
if cell.terrain_roughness > 0.65 {
let opposing_lower = has_lower_cells_on_opposing_sides(cell_id, regional_grid);
if opposing_lower {
cell_features.push(GeographicFeatureTag::MountainPass);
}
}
// ArablePlain: low roughness, terrestrial, non-coastal, suitable biome
if cell.terrain_roughness < 0.25
&& !cell.is_ocean && !cell.is_coastal
&& ARABLE_BIOMES.contains(&cell.biome_class) {
cell_features.push(GeographicFeatureTag::ArablePlain);
}
// ResourceConcentration: economic_role biome prior (L1-Q4 applied here)
// An extraction-economy body should have more ResourceConcentration tags
let resource_weight = economic_role_resource_weight(economic_role, &cell.biome_class);
if resource_weight > 0.6 {
cell_features.push(GeographicFeatureTag::ResourceConcentration);
}
// Defensible: elevated position with limited approach vectors (≤2 non-cliff approaches)
if cell.terrain_roughness > 0.5 {
let approach_count = count_accessible_approaches(cell_id, regional_grid);
if approach_count <= 2 {
cell_features.push(GeographicFeatureTag::Defensible);
}
}
// NaturalBarrier: ocean, mountain spine, impassable swamp
if cell.is_ocean || (cell.terrain_roughness > 0.85) || is_impassable_biome(&cell.biome_class) {
cell_features.push(GeographicFeatureTag::NaturalBarrier);
}
if !cell_features.is_empty() {
features.push((cell_id, cell_features));
}
}
features
}
Algorithm 3: Sub-Biome Variant Selection (L1-Q4 included)
fn compute_sub_biome_variants(
regional_grid: &RegionalBiomeGrid,
world_seed: u64,
economic_role: EconomicRole,
) -> Vec<(RegionalCellId, SubBiomeVariant)> {
regional_grid.iter().map(|(cell_id, cell)| {
let cell_seed = child_seed(world_seed, cell_id as u64);
let base_variants = SUB_BIOME_VARIANTS[cell.biome_class]; // 3-4 per class
// L1-Q4: economic role biases sub-biome toward resources
let weights = economic_prior_weights(base_variants, economic_role, cell);
let variant = weighted_select(base_variants, &weights, cell_seed);
(cell_id, variant)
}).collect()
}
// terrain_modification_cost: derived analytically, no separate algorithm needed
// high roughness = high cost; dense/wet biome = higher cost; plains = low cost
fn terrain_mod_cost(cell: &RegionalCell) -> f32 {
let roughness_cost = cell.terrain_roughness * 0.6;
let biome_cost = BIOME_CLEARING_COST[cell.biome_class]; // forest >> grassland
(roughness_cost + biome_cost).clamp(0.0, 1.0)
}
Part 5: Layer 2 Algorithm Proposals
Execution tier: Rust runtime-background
Inputs:
- BodyWorldState (from Layer 1): river_network, geographic_features, sub_biome_variants, terrain_mod_costs
- systems.db: bodies, system_economy, corp_presence, corporations, atlas_city_names (new schema per Tyre's ARCH-3)
world_seed: u64
Outputs (extend BodyWorldState):
settlements: Vec<GeneratedSettlement>road_graph: RoadGraph(nodes + edges with MaintenanceAuthority)territorial_grid: Vec<(RegionalCellId, TerritorialCell)>
Algorithm 1: Settlement Placement (Attractor-Matching)
Fully specified in Part 1. The Layer 2 integration:
fn place_settlements(body_state: &BodyWorldState, systems_db: &SystemsDb, world_seed: u64)
-> Vec<GeneratedSettlement>
{
// Read named cities from systems.db (new atlas_city_names table)
let named_cities: Vec<NamedCity> = systems_db.named_cities_for_body(body_state.body_id);
// Attractors from Layer 1 geographic features
let attractors: Vec<GeographicAttractor> = body_state.geographic_features.iter()
.flat_map(|(cell_id, tags)| tags.iter().map(|tag| GeographicAttractor {
position: *cell_id,
feature_type: *tag,
quality_score: attractor_quality(cell_id, tag, body_state),
}))
.collect();
// Run attractor-matching algorithm (Part 1)
let assignments = attractor_match(named_cities, attractors, world_seed);
// Convert to GeneratedSettlement
assignments.iter().map(|a| GeneratedSettlement {
name: a.city.name.clone(),
position: a.position,
economic_role: a.city.economic_role,
population: a.city.population,
founding_orientation: a.founding_orientation,
geographically_triggered: a.geographically_triggered,
political_archetype: derive_political_archetype(a),
political_tether: None, // only for sub-settlements
active: true, // runtime state; economic sim updates this
}).collect()
}
Sub-settlement placement runs after primary settlement placement, using the same deterministic seed pattern from Round 3 / Layer 2 brief section. Sub-settlements inherit PoliticalTether from their nearest primary settlement.
Algorithm 2: Road Graph Generation
Under Amendment 3, all roads are generated. Named highway routes get identity via post-generation assignment.
fn generate_road_graph(settlements: &[GeneratedSettlement], world_seed: u64) -> RoadGraph {
// Step 1: Build MST (minimum spanning tree) for inter-settlement connectivity
// Weights: geographic distance × terrain_modification_cost × attractor compatibility
let mst = prim_mst(settlements, |a, b| road_cost(a, b));
// Step 2: Add organic secondary connections (not strictly MST)
// For each non-MST pair within threshold distance, seed-derived probability of connection
let secondary_edges = organic_road_connections(settlements, mst, world_seed);
// Step 3: Assign MaintenanceAuthority per edge
// Administrative: between two administratively tethered settlements
// Corporate: between corp HQ and its corp-tethered outposts
// Communal: between rural clusters and their tether towns
// Trade: named trade routes (post-generation identity assignment)
// Abandoned: leading to AbandonedZone settlements
let edges_with_authority = assign_road_authority(mst + secondary_edges, settlements);
// Step 4: Assign named route identity to qualifying road segments
// A named highway route = high-quality road segment between two large settlements
// on a body where the route name appears in systems.db route_names
let named_routes = assign_named_routes(edges_with_authority, systems_db);
RoadGraph { nodes: settlements.map(|s| s.position), edges: named_routes }
}
Algorithm 3: TerritorialStatus Classification
Fully specified in Part 2. The Layer 2 integration:
fn classify_territorial_grid(
road_graph: &RoadGraph,
settlements: &[GeneratedSettlement],
geographic_features: &[(RegionalCellId, Vec<GeographicFeatureTag>)],
corp_presence: &[CorporatePresence],
) -> Vec<(RegionalCellId, TerritorialCell)> {
(0..(64 * 32)).map(|cell_id| {
let status = classify_territorial_status(&ProvinceView {
cell_id,
settlements: settlements.iter().filter(|s| s.position == cell_id).collect(),
road_edges: road_graph.edges_in_cell(cell_id),
geographic_features: geographic_features.features_at(cell_id),
corp_presence: corp_presence.in_cell(cell_id),
});
(cell_id, TerritorialCell { status, placed_at_generation: !settlements.is_empty() })
}).collect()
}
Part 6: Layer 3 Algorithm Proposals
Execution tier: Rust runtime-on-demand (triggered by player proximity to city footprint)
Inputs (per city):
CityGenerationContextfrom systems.db (read at session startup from BodyWorldState handoff)body_id,city_name,political_archetype,prosperity_baseline,surrounding_biomeroad_entry_directions,footprint_radius_km,founding_orientation,world_tier
world_seed: u64
Outputs (in memory, never stored):
district_positions: Vec<(DistrictId, SimTilePos)>district_skeletons: Vec<DistrictSkeleton>(Stage 1 + Stage 2)
Algorithm 1: District Grid Layout with FoundingOrientation Rotation (L3-Q1 — locked)
fn layout_district_grid(ctx: &CityGenerationContext) -> Vec<(DistrictId, SimTilePos)> {
let district_count = district_count_formula(ctx.population, ctx.world_tier);
// Base grid: districts arranged in near-square grid
let grid_cols = (district_count as f32).sqrt().ceil() as u32;
let grid_rows = (district_count as f32 / grid_cols as f32).ceil() as u32;
// FoundingOrientation determines which edge is "primary" (the city's face)
// Grid is rotated so the primary edge faces the founding attractor direction
let primary_edge = founding_orientation_to_primary_edge(ctx.founding_orientation);
let mut positions: Vec<(DistrictId, SimTilePos)> = Vec::new();
let mut district_id = 0u64;
for row in 0..grid_rows {
for col in 0..grid_cols {
if district_id >= district_count as u64 { break; }
// District origin in city-local sim tiles (D-C5: district at (col×512, row×512))
let base_pos = SimTilePos {
x: col * 512,
y: row * 512,
};
// Apply FoundingOrientation rotation
let rotated_pos = rotate_district_position(base_pos, primary_edge, grid_cols, grid_rows);
positions.push((district_id, rotated_pos));
district_id += 1;
}
}
positions
}
fn founding_orientation_to_primary_edge(orientation: FoundingOrientation) -> CardinalDirection {
match orientation {
FoundingOrientation::PortFacing => CardinalDirection::South, // harbor at south edge
FoundingOrientation::ResourceFacing => CardinalDirection::East, // resource face
FoundingOrientation::DefenseFacing => CardinalDirection::North, // elevated/inland face
FoundingOrientation::RailHeadFacing => CardinalDirection::West,
FoundingOrientation::AdminFacing => CardinalDirection::North, // administrative at top
}
}
Algorithm 2: Political Archetype Spatial Arrangement (L3-Q2 — locked)
Three explicit arrangement patterns for primary archetypes:
fn apply_archetype_arrangement(
district_positions: &[(DistrictId, SimTilePos)],
district_types: &[DistrictType],
archetype: PoliticalArchetype,
grid_cols: u32,
) -> Vec<DistrictType> {
match archetype {
// SPINE: industrial/logistics at primary edge, residential cascading back
PoliticalArchetype::CompanyTown => {
// Primary edge (row 0) = Industrial + Logistics
// Middle rows = Mixed + Residential
// Back edge (last row) = Residential only
arrange_spine(district_positions, district_types, grid_cols)
}
// RADIAL: administrative at center, residential radiating outward
PoliticalArchetype::AdminCapital => {
// Center district = Administrative
// Ring 1 = Commercial + Specialized
// Ring 2+ = Residential (density decreasing outward)
arrange_radial(district_positions, district_types, grid_cols)
}
// MULTI-NODE: no single center; transit/commercial at multiple entry points
PoliticalArchetype::FreePort => {
// Multiple Transit/Commercial anchors at different edges
// No Administrative core; Mixed throughout
// High variance — seed-derived node positions
arrange_multi_node(district_positions, district_types, grid_cols)
}
// OVERLAY: two competing cores from different archetypes, separated spatially
// (Contested cities have two neighborhoods with different dominant character)
PoliticalArchetype::Contested => {
// Split the grid in half; each half gets a different sub-archetype arrangement
// The boundary between halves = Mixed/contested zone
arrange_contested_overlay(district_positions, district_types, grid_cols)
}
// IRREGULAR: no explicit arrangement; weighted by local density
// Density highest near geographic attractor (river, coastal)
PoliticalArchetype::OrganicGrowth => {
// Use normalized weights directly — no explicit arrangement pattern
// The FoundingOrientation edge still has its primary type there,
// but the rest is seed-derived from weights
arrange_organic(district_positions, district_types, grid_cols)
}
}
}
Note on L3-Q2 disagreement with Gestalt Round 1: I previously proposed explicit arrangement for three primary archetypes, emergent for Contested and OrganicGrowth. Paula argued for all five. I'm now updating my position: explicit patterns for all five, but Contested and OrganicGrowth are simpler patterns than the primary three. Contested is a split-grid overlay; OrganicGrowth is a density-weighted fallback. These are not emergent — they're just simpler explicit patterns. The code above shows why: each match arm is distinct and produces a consistently legible result. Purely emergent produces less consistent results and wastes the archetype label.
Algorithm 3: District Type Allocation
Specified in Part 3. The Layer 3 integration applies this per city to produce district_type for each district position.
Algorithm 4: DistrictSkeleton Stage 1 Classification
fn classify_district(
district_id: DistrictId,
district_type: DistrictType,
position_rank: u32, // 0 = primary-edge district, increases inward
ctx: &CityGenerationContext,
district_seed: u64,
) -> DistrictStage1 {
// prosperity_baseline from Burnelli-Sheldon formula + Paula's topographic modifier
let base = ctx.economic_tier as f32 / 5.0;
let role_modifier = ROLE_MODIFIER[ctx.economic_role];
let prestige_rank = prestige_rank_for_position(position_rank, ctx.founding_orientation);
let prosperity_baseline = match ctx.distribution_index {
DistributionIndex::Stratified => {
base + role_modifier + (prestige_rank - 0.5) * 0.7
}
DistributionIndex::Moderate => {
let mean = base + role_modifier;
clamp(normal_sample(mean, 0.1, district_seed), 0.2, 0.8)
}
};
// Paula's topographic modifier (additive)
let topo_modifier = topo_prosperity_modifier(position_rank, ctx.founding_orientation);
let prosperity_baseline = (prosperity_baseline + topo_modifier).clamp(0.0, 1.0);
DistrictStage1 {
world_tier: ctx.world_tier,
complexity_tier: complexity_from_world_tier(ctx.world_tier),
setting_type: SettingType::Urban, // wilderness districts are a separate path
district_type,
layout_mode: layout_mode_for_archetype(ctx.political_archetype, district_type),
prosperity_baseline,
perimeter_treatment: perimeter_for_type_and_archetype(district_type, ctx.political_archetype),
}
}
Part 7: Layer 4 Algorithm Proposals
Execution tier: Rust runtime-on-demand (per chunk, triggered by load_chunk())
Inputs (per chunk):
district_skeleton: &DistrictSkeleton(Stage 1 + Stage 2, from Layer 3)block_skeleton: &BlockSkeleton(4×4 grid from Stage 2 — the specific block this chunk belongs to)chunk_seed: u64(= child_seed(district_seed, cx as u64 << 32 | cy as u64))prosperity_current: f32(runtime economic state — separate from prosperity_baseline)
Outputs:
GeneratorChunkData: Vec<TileEntry>(64×64 = 4,096 entries)
Layout is seed-locked. Condition state is economics-variable (computed from prosperity_current, not seed).
Algorithm 1: Street Skeleton (Phase 2, confirmed algorithm)
fn generate_street_skeleton(block_skeleton: &BlockSkeleton, chunk_coord: ChunkCoord) -> StreetMask {
// Door-per-block-edge defines connectivity (confirmed algorithm from Rounds 2-3)
// Each block edge can have 0, 1, or 2 door positions (seed-derived)
let block_seed = child_seed(chunk_seed, BLOCK_SEED_DISCRIMINANT);
let mut street_mask = [false; 64 * 64];
// Street corridors along block edges
for edge in block_skeleton.edges() {
// Minimum corridor width: 4 tiles (enough for two-way foot traffic + walls)
// Maximum corridor width: 8 tiles (main arterial)
let corridor_width = match block_skeleton.density_pct {
d if d > 0.75 => 4, // packed: narrow streets
d if d > 0.40 => 6, // moderate density: normal streets
_ => 8, // sparse: wide boulevards
};
mark_corridor(&mut street_mask, edge, corridor_width);
}
StreetMask(street_mask)
}
Algorithm 2: Building Fill
fn fill_buildings(
street_mask: &StreetMask,
block_skeleton: &BlockSkeleton,
chunk_seed: u64,
) -> Vec<BuildingFootprint> {
let available = street_mask.invert(); // non-street cells
let target_coverage = block_skeleton.density_pct;
// Maximum building rectangle: 12×12 tiles (prevents monolithic blocks)
// Minimum building rectangle: 2×4 tiles (smallest viable structure)
let max_rect = (12, 12);
let min_rect = (2, 4);
let mut buildings: Vec<BuildingFootprint> = Vec::new();
let mut remaining = available.clone();
let mut rng = chunk_seed;
while coverage(&buildings, 64) < target_coverage && !remaining.is_empty() {
// Seed-derived rectangle: random starting cell, random dimensions
rng = child_seed(rng, BUILDING_SEED_DISCRIMINANT);
let start = weighted_empty_cell(&remaining, rng);
let w = lerp_u32(min_rect.0, max_rect.0, (rng >> 8) as f32 / u32::MAX as f32);
let h = lerp_u32(min_rect.1, max_rect.1, (rng >> 16) as f32 / u32::MAX as f32);
if can_place_rect(&remaining, start, w, h) {
let footprint = BuildingFootprint {
origin: start,
width: w,
height: h,
door_positions: door_positions_per_edge(start, w, h, rng),
};
mark_occupied(&mut remaining, &footprint);
buildings.push(footprint);
}
}
buildings
}
Algorithm 3: TileEntry Generation (Economics-Variable Conditions)
fn generate_chunk_tiles(
street_mask: &StreetMask,
buildings: &[BuildingFootprint],
prosperity_current: f32,
prosperity_baseline: f32,
) -> Vec<TileEntry> {
// Tile condition derived from prosperity_current (NOT from seed — economics-variable)
let tile_condition = prosperity_to_condition(prosperity_current);
(0..64*64).map(|idx| {
let pos = (idx % 64, idx / 64);
let tile_id = if street_mask.is_street(pos) {
TileId::FloorStreet
} else if buildings.iter().any(|b| b.is_wall(pos)) {
TileId::Wall
} else if buildings.iter().any(|b| b.is_interior(pos)) {
TileId::FloorInterior
} else {
TileId::FloorStreet // uncovered space defaults to street-like open ground
};
TileEntry {
tile_id,
walkable: !matches!(tile_id, TileId::Wall),
condition: tile_condition, // economics-variable
}
}).collect()
}
// LOCKED (L4-Q1): Threshold-crossing cache invalidation
// Thresholds: Intact ≥ 0.60, Worn [0.40, 0.60), Cracked [0.20, 0.40), Broken < 0.20
// Paula's offset proposal (0.63/0.43/0.23) adopted to prevent oscillation at boundaries
fn prosperity_to_condition(prosperity_current: f32) -> TileCondition {
match prosperity_current {
p if p >= 0.63 => TileCondition::Intact,
p if p >= 0.43 => TileCondition::Worn,
p if p >= 0.23 => TileCondition::Cracked,
_ => TileCondition::Broken,
}
}
// Chunk invalidation: called when economic sim pushes a prosperity update
fn invalidate_chunk_if_threshold_crossed(
chunk: &mut ChunkConditionState,
new_prosperity: f32,
) {
let new_condition = prosperity_to_condition(new_prosperity);
if new_condition != chunk.cached_condition {
chunk.dirty = true;
chunk.prosperity_snapshot = new_prosperity;
chunk.cached_condition = new_condition;
// chunk.tile_conditions regenerated on next render request
}
}
Paula's offset thresholds adopted: 0.63/0.43/0.23 instead of round 0.6/0.4/0.2. This prevents oscillation at boundary values (a district at exactly 0.60 doesn't flicker between Intact and Worn each economic tick). I updated my Round 1 position here — the offset is strictly better than round numbers for no implementation cost.
Part 8: Positions on Remaining Open Items
L4-Q5 Scatter — Lead Decision: Deferred
Locked per lead. I'll add one note for the record: prop spawn points can be stored in TileEntry without props being rendered, as a spawn_category: Option<PropCategory> field. The layout is then seed-locked (which tiles are spawn points) while the entity system populates props at runtime. This is consistent with the determinism rule and would let the deferred scatter work activate without a re-generation. Not proposing this now — flagging for the implementation ticket when scatter scope is unlocked.
L3-Q7 Port/Station Direction — My Position
I previously classified this as deferred. After Ozzie's "if I'm standing in the Transit district, I want to see the beanstalk" and Paula's mechanism design (one query, one lookup, one edge-assignment), I'm upgrading this to: worth including in the minimum viable Layer 3 struct, even if rendering is deferred.
Adding orbital_approach_direction: Option<CardinalDirection> to CityGenerationContext is one systems.db query at session startup. It requires knowing the orbital station's atlas position relative to the city's regional position, which Tyre's dimension table confirms is derivable. The data field costs nothing. The rendering that uses it is separate work.
Agreed Positions (Carried Forward Unchanged)
| Question | Position | Reason |
|---|---|---|
| L3-Q6 (sub-settlement codepath) | Unified: district_count = 1 |
No implementation reason for a separate codepath at this point |
| CL-Q2 (regional land-use evolution) | Runtime biome-cell update, not ChunkMutation | ChunkMutations = player-caused tile-precision changes only |
| CL-Q3 (ruin lifecycle) | Emergent rendering consequence, not a design question | Amendment 5 settled this |
| NEW-Q2 (OrganicGrowth disambiguation) | geographically_triggered: bool flag |
Paula's proposal is correct and complete |
Gestalt — Round 2. Written 2026-05-01.