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>
36 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Paula — Round 2: Algorithm Proposals | Concrete algorithm proposals: OrganicGrowth disambiguation mechanism, TerritorialStatus converged thresholds, name reservation data structure, five explicit spatial arrangement patterns, attractor-matching narrative plausibility constraints | workshop | active | planet-down-cascade | paula | 2 | 2026-05-01 |
Planet-Down Cascade — Round 2 (Paula)
Focus: Algorithm proposals. Everything in this document is a concrete specification — inputs, outputs, data formats, decision rules — not a position or preference. Where I have positions that may conflict with other agents' proposals, I state the conflict clearly rather than averaging.
Lead decisions absorbed (locked, not revisited):
- District mix: Burnelli-Sheldon's three-component model. Self-contained settlements — no settlement above minimum population has zero essential district types.
- Scatter (prop/decal/entity spawn): deferred.
- L3-Q1 locked: spatial grid rotation.
- L3-Q2 locked: explicit arrangement patterns.
- L4-Q1 locked: threshold-crossing cache invalidation.
- L4-Q4 locked: pre-fetch (on-entry for minimum viable).
- CL-Q1 locked: two-field prosperity model.
1. NEW-Q2 — OrganicGrowth Disambiguation: Concrete Mechanism
The problem
When the attractor-matching algorithm runs, some settlements are assigned to geographic attractors and some are not. The data model must distinguish:
- A settlement assigned to a geographic attractor (
FoundingOrientationreadable from attractor type) - A settlement placed at a synthetically-derived position (overflow, political, economic density)
- Specifically: an OrganicGrowth-archetype settlement (excess population, no attractor) vs. an AdminFacing-archetype settlement placed by political decision
My Round 1 proposal was geographically_triggered: bool. Round 2 refines this to an enum that carries more information without significant added complexity.
Proposed mechanism: AttractorAssignment enum
pub enum AttractorAssignment {
/// Settlement was assigned to a geographic feature attractor.
/// FoundingOrientation is derived directly from the attractor tag.
Geographic {
attractor_type: GeographicFeatureTag, // RiverConfluence | CoastalHarbor | ...
quality_score: f32, // 0.0–1.0, how well the settlement fits
},
/// Settlement was placed at a seed-derived position (no natural attractor available
/// or a political decision overrides geographic logic).
Synthetic {
reason: SyntheticPlacementReason,
},
}
pub enum SyntheticPlacementReason {
/// More named cities needed than geographic attractors available.
/// The settlement exists because the population quota demands it.
/// → political_archetype: OrganicGrowth, FoundingOrientation: AdminFacing
PopulationOverflow,
/// systems.db explicitly marks this body as having a politically-placed settlement
/// (a capital established away from natural advantages, a religious site, etc.)
/// → political_archetype: AdminCapital or OrganicGrowth per economic_role
/// → FoundingOrientation: AdminFacing
PoliticalDecision,
/// High economic_tier body with manufactured-settlement density exceeding natural
/// attractor count. Placed at seed-derived offset along existing road corridors.
/// → political_archetype: derived from economic_role
/// → FoundingOrientation: AdminFacing
CorpExpansion,
}
FoundingOrientation derivation from AttractorAssignment
Geographic(CoastalHarbor | RiverConfluence) → PortFacing
Geographic(ResourceConcentration) → ResourceFacing
Geographic(MountainPass | Defensible) → DefenseFacing
Geographic(ArablePlain) → AdminFacing
(agricultural plains produce administrative service hubs, not port/resource/defense cities)
Synthetic(_) → AdminFacing
Note: RailHeadFacing is currently not derivable from a geographic attractor — transit junctions are infrastructure, not terrain features. RailHeadFacing is assigned when the road/rail graph algorithm places a settlement at a road/rail network junction during Layer 2 road generation, after the initial attractor matching. This is a second-pass assignment on top of AttractorAssignment.
political_archetype derivation rules (summary)
Full derivation formula is Burnelli-Sheldon's domain. My narrative constraints:
AttractorAssignment::Geographic(ResourceConcentration) + corp_presence_count > 0
→ CompanyTown
AttractorAssignment::Geographic(CoastalHarbor | RiverConfluence) + economic_role ∈ {commercial, transit}
→ FreePort
AttractorAssignment::Geographic(Defensible | MountainPass) + economic_role ∈ {administrative}
→ AdminCapital
AttractorAssignment::Synthetic(PoliticalDecision)
→ AdminCapital (the political decision is itself an administrative act)
AttractorAssignment::Synthetic(PopulationOverflow) + no dominant corp + economic_role ∈ {service_mixed, commercial}
→ OrganicGrowth
Two settlements in ContestZone Province with competing road authority types
→ Contested (assigned to the lower-population settlement; larger settlement keeps its primary archetype)
GeneratedSettlement record fields
The GeneratedSettlement struct in BodyWorldState requires these fields to support the mechanism:
pub struct GeneratedSettlement {
pub settlement_id: u64, // stable ID for Session DB references
pub name: Option<String>, // None for unnamed (gets generated name)
pub population: u32,
pub economic_role: EconomicRole,
pub attractor_assignment: AttractorAssignment,
pub founding_orientation: FoundingOrientation, // derived from attractor_assignment
pub political_archetype: PoliticalArchetype, // derived from above + economics
pub tier: SettlementTier, // City | Town | Outpost | Waypoint | RuralCluster | Ruin
pub active: bool, // runtime: false when economic basis collapsed
pub placed_at_generation: bool, // immutable: true for all settlements placed at gen time
}
placed_at_generation is always true for settlements placed at Layer 2. At runtime, active can become false. The combination distinguishes WildernessBuffer (Province with no placed_at_generation = true settlements) from AbandonedZone (Province with at least one placed_at_generation = true settlement where all have active = false).
2. TerritorialStatus — Converged Thresholds
Points of agreement across Round 1 proposals
All three proposals (Paula / Tyre / Burnelli-Sheldon) agree on:
- WildernessBuffer = no settlements placed
- AbandonedZone = placed-at-generation settlements, none currently active
- ExtractiveZone requires ResourceConcentration attractor + corp presence + characteristic road pattern
- ContestZone requires multiple settlement clusters with competing political identity
- The distinction between CoreTerritory and FrontierTerritory is infrastructure quality, not just presence
The disagreements are primarily in how thresholds are expressed (absolute counts vs. relative density vs. economic_tier-relative ratios).
Converged proposal
For Phase 1, use absolute thresholds. Burnelli-Sheldon's economic_tier-relative ratios are more accurate but require more inputs and can be added in a subsequent pass. The priority here is an algorithm that produces TerritorialStatus correctly in the common case.
TerritorialStatus derivation — per Province, evaluated in priority order:
Input per Province:
settlements_placed_count: u32 // count of settlements with placed_at_generation=true
settlements_active_count: u32 // runtime: count with active=true
has_city_tier: bool // any settlement of SettlementTier::City
has_resource_attractor: bool // any GeneratedSettlement has Geographic(ResourceConcentration)
corp_presence_count: u32 // distinct corps with presence in Province settlements
road_edge_count: u32 // total road edges in Province
road_maintained_count: u32 // edges with MaintenanceAuthority != Abandoned
road_authority_types: Set<MaintenanceAuthority>
extraction_corridor_quality: f32 // avg quality of road edges marked Corporate; 0.0 if none
off_corridor_road_quality: f32 // avg quality of non-Corporate road edges; 0.0 if none
political_archetypes_present: Set<PoliticalArchetype> // archetypes of City/Town-tier settlements
Rule 1 — AbandonedZone (runtime-evaluated):
settlements_placed_count > 0 AND settlements_active_count == 0
Rule 2 — WildernessBuffer:
settlements_placed_count == 0
Rule 3 — ExtractiveZone:
has_resource_attractor == true
AND corp_presence_count > 0
AND extraction_corridor_quality > 0.60
AND off_corridor_road_quality < 0.35
Rule 4 — ContestZone:
settlements_active_count >= 2
AND |political_archetypes_present| >= 2
AND any two settlements in Province are of City or Town tier
Rule 5 — CoreTerritory:
settlements_active_count >= 2
AND has_city_tier == true
AND road_maintained_count / road_edge_count >= 0.65
Rule 6 — FrontierTerritory (catch-all for active settlements):
settlements_active_count >= 1
Notes on the ordering:
- AbandonedZone is checked first because it is a runtime state that overrides generation-time classification. An ExtractiveZone from which the corp has withdrawn is AbandonedZone, not still ExtractiveZone.
- WildernessBuffer before all others because it requires zero placed settlements (the simplest case).
- ExtractiveZone before ContestZone because a contested extraction zone is still ExtractiveZone in character — the extraction is the defining feature; the contest is a modifier.
- ContestZone before CoreTerritory because a well-connected contested Province reads as ContestZone rather than CoreTerritory — the political conflict is the primary narrative fact.
- FrontierTerritory as catch-all: any active settlement that doesn't qualify for a more specific status.
placed_at_generation Province flag:
pub struct ProvinceWorldState {
pub province_id: u64,
pub territorial_status: TerritorialStatus, // runtime-updated
pub placed_at_generation: bool, // immutable: set at Layer 2, never changed
// ... other Province fields
}
placed_at_generation = true for any Province that received at least one settlement at Layer 2 generation time. This flag is the only way to distinguish AbandonedZone from WildernessBuffer at runtime.
Threshold rationale
The 0.65 road maintenance fraction for CoreTerritory is conservative — in practice, maintained road infrastructure is a clear marker of settled political power. The 0.60/0.35 split for ExtractiveZone reflects the characteristic pattern of corporate road investment: excellent on the extraction corridor, negligible off it.
Province dimensions (Tyre's ~60-200km range) mean a Province can contain 1-5 settlements in most cases. The absolute settlement count thresholds (≥2 for CoreTerritory, ≥1 for FrontierTerritory) are appropriate at this scale — a single isolated city is frontier territory regardless of its internal quality.
3. Naming: Name Reservation Data Structure and Fulfillment
What the authoring layer provides
Under Amendment 3, systems.db holds:
- Named cities with economic roles, populations, and corp HQ associations
- Geographic feature names (rivers, mountain ranges, seas)
What it does NOT hold: positions. The generator must fulfill all name reservations.
Name reservation data structure
New atlas_city_names table (replaces authored position storage in atlas_cities):
CREATE TABLE IF NOT EXISTS atlas_city_names (
body_id TEXT NOT NULL,
name TEXT NOT NULL,
population INTEGER NOT NULL,
economic_role TEXT NOT NULL, -- matches EconomicRole enum
hq_for_corp TEXT, -- NULL if not a HQ; corp_id from corporations table
cultural_hint TEXT, -- optional: hints naming pool for surrounding features
PRIMARY KEY (body_id, name)
);
New atlas_feature_names table (geographic feature identity):
CREATE TABLE IF NOT EXISTS atlas_feature_names (
body_id TEXT NOT NULL,
name TEXT NOT NULL,
feature_class TEXT NOT NULL, -- 'river' | 'mountain_range' | 'ocean' | 'sea' | 'bay'
rank_hint INTEGER, -- 1 = primary/largest, 2 = secondary; NULL = seed-assigned rank
PRIMARY KEY (body_id, name)
);
Runtime NameReservation struct (built from systems.db at generation start):
pub struct NameReservation {
pub name: String,
pub reservation_kind: ReservationKind,
}
pub enum ReservationKind {
CorpHeadquarters {
corp_id: String,
economic_role: EconomicRole,
// Hard constraints on attractor type, derived from economic_role
// (see attractor-matching constraints in Section 5)
},
NamedCity {
population: u32,
economic_role: EconomicRole,
},
GeographicFeature {
feature_class: GeographicFeatureClass,
rank_hint: Option<u32>,
},
}
Fulfillment order
Stage 0 (pre-generation): Build the Vec<NameReservation> from systems.db for this body. Sort reservations:
- Corp HQ reservations (sorted by economic role constraint tightness: extraction most constrained → commercial least constrained)
- Named city reservations (sorted by population descending)
- Geographic feature reservations (rivers, then mountains, by rank_hint ascending)
Stage 1 (Layer 1, during feature tag extraction): For each named geographic feature reservation:
- Match to the generated feature of that class with the matching rank (e.g., rank_hint = 1 → largest/longest river on the body)
- If rank_hint is NULL, assign to the seed-derived Nth feature of that class (deterministic from body seed)
- Store:
(body_id, feature_name, generated_feature_id)in BodyWorldState
Stage 2 (Layer 2, during attractor matching): The attractor-matching algorithm processes settlement name reservations. For each reservation (in the sorted order from Stage 0):
- Evaluate all available attractors against the reservation's hard constraints
- Score each compatible (attractor, reservation) pair
- Assign highest-scoring attractor; mark attractor as consumed
- If no compatible attractor available: log mismatch; assign to best available ignoring hard constraints (soft placement); flag for lead review
Stage 3 (Layer 2, overflow and unnamed): After all named reservations are fulfilled:
- Remaining population quota settlements receive generated names from the cultural pool for this body
- Generated names use
cultural_hintfrom the nearest named city as the culture anchor
Mismatch handling:
A name reservation "mismatch" occurs when:
- A corp HQ reservation cannot be placed at a compatible attractor type
- A named city's economic role is incompatible with all available attractors
Mismatch action: place at best-available attractor (never fail silently), write a log entry to BodyWorldState.generation_log:
ATTRACTOR_MISMATCH: {body_id} / {reservation_name}
Expected: {compatible_attractor_types}
Placed at: {actual_attractor_type} (quality: {score:.2f})
Reason: {available_attractors_at_generation}
Action: review systems.db economic_role for {reservation_name} or add geographic feature
This log is read-only world state — it persists in the session and can be surfaced in developer tooling.
What names are generated (vs. reserved)
- All geographic features NOT in
atlas_feature_namesreceive generated names at Stage 1 - All cities NOT in
atlas_city_namesreceive generated names at Stage 3 - Sub-settlements (Outpost, Waypoint, RuralCluster) always receive generated names (they are never in systems.db as named reservations — sub-settlement names are emergent)
Generated names use the cultural pool for the body. The pool selection rule:
- Primary pool: cultural_hint from the nearest named city reservation
- Secondary pool: body's primary biome class as a fallback cultural flavor
- Tertiary pool: generic Settled Reach pool (all cultures, minimal distinctiveness)
The name pool population (content, actual strings) is Mellanie's domain. The generator only needs the pool selection rule and the pool reference.
4. Five Spatial Arrangement Patterns
These are Layer 3 algorithm specifications. Each pattern takes as input the CityGenerationContext (including founding_orientation and political_archetype) and produces district grid positions in city-local sim tiles.
All patterns must satisfy the three-component district mix constraint (Burnelli-Sheldon, locked): every settlement has guaranteed baseline district types; economic role modifies character, not presence. The spatial patterns below arrange those mandatory districts, not eliminate them.
Pattern 1: CompanyTown Spine
When: political_archetype = CompanyTown
Principle: The company is the city. The resource or facility is at one end; everything else is infrastructure for getting workers to it and back. The city has no civic center because it has no civic purpose — it exists for the company's operation.
Spine direction: Derived from founding_orientation:
ResourceFacing, North→ spine runs South-North; resource/industrial end at North edge
District placement algorithm:
1. Set spine_axis = direction from city center toward FoundingOrientation feature
2. Sort districts along spine_axis, position 0 at the resource-facing end
Mandatory district sequence (applied in order along spine):
Position 0 (resource-facing end):
DistrictType = Industrial or LogisticsHub (whichever matches economic_role closer)
prosperity_baseline = district_base - 0.10 (older, wear from proximity to operations)
Position 1:
DistrictType = Mixed (rough — worker services, basic commerce, rougher entertainment)
prosperity_baseline = district_base - 0.05
Positions 2..(N-2):
DistrictType = Residential (denser near industrial end, more spacious at far end)
prosperity_baseline = district_base + linear_gradient(position, 0.0 to +0.10)
Position N-1 (far end, away from resource):
DistrictType = Commercial or Administrative (company office, newer development)
prosperity_baseline = district_base + 0.10 (newest, planned)
Width: Spine is 1 district wide for N ≤ 4. For N > 4, spine is 2 districts wide (parallel columns aligned to spine_axis). The wider column gets Residential; the narrower gets the character types (Industrial, Mixed, Commercial).
Grid geometry: Districts are placed at:
(col=0 or col=1, row=position_index)
where col=0 is the primary spine, col=1 is the secondary spine for width > 1.
Origin: (0, 0) = resource-facing end of city.
No civic center rule: No Administrative district is placed at the center. If the three-component model requires an Administrative district (WorldTier ≥ Backwater), it goes at the far-end position (N-1), not at a central grid position.
Pattern 2: AdminCapital Radial
When: political_archetype = AdminCapital
Principle: Power radiates from the administrative center. The fortified or palatial core is at the middle or at the prestige position (high ground, commanding view). Everything else is arranged in relation to it. You know where power lives by looking at the geometry.
Center determination:
founding_orientation = DefenseFacing: Administrative district placed at geometric center of the district grid. City radiates in all directions from that center.founding_orientation = AdminFacing: Administrative district placed at one edge (the "prestige edge" — the edge facing the primary road approach into the city). City radiates inward from that edge.- Other orientations for AdminCapital: treat as
AdminFacing(the admin capital was built with a clear face toward its administered territory).
District placement algorithm (DefenseFacing radial):
For N districts forming approximately a square grid:
Find center cell (for N districts, the closest thing to a 2D grid center)
e.g., N=9 → 3×3 grid, center = (1,1)
e.g., N=5 → irregular; center = (1,1) in a + shape
Center cell: Administrative
prosperity_baseline = district_base + 0.15 (power center; oldest planned district)
Cells adjacent to center: Commercial, Entertainment, Mixed
(the districts that exist to serve the administrative core)
prosperity_baseline = district_base + 0.05
Cells in outer ring: Residential, Industrial, Logistics
prosperity_baseline = district_base - 0.05 to 0.0
Prosperity gradient: Decreases from center outward. The gradient is radial, not directional.
District placement algorithm (AdminFacing edge-facing):
Prestige edge = the edge of the district grid facing the primary road approach direction
(read from road_entry_directions in CityGenerationContext, take the direction with
highest road quality)
Prestige row (row 0, facing primary approach):
Administrative district at col floor(district_grid_width / 2)
Adjacent in prestige row: Commercial, Mixed
Middle rows:
Residential, Commercial, Entertainment
Back row (away from prestige edge):
Industrial, Logistics
Prosperity gradient: decreases from prestige row toward back row
Pattern 3: FreePort Multi-Node
When: political_archetype = FreePort
Principle: Commerce without a center. A FreePort isn't controlled; it's used. Multiple independent commerce nodes emerged at different geographic junctions (river mouth + harbor, rail junction + river crossing, etc.). Each node has its own commercial gravity. There is no civic center because no single authority built one — civic infrastructure is minimal relative to commercial infrastructure.
Node count: 2 nodes for N ≤ 6 districts. 3 nodes for N > 6.
Node positions:
Node 0: FoundingOrientation edge (primary harbor or confluence point)
Node 1: Seed-derived position [30–50%] of district_grid_width away from Node 0,
along the geographic feature axis (along coastline if PortFacing,
along river direction if river confluence)
Node 3 (if applicable): Seed-derived position approximately opposite Node 0
(the "newer commerce" node, where development extended when Node 0 saturated)
Districts per node: Divide total district count across nodes:
Node 0: ceil(N * 0.45) districts
Node 1: ceil(N * 0.35) districts
Node 2 (if present): remaining districts
Within each node:
Node center district: Commercial or Entertainment
prosperity_baseline: independent per node; Node 0 = oldest (potentially lower prosperity
if worn); Node 1 = newer (possibly higher prosperity if the city is growing that direction)
Adjacent to node center: Mixed (dense services, rough commerce)
Node periphery: Residential (for the workers who service each commerce node)
No city-wide prosperity gradient: Each node has its own local gradient (high at commerce center, decreasing outward). There is no single directional gradient across the entire city. This produces the "patchwork" character of free ports — pockets of wealth and roughness alternating based on which node you're near.
Space between nodes: Districts in the gap between nodes are assigned Mixed or Residential type. They are not associated with either node's commercial center — they are the "filler" that grew between commerce clusters.
Pattern 4: Contested Dual-Center / Overlay
When: political_archetype = Contested
Principle: Two political actors each built their version of this city, and neither succeeded in removing the other. The city has two half-grids, each internally coherent with one actor's logic, meeting at a contested boundary where the coherence breaks down.
Contested boundary derivation:
- If the Province has a NaturalBarrier tag bisecting the city footprint: use that barrier's line as the boundary.
- Otherwise: boundary = the road axis perpendicular bisector (the line perpendicular to the primary road through the city, passing through the city center).
- Boundary position is seeded to prevent exact bisection: offset by
child_seed(city_seed, CONTESTED_BOUNDARY_DISCRIMINANT) % 25 - 12percent from center.
Each half-city arrangement:
Faction A's half (the half facing FoundingOrientation direction):
Arranged according to Faction A's inferred archetype:
- If economic_role suggests CompanyTown: spine arrangement within the half
- If economic_role suggests AdminCapital: radial from the half's center
- Otherwise: ordered Commercial/Residential sequence
Faction B's half (opposite side of boundary):
Arranged according to Faction B's inferred archetype
(Faction B's archetype is always different from Faction A's — a Contested city
has two distinct political logics, not two copies of the same logic)
How Faction A vs. Faction B archetypes are determined:
- Faction A: the larger of the two corp presence clusters in the Province (by corp asset count). Faction A's archetype = the political archetype implied by Faction A corp's
economic_role. - Faction B: the smaller cluster. Faction B's archetype = implied by Faction B corp's
economic_role. - If no distinct corp clusters: Faction A = corporate (economic_role-derived), Faction B = administrative (government/independent).
Contested boundary districts:
Districts adjacent to the boundary line receive:
DistrictType = Mixed (contested zones are commercially pragmatic — both sides trade here)
perimeter_treatment = Checkpoint or Walled
(Checkpoint if both sides have active political authority; Walled if one side has retreated)
prosperity_baseline = district_base - 0.15
(contested areas are economically suppressed — no authority invests here fully)
Prosperity gradient: Increases away from the contested boundary toward each faction's center. The contested middle is the economic trough; both centers are the local prosperity peaks.
Pattern 5: OrganicGrowth Irregular
When: political_archetype = OrganicGrowth, or AttractorAssignment::Synthetic(PopulationOverflow)
Principle: No single authority planned this city. It grew around multiple small centers of gravity — a crossroads that attracted a market, a river bend where boats rested, a flat area where multiple villages merged. No single direction is "oldest." There is no civic center in the traditional sense; there are just the places people kept returning to.
District placement:
No predefined sequence. Districts are placed by local density attractors within the city footprint:
1. Identify 2-3 local attractor points within city footprint (derived from seed):
- Attractor 0: geometric center of city footprint (always present)
- Attractor 1: seed-derived offset from center, [20-40%] of district_grid_width
- Attractor 2 (for N ≥ 6): second seed-derived offset, approximately opposite Attractor 1
2. Assign each district to its nearest attractor (Voronoi partition by attractor proximity)
3. Within each attractor's cluster:
- Attractor-center district: Commercial or Mixed
- Adjacent districts: Residential, Entertainment, Commercial (random by seed)
- No fixed ordering
4. Apply jitter to district grid positions:
Each district position is offset by seed-derived jitter:
jitter_x = child_seed(district_seed, JITTER_X) % (DISTRICT_TILE_WIDTH / 4) - (DISTRICT_TILE_WIDTH / 8)
jitter_y = child_seed(district_seed, JITTER_Y) % (DISTRICT_TILE_WIDTH / 4) - (DISTRICT_TILE_WIDTH / 8)
(Jitter is ±12.5% of district width — enough to break grid regularity without district overlap)
Prosperity gradient: No city-wide gradient. Each attractor-cluster has an independent local gradient (higher at cluster center, decreasing outward). The overall effect is a prosperity map with multiple peaks and valleys — not a directional slope.
Why jitter instead of full irregular placement: Full irregular placement requires collision detection and complex position assignment. Jitter on a regular grid produces visible irregularity with O(1) computation. The player experiences the city as "not planned"; the implementation is still grid-based. This is an approximation that is improved post-Phase 1 if needed.
Three-component model compatibility across all patterns
Burnelli-Sheldon's three components (population tier guarantees, economic role multipliers, settlement age character) apply within each pattern:
- Population tier guarantees ensure that even a CompanyTown spine has its mandatory Residential, Commercial, and Entertainment districts. The spine layout sequence above assigns these; the guarantee prevents their count from going to zero.
- Economic role multipliers change the CHARACTER of the guaranteed districts. A CompanyTown's "Commercial" district is rougher and smaller than a FreePort's. The spatial pattern places it; the economic multiplier determines what kind.
- Settlement age character modifies district quality without affecting spatial arrangement. Applied per district as a character modifier after spatial placement.
5. Attractor-Matching Narrative Plausibility Constraints
The attractor-matching algorithm (Tyre owns the implementation; Burnelli-Sheldon owns the scoring matrix) needs narrative plausibility constraints to prevent geographic nonsense. These are scoring rules from the narrative/political domain.
Hard constraints (score → 0.0 if violated; settlement cannot be placed here)
H1 — Port-land incompatibility:
CoastalHarbor or RiverConfluence attractor cannot be assigned to a settlement with economic_role = mining or economic_role = extraction UNLESS the body has coastal mineral deposits (flagged by has_coastal_extraction in systems.db, if that field exists, or implied by extraction corp with location_id at the coastal body).
- Rationale: A mining town doesn't sit at a harbor without a reason to ship ore from that harbor. If it ships ore, it's a port-mining hybrid and the constraint relaxes.
H2 — Research isolation:
A corp HQ settlement with economic_role = research cannot be assigned to CoastalHarbor unless the body's planet_class includes ocean/aquatic research biome.
- Rationale: Research facilities in established SF settings are usually built away from commercial centers for noise isolation and security. A research HQ at a busy commercial harbor is unusual without a specific scientific rationale.
H3 — Administrative terrain:
A corp HQ settlement with primary administrative operation (economic_role ∈ {service_mixed} at high economic_tier, or a government entity) cannot be assigned to MountainPass as primary attractor.
- Rationale: Administrative power centers avoid terrain that is difficult to reach, maintain, and project from. Passes are chokepoints; they attract checkpoint authorities, not administrative capitals.
H4 — Agricultural plains city size:
ArablePlain attractor can only be assigned to settlements with population ≤ 100,000 OR if the settlement is the body's primary food production hub (implied by economic_role = agricultural).
- Rationale: A megacity on an agricultural plain has overwhelmed the plain with urban development — the plain is no longer the founding rationale, it's just flat terrain. Large cities at ArablePlain attractors in reality became cities for other reasons after outgrowing their agricultural origin.
Soft constraints (scoring penalties applied to the compatibility matrix)
These modify Burnelli-Sheldon's scoring matrix with narrative-derived weights. They are additive to (or multiplicative against) the economic compatibility scores.
| Settlement economic_role | Attractor to avoid | Narrative penalty |
|---|---|---|
agricultural |
ResourceConcentration, MountainPass |
-0.40 |
commercial |
ResourceConcentration, Defensible |
-0.25 |
manufacturing |
MountainPass, Defensible |
-0.20 |
frontier |
RiverConfluence, CoastalHarbor |
-0.10 (frontier implies isolation; established trade nodes are not frontier) |
research |
RiverConfluence (as primary) |
-0.15 (research at river confluences is possible but unusual) |
energy |
ArablePlain (if no resource) |
-0.30 (energy facilities need a resource to process, not farmland) |
| Settlement economic_role | Preferred attractor | Narrative bonus |
|---|---|---|
extraction, mining |
ResourceConcentration |
+0.50 (strong positive signal) |
agricultural |
ArablePlain |
+0.40 |
transit, commercial |
RiverConfluence, CoastalHarbor |
+0.35 |
frontier |
MountainPass, ResourceConcentration |
+0.25 |
research |
Defensible, ArablePlain (quiet) |
+0.15 |
manufacturing |
CoastalHarbor, ArablePlain |
+0.20 |
Mismatch detection and flagging
A placement with final compatibility score < 0.35 (after hard constraints verified and soft constraints applied) is flagged as a mismatch:
Log entry in BodyWorldState.generation_log:
ATTRACTOR_MISMATCH:
body: {body_id}
settlement: {name}
placed_at: {attractor_type} (quality score: {score:.2f})
economic_role: {economic_role}
available_compatible_attractors: {count} (were there any? if 0, the body lacks this attractor type)
recommendation: see ATTRACTOR_MISMATCH resolution guide
Mismatch does NOT prevent generation. The city is placed at the best available attractor. The log exists so developers can identify bodies where systems.db economic_role assignments are implausible relative to generated terrain.
The geographic common sense rule (from Ozzie)
Beyond scoring, one categorical rule applies: a settlement's primary economic function must be physically possible at its placed attractor. This is not a score — it is a binary check:
fn is_physically_possible(role: EconomicRole, attractor: GeographicFeatureTag) -> bool {
match (role, attractor) {
// Explicit impossibilities
(EconomicRole::Agricultural, GeographicFeatureTag::MountainPass) => false,
(EconomicRole::Extraction | EconomicRole::Mining, GeographicFeatureTag::ArablePlain)
if !body.has_subsurface_resources => false,
// Everything else: physically possible even if implausible
_ => true,
}
}
If is_physically_possible returns false, the assignment is treated as a hard constraint violation (score → 0.0). This is stricter than H1-H3 above; it catches cases the scoring matrix might not cover.
6. Interaction Notes for Other Agents
For Tyre: The AttractorAssignment enum (Section 1) needs to be integrated into the GeneratedSettlement struct. The placed_at_generation field on ProvinceWorldState is required for AbandonedZone/WildernessBuffer distinction at runtime. The name reservation tables (atlas_city_names, atlas_feature_names) are schema changes — these replace the authored position storage in atlas_cities and add feature names.
For Burnelli-Sheldon: My soft constraint table (Section 5) is proposed as additive/multiplicative to your compatibility matrix. Please verify these don't conflict with your comparative advantage matching formula — specifically, my -0.40 for agricultural at MountainPass should not create cases where a body has no valid attractor for an agricultural city. If that's possible (all-mountain body with an agricultural economic_role), define the fallback behavior.
For Gestalt: The Contested dual-center pattern (Section 4, Pattern 4) uses Faction A vs. Faction B archetype inference. This depends on corp cluster analysis during Layer 2, which I've sketched but which needs a concrete algorithm. Does your mechanic design for Layer 2 include corp clustering, or should I specify a simpler faction inference rule?
Paula — Round 2. Written 2026-05-01.