Files
settled-reach/docs/workshops/generator-architecture/tyre-round2.md
T
jpmschweitzer 23d9ff0a58 Merge remote-tracking branch 'origin/main' into planning
# Conflicts:
#	CHANGELOG.md
#	content/_meta/README.md
#	content/_meta/npc-authoring-style-guide.md
#	wiki/_templates/cultural-group.md
#	wiki/_templates/institution.md
#	wiki/_templates/star-system.md
#	wiki/characters/devra.md
#	wiki/characters/drin.md
#	wiki/characters/harek.md
#	wiki/characters/lera-sessik.md
#	wiki/characters/maret-korr.md
#	wiki/characters/naia-tamm.md
#	wiki/characters/nils-davan.md
#	wiki/characters/pell.md
#	wiki/characters/renn.md
#	wiki/characters/resha.md
#	wiki/characters/sabel.md
#	wiki/characters/sera-venn.md
#	wiki/characters/torek-lintar.md
#	wiki/characters/voss.md
#	wiki/star-systems/krenn/index.md
2026-03-14 00:24:53 +01:00

36 KiB
Raw Blame History

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Tyre Round 2 — Two-Phase Pipeline Two-phase generation architecture with edge bleed and playstyle-agnostic spatial guarantees workshop archived generator-architecture tyre 2 2026-02-27

Round 2: Tyre — Technical Pipeline with Two-Phase Generation and Edge Bleed

Workshop: Generator Architecture (#562) Agent: Tyre (Technical Architect) Date: 2026-02-27

Lead directive acknowledged: This is NOT a detective game. The generator must support tycoon, dating sim, political drama, and investigation playstyles equally. The DistrictSkeleton and all spatial guarantees are playstyle-agnostic. The architecture bakes in information asymmetry as a spatial property, not investigation as a gameplay assumption.


1. Two-Phase Generation Architecture

The lead directive splits generation into two architecturally separate phases. cracks knuckles — this is actually elegant, because it maps cleanly onto two different computational profiles.

1.1 Phase 1: World Prep (Background, Async)

Runs on a spare CPU core while the player is playing. Produces the skeleton layer — everything above chunk fill. This is the "what goes where" pass.

┌──────────────────────────────────────────────────────────┐
│  PHASE 1: WORLD PREP (background thread, ~50-500ms/district)  │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  Master Seed                                             │
│    ↓                                                     │
│  System Generation (star type, worlds, stations)         │
│    ↓                                                     │
│  Society Profile per world (ingredients → parameters)    │
│    ↓                                                     │
│  District Skeletons per world (zoning, social sites,     │
│    access topology, NPC slots, reservations,             │
│    corridor spines, zone palettes)                       │
│    ↓                                                     │
│  Block Planning per district (ChunkLayout, edge          │
│    contracts, era tags, quarter assignments)              │
│    ↓                                                     │
│  NPC Population per district (role assignment,           │
│    triangle seeding, entanglement marking)                │
│    ↓                                                     │
│  OUTPUT: PreparedDistrict (skeleton + block plans +      │
│    NPC roster — everything except tile data)              │
│                                                          │
└──────────────────────────────────────────────────────────┘

Characteristics:

  • CPU-bound, no I/O. Pure deterministic computation from seed.
  • Can run speculatively for districts the player hasn't visited yet.
  • Output is small (~10-50 KB per district). All PreparedDistricts for a 300-world game fit in ~30-150 MB.
  • No rendering dependency. No Godot interaction. Pure Rust.
  • Scheduling: Prepare the player's home system at game start (blocking). Queue neighboring systems by gate distance. Prepare on-demand when the player books travel.

Timing budget: Phase 1 for one district: ~50-500ms (dominated by NPC population generation). One full world (6 districts): ~300ms-3s. Entire 300-world galaxy: ~90-900s (1.5-15 minutes). At game start, only the home system is blocking (~2-3s); everything else runs in background.

1.2 Phase 2: Local Area Gen (On-Demand, Interactive)

Runs when the player enters a district for the first time, triggered by chunk loading. Produces tile data — the actual playable space.

┌──────────────────────────────────────────────────────────┐
│  PHASE 2: LOCAL AREA GEN (on-demand, ~100-500ms/chunk)   │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  PreparedDistrict (from Phase 1)                         │
│    ↓                                                     │
│  Chunk Fill (per chunk, on entering loading radius)      │
│    - Read BlockSkeleton + edge contracts                 │
│    - Select template from social site tag                │
│    - Place walls, floors, furniture, fixtures             │
│    - Apply zone palette + era materials                  │
│    - Place NPC spawn points from roster                  │
│    - Validate edge contracts against neighbors           │
│    ↓                                                     │
│  OUTPUT: ChunkData (64×64 tile array, ready to stream)   │
│                                                          │
│  Chunk Cache (LRU, persists to save file)                │
│    - Generated chunks cached in memory                   │
│    - Written to save on save-game                        │
│    - Loaded from save on load-game (skips re-gen)        │
│                                                          │
└──────────────────────────────────────────────────────────┘

Characteristics:

  • Runs on the simulation thread (or a dedicated gen thread with result handoff).
  • Template-based stamping — NOT full WFC. WFC is a future optimization if templates prove insufficient.
  • Each chunk fill reads only its own BlockSkeleton + neighbor edge contracts. No global state dependency.
  • Idempotent from seed: Same PreparedDistrict + same chunk coordinates → same ChunkData. Always.
  • Once generated, chunks are cached and never regenerated (unless the save file is wiped).

Timing budget per chunk: ~100-500ms. Player walk speed = 1 tile/200ms, crossing a chunk takes ~12.8s. New chunks enter the 3×3 loading grid every ~6-12s. Budget is generous.

1.3 The Interface Between Phases

The PreparedDistrict is the contract between Phase 1 and Phase 2. It is the only data structure that crosses the boundary. Phase 2 never calls Phase 1 functions. Phase 1 never produces tile data.

/// The contract between Phase 1 (world prep) and Phase 2 (local gen).
/// Serializable, cacheable, deterministic from seed.
struct PreparedDistrict {
    skeleton: DistrictSkeleton,     // spatial plan (§2 below)
    block_plans: [[BlockPlan; 4]; 4],  // per-block fill instructions
    npc_roster: NpcRoster,          // generated NPCs with role assignments
    seed_chain: SeedChain,          // derived seeds for Phase 2 determinism
}

struct BlockPlan {
    skeleton: BlockSkeleton,        // from Phase 1
    chunk_fills: [[ChunkFillSpec; 2]; 2],  // per-chunk fill instructions
    edge_contracts: BlockEdgeContracts,    // connection points on all 4 faces
}

struct ChunkFillSpec {
    /// Template tag to instantiate (e.g., "logistics_hub_main_floor")
    template_tag: String,
    /// Quarter layout within this chunk
    quarter_layout: QuarterLayout,
    /// Derived seed for this specific chunk's procedural details
    chunk_seed: u64,
    /// Zone palette inherited from district
    zone_id: ZoneId,
    /// Era tag inherited from block
    era: Era,
    /// NPC spawn points assigned from roster
    npc_spawns: Vec<NpcSpawnPoint>,
    /// Access tier for this chunk's primary zone
    access_tier: AccessTier,
}

2. Updated DistrictSkeleton with Edge Bleed

The lead directive is clear: the 4×4 block grid must NOT be perceptible. Districts must bleed into each other at boundaries.

2.1 The Edge Bleed Problem

D-094 defines a district as 512×512 sim tiles (4×4 blocks). If two adjacent districts have hard boundaries — Gate Cluster ends at block (3,y) and Residential starts at block (0,y) — the player walks through a visual seam. That seam screams "procedural grid."

2.2 Solution: Shared Boundary Blocks

At district boundaries, adjacent districts share a transition strip — a row of blocks that belongs to neither district exclusively. These blocks blend the zone palettes, era tags, and building character of both districts.

District A                    District B
┌────┬────┬────┬────┐        ┌────┬────┬────┬────┐
│ A  │ A  │ A  │ A  │        │ B  │ B  │ B  │ B  │
├────┼────┼────┼────┤        ├────┼────┼────┼────┤
│ A  │ A  │ A  │ A  │        │ B  │ B  │ B  │ B  │
├────┼────┼────┼────┤        ├────┼────┼────┼────┤
│ A  │ A  │ A  │ A  │        │ B  │ B  │ B  │ B  │
├────┼────┼────┼────┤        ├────┼────┼────┼────┤
│ A  │ A  │ Aₜ │ Aₜ │←─ SHARED ─→│ Bₜ │ Bₜ │ B  │ B  │
└────┴────┴────┴────┘        └────┴────┴────┴────┘

Aₜ/Bₜ = transition blocks. Visually: A's palette → neutral → B's palette.

How it works:

The outermost column/row of each district is designated as the transition strip. Transition blocks:

  • Use a blended zone palette (weighted average of both districts' adjacent zone palettes)
  • Can have mixed era tags (one chunk from District A's era, one from District B's)
  • Use a specific "transition corridor" street type (per V-05: 6vt width, neutral industrial palette)
  • Building footprints in transition blocks are smaller (no 2×2 full-merge buildings) to avoid buildings that feel like they belong to one district or the other
  • Social sites are NOT placed in transition blocks — they are pass-through zones, not destinations

2.3 Updated DistrictSkeleton Struct

struct DistrictSkeleton {
    district_id: DistrictId,
    seed: u64,
    district_type: DistrictType,
    context: DistrictContext,

    /// The 4×4 block grid — interior blocks
    blocks: [[BlockSkeleton; 4]; 4],

    /// NEW: Boundary descriptors for edge bleed
    /// Each edge (N/S/E/W) describes what this district offers
    /// to the shared transition strip with its neighbor
    boundaries: DistrictBoundaries,

    social_sites: Vec<SocialSitePlacement>,
    reservations: Vec<MultiBlockReservation>,
    access_points: Vec<AccessPoint>,
    corridors: Vec<CorridorSpine>,
    z_levels: u8,
    zone_palette: Vec<ZoneDefinition>,

    /// NEW: Society profile reference (serde-compatible, §4)
    society_profile: SocietyProfileRef,

    /// NEW: Terrain type for non-urban districts (§5)
    terrain: TerrainType,

    /// NEW: District complexity tier (§6)
    complexity: ComplexityTier,
}

struct DistrictBoundaries {
    /// For each of the 4 edges, describe the transition interface
    north: Option<BoundaryEdge>,
    south: Option<BoundaryEdge>,
    east: Option<BoundaryEdge>,
    west: Option<BoundaryEdge>,
}

struct BoundaryEdge {
    /// Zone palette at this district's boundary edge
    edge_palette: ZonePalette,

    /// Era tag at the boundary
    edge_era: Era,

    /// Access points that open onto the boundary (doors, corridors)
    /// These must align with the neighbor's corresponding access points
    access_points: Vec<BoundaryAccessPoint>,

    /// Terrain type at the boundary (for non-urban transitions)
    edge_terrain: TerrainType,

    /// Building density at boundary (always lower than interior)
    edge_density: f32,  // 0.0-1.0, typically 0.3-0.5 for transition zones
}

struct BoundaryAccessPoint {
    /// Position along the edge (0-3 for 4 blocks on this edge)
    block_index: u8,

    /// Offset within the block (in chunks: 0 or 1)
    chunk_offset: u8,

    /// Width in visual tiles
    width_vt: u8,

    /// Access tier
    access_tier: AccessTier,

    /// What kind of connection (street, corridor, service, restricted)
    connection_type: ConnectionType,
}

2.4 Transition Block Generation

Transition blocks are generated in Phase 1 as a joint operation between two adjacent PreparedDistricts. The algorithm:

  1. District A and District B are both Phase 1 complete.
  2. For each shared edge, compute the transition strip:
    • Read A's boundaries.east and B's boundaries.west (or whichever edge pair).
    • Align access points: match A's boundary access points with B's. Where both districts offer a corridor, connect them. Where only one does, dead-end the other gracefully (service door, maintenance hatch).
    • Blend zone palettes: transition blocks use lerp(A.edge_palette, B.edge_palette, 0.5) with rounding to nearest palette stop.
    • Select era: use the older of the two boundary eras (transitions feel like infrastructure, not new construction).
    • Generate transition block skeletons with the blended parameters.
  3. Store transition blocks in a TransitionStrip struct shared between both PreparedDistricts.
struct TransitionStrip {
    /// Which two districts this strip connects
    district_a: DistrictId,
    district_b: DistrictId,

    /// Shared edge (from A's perspective)
    edge: CardinalDirection,

    /// Transition blocks (1×4 strip = 4 blocks between the districts)
    blocks: [TransitionBlock; 4],
}

struct TransitionBlock {
    /// Blended palette
    palette: ZonePalette,
    era: Era,
    /// Simplified chunk layout (no large merges, mostly corridors)
    chunks: [[ChunkFillSpec; 2]; 2],
    /// Access points connecting to each district
    connections_a: Vec<BoundaryAccessPoint>,
    connections_b: Vec<BoundaryAccessPoint>,
}

Memory cost: 4 transition blocks per shared edge × ~1 KB each = ~4 KB per edge. A station with 6 districts has ~10 shared edges = ~40 KB of transition data. Trivial.

Visual result: Walking from the Terminal district into the Residential Core, the player crosses 2-3 blocks of gradual transition — neutral corridor widening, palette shifting, era mixing, building scale changing. No seam. No grid visible.


3. Seed Propagation — Single Master Seed with Deterministic Derivation

The lead says "seeds are solved." Good. Here's the architecture.

3.1 Seed Derivation Tree

One master seed. Everything else is deterministically derived. No per-stage seeds as independent parameters.

/// Single master seed → everything.
struct SeedChain {
    master: u64,
}

impl SeedChain {
    /// Derive a sub-seed for a specific purpose.
    /// Uses a keyed hash: blake3(master || domain_tag || index)
    fn derive(&self, domain: &str, index: u64) -> u64 {
        let mut hasher = blake3::Hasher::new();
        hasher.update(&self.master.to_le_bytes());
        hasher.update(domain.as_bytes());
        hasher.update(&index.to_le_bytes());
        let hash = hasher.finalize();
        u64::from_le_bytes(hash.as_bytes()[..8].try_into().unwrap())
    }

    fn system_seed(&self, system_id: u64) -> u64 {
        self.derive("system", system_id)
    }

    fn district_seed(&self, system_id: u64, district_id: u64) -> u64 {
        self.derive("district", system_id * 10000 + district_id)
    }

    fn npc_seed(&self, district_seed: u64, npc_index: u64) -> u64 {
        self.derive("npc", district_seed.wrapping_mul(1000) + npc_index)
    }

    fn chunk_seed(&self, district_seed: u64, chunk_x: u64, chunk_y: u64) -> u64 {
        self.derive("chunk", district_seed ^ (chunk_x << 16) ^ chunk_y)
    }
}

3.2 Answering Nigel's Question

"Same seed, different character selection — same world or different world?"

Same world. The master seed determines the physical world, NPC roster, triangle configurations, entanglement pattern — everything generated. Character selection is a filter, not a world-generation input. Both characters exist in the same generated world. The player picks which lens to view it through.

This is architecturally correct per D-010 principle 3: "no baking player identity into the game loop." The simulation doesn't know which character is player-controlled. Character selection happens at the session layer, not the generation layer.

Consequence: Two players with the same seed but different character choices play in an identical world. Their experiences differ because information boundaries (D-010 principle 2) filter what each character can see, access, and know. This is exactly the D-027 "two keyholes on the same world" promise.

3.3 Seed-State Artifact (Q-030)

The seed state is a single file recording all derivation inputs:

# seed-state.yaml — complete reproduction record
master_seed: 0xA7B3F1D2E5C84096
character: smuggler  # session layer, not generation layer
tier1_module_draws: [smuggling_ring, corporate_espionage]  # pool draws from master seed
home_system: van-maanens-star
home_district: transit
# Everything else is deterministically derivable from master_seed.
# This file exists for debugging and replay, not as a generation input.

4. Society Profile as Serde Schema (OQ-5)

Miri asks: can the content pipeline consume the society profile YAML as a serde-compatible schema?

Yes. Feasible. Not even challenging. Here's what the Rust struct looks like:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
struct SocietyProfile {
    heritage: HeritageBlend,
    settlement_motivation: Option<SettlementMotivation>,
    economic_function: EconomicFunction,
    economic_pressure: Vec<EconomicPressure>,  // 0-2 items
    drift_stage: DriftStage,
    faction_presence: FactionPresence,
    philosophical_alignment: Option<PhilosophicalAlignment>,
    meridian_coverage: MeridianCoverage,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
struct HeritageBlend {
    /// 1-3 roots with blend weights summing to 1.0
    roots: Vec<HeritageEntry>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
struct HeritageEntry {
    root: HeritageRoot,
    weight: f32,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
enum HeritageRoot {
    Frost, Tide, Iron, Spice, Jade,
    Dust, Vine, Salt, Stone, Arc,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
enum DriftStage {
    Pioneer,       // 0-50yr
    Crystallizing, // 50-150yr
    Mature,        // 150-300yr
    Ancient,       // 300+yr
}

#[derive(Serialize, Deserialize, Clone, Debug)]
struct FactionPresence {
    commission: PresenceTier,
    concord: PresenceTier,
    syndic: PresenceTier,
    independent: PresenceTier,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
enum PresenceTier {
    Comprehensive, Standard, Intermittent, Absent,
}

// ... remaining enums follow the same pattern

serde_yaml handles this out of the box. Miri's YAML format maps 1:1 to Rust structs. NULL values → Option<T> with serde default. Blend weights → Vec<HeritageEntry> with a validation pass to ensure sum ≈ 1.0.

Validation: Add a validate() method that checks:

  • Heritage weights sum to 1.0 (±0.01 tolerance)
  • At least 1 heritage root
  • At most 3 heritage roots
  • Economic pressure has 0-2 entries
  • No contradictory faction presence (e.g., commission: Comprehensive + independent: SystemWide)

Integration: Society profiles can be:

  1. Hand-authored in YAML (for specific systems like Van Maanen's Star)
  2. Generated from seed (for the other 299 systems)
  3. Loaded via serde_yaml and passed to the generation pipeline

Effort estimate: ~1 developer-day to define all enum types and validation. The serde derive macros do the rest.


5. Era Fields in Chunk Data (OQ-6)

Miri asks: does the chunk data structure have era fields?

Yes. At the block level, inherited by chunks.

struct BlockSkeleton {
    position: (u8, u8),
    zoning: ZoningType,
    reservation: Option<ReservationId>,
    chunk_layout: ChunkLayout,
    hosted_sites: Vec<SocialSiteId>,

    /// NEW: Construction era for this block
    era: Era,

    /// NEW: Era modifications (retrofits, additions)
    /// A block can have a base era + modification overlays
    era_modifications: Vec<EraModification>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
enum Era {
    /// Original construction. Lowest Meridian coverage.
    /// Maintenance corridors, foundation infrastructure.
    Era1,

    /// First major retrofit/expansion. Mixed coverage.
    /// Operational spaces, working infrastructure.
    Era2,

    /// Recent construction. Highest coverage.
    /// Institutional, commercial, modern residential.
    Era3,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
struct EraModification {
    /// Which era this modification represents
    era: Era,

    /// What fraction of the block shows this modification (0.0-1.0)
    coverage: f32,

    /// Type of modification
    mod_type: ModificationType,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
enum ModificationType {
    /// Surface-mounted conduits, junction boxes (Era 2 on Era 1)
    SurfaceRetrofit,
    /// New partition walls, converted spaces (Era 3 on Era 1/2)
    InternalConversion,
    /// Extension/addition changing building footprint
    StructuralAddition,
    /// Commission-grade infrastructure upgrade
    InstitutionalUpgrade,
}

How it flows:

  1. Phase 1 assigns era per block based on district context + z-level + historical events.
  2. Phase 1 assigns era_modifications for blocks that have been retrofitted.
  3. Phase 2 (chunk fill) reads the block's era + modifications and selects materials accordingly.
  4. Araminta's visual rules apply: base palette from era, modifications as overlay elements.

Z-level correlation (from D-093): The default station pattern is z=0 → Era 1, z=1 → Era 2, z=2 → Era 3. But this isn't mandatory — a recently rebuilt ground level could be Era 3, with an old observation deck as Era 1. The generator decides per-block, not per-z-level.


6. Population/Zoning Ordering (OQ-1)

Gestalt raises: NPC secrets must have plausible staging grounds. Population can't be assigned before spaces exist. But spaces need population targets to be correctly sized.

Implementation cost of the feedback loop: LOW. Here's why.

6.1 The Two-Pass Solution

This isn't a feedback loop — it's a two-pass pipeline where each pass produces a different artifact:

Pass 1 (in Phase 1, district skeleton stage):

  • Zoning assigns block types.
  • Population targets are set from capacity formulas (population density × block count × block type multiplier).
  • NPC role slots are allocated to social sites (e.g., "this logistics hub needs 1 supervisor, 3 dock workers, 2 customs handlers").
  • Triangle templates are selected (e.g., "workplace rivalry triangle in logistics hub" + "social tension triangle across bar and logistics hub").
  • Secret type requirements are checked: "this triangle requires a restricted-access staging ground" → verify at least one block has access_tier: restricted. If not, add one.

Pass 2 (in Phase 1, NPC population stage):

  • NPC 10-axis generation fills the role slots with concrete NPCs.
  • Secrets are assigned to NPCs with spatial anchoring to specific blocks/chunks.
  • Entanglement marking (which NPCs are in the 20%) is applied.
  • The NPC roster is complete.

Key insight: Pass 1 checks spatial prerequisites. It doesn't generate NPCs — it verifies that the spaces NPC secrets will need exist in the skeleton. If a triangle template requires a restricted zone and none exists, the skeleton adjusts its zoning (adds a restricted block) before NPC generation runs. This is a validation-and-adjust step, not a true feedback loop.

Implementation cost: One validate_spatial_prerequisites() function that runs after zoning, before NPC population. Checks ~10 spatial requirements (each gameplay guarantee from Gestalt's Round 1) against the skeleton. Adjusts zoning for any unmet requirement. ~200 lines of Rust. Half a developer-day.

6.2 Why This Isn't Expensive

The prerequisites are finite and small:

  • At least 1 block with access_tier: Restricted (for secrets requiring private space)
  • At least 1 block with meridian_coverage: Degraded (for grey economy activity)
  • At least 1 social site with access_tier: Public (for social manipulation)
  • At least 1 social site with access_tier: Insider (for asymmetric access)
  • At least 1 corridor spine connecting transit node to social sites (for routine observation)

These are Gestalt's guarantees expressed as spatial validators. The zoning pass produces them naturally 95% of the time. The validator catches edge cases and adjusts the remaining 5%.


7. Non-Urban Terrain: Farmland, Wilderness, Ocean, Secluded Towns

The lead directive pushes beyond population hubs. Let me be honest about what this means technically.

7.1 What Changes

Non-urban terrain changes chunk fill content, not the hierarchy structure. Chunks are still 64×64 sim tiles. Blocks are still 2×2 chunks. Districts are still 4×4 blocks. The spatial hierarchy is terrain-agnostic.

What changes:

Property Urban Non-Urban
Fill density 60-100% of quarters filled with structures 0-20% filled; rest is terrain
Template type Buildings, corridors, rooms Terrain features (fields, trees, water, paths)
NPC density 30-80 per district 0-10 per district
Social sites 3-8 per district 0-2 per district
Edge contracts Door/corridor connections Path/road connections
LOS anchors Walls, pillars, furniture Trees, terrain elevation, fences, hedgerows
Zone palette Architectural materials Natural materials (soil, grass, water, rock)

7.2 TerrainType Enum

#[derive(Serialize, Deserialize, Clone, Debug)]
enum TerrainType {
    /// Station interior (current default)
    Station,

    /// Urban settlement (planet-side city)
    Urban,

    /// Agricultural (farmland, orchards, greenhouses)
    Agricultural,

    /// Wilderness (forest, grassland, desert, tundra)
    Wilderness { biome: Biome },

    /// Water (ocean, lake, river delta)
    Water { water_type: WaterType },

    /// Transitional (urban edge, suburbs, outskirts)
    Transitional,

    /// Orbital (small installation, different geometry rules)
    Orbital,
}

7.3 How Non-Urban Chunks Fill

Non-urban chunk fill uses terrain templates instead of building templates:

  • Agricultural: Grid of field plots (each plot = 1-2 quarters), irrigation channels as corridors, farmhouse/barn as the 1-2 buildings per district. Edge contracts carry road/path connections. A farm district is mostly open space with sparse LOS anchors (fences, crop height variation, equipment sheds).

  • Wilderness: Procedural terrain with natural LOS blockers (trees, rock formations, elevation). Paths replace corridors. No buildings unless the district has a SecludedSettlement social site. Edge contracts carry trail connections.

  • Water: Mostly impassable tiles. Docks/jetties as narrow accessible strips. Boats as mobile platforms. Edge contracts carry dock access points.

  • Transitional: Sparse urban. Wide roads, scattered buildings, open lots. The "suburb" between a city district and farmland. This is where edge bleed naturally produces a transition from urban density to rural openness.

7.4 What Stays the Same

  • Chunk size: 64×64 sim tiles. Still the streaming atom. A field is just a chunk full of crop tiles instead of floor tiles.
  • Block planning: Still 2×2 chunks. The "block" in farmland means "which field plot goes where" instead of "which building footprint goes where."
  • District skeleton: Still describes what's in the district. A wilderness district skeleton has fewer social sites (maybe 1 — a ranger station or hermit cabin) and more terrain descriptors.
  • Edge contracts: Still define how chunks connect at boundaries. Roads connect instead of corridors.
  • Shadowcasting: Still works. Trees and terrain features occlude LOS just like walls.

7.5 Insignificant Places

The lead directive explicitly requires "boring" districts — low-complexity, low-NPC, pass-through zones.

#[derive(Serialize, Deserialize, Clone, Debug)]
enum ComplexityTier {
    /// Full gameplay district — multiple social sites, rich NPC population,
    /// all gameplay guarantees met. (Transit District, Residential Core)
    Full,

    /// Moderate — 1-2 social sites, moderate NPC population, partial
    /// gameplay guarantees. (Commercial Quarter, Industrial Sector)
    Moderate,

    /// Minimal — 0-1 social sites, sparse NPCs, pass-through zone.
    /// No gameplay guarantees required. (Farmland, wilderness, transit corridor)
    Minimal,

    /// Empty — no social sites, no NPCs. Pure terrain.
    /// (Open water, deep wilderness, uninhabited terrain)
    Empty,
}

Gameplay guarantees (Gestalt's 7 from Round 1) only apply to Full complexity districts. A farmland district doesn't need a surveillance chokepoint or three investigation paths. It needs to exist, be traversable, and feel appropriate to its terrain type.

Why this matters for performance: Minimal/Empty districts are trivially cheap. Their skeletons are tiny (~500 bytes). Their chunk fill is fast (terrain stamping, no NPC placement, no social site layout). The generator can produce hundreds of these as background filler for a planet-side world without meaningful CPU cost.


8. Addressing Remaining Open Questions

8.1 Quarter Fill Social Consequences (OQ-3)

Ozzie asks: does the quarter fill type have downstream social consequences?

Yes, but through an indirect mechanism. The quarter fill type is selected based on society profile + economic tier, which are the same parameters that drive NPC generation. A "market stall" quarter appears in districts with economic_function: Mixed or economic_pressure: [tight-margin], which also produces NPCs with specific behavioral patterns (transaction-oriented trust models, informal economy participation).

The quarter fill doesn't cause NPC behavior. Both the quarter fill and the NPC behavior are caused by the same upstream parameters. The player sees correlation (market stalls → certain NPC types) and reads it as causation. That's architecturally correct — the relationship is real, just indirect.

Implementation: The quarter fill tag feeds into the NPC roster's spawn_location_preference field. NPCs generated with "informal economy" traits prefer to spawn near market stall quarters. This creates the spatial correlation Ozzie wants without a direct quarter → NPC dependency.

8.2 Historical Palimpsest (OQ-4)

Ozzie asks: when the generator produces an L-shaped building, does it record WHY?

The generator records the causal chain, but the player discovers the reason through gameplay, not data inspection.

The EraModification system (§5 above) encodes the cause: an L-shaped building has mod_type: StructuralAddition with an era tag indicating when the addition was built. The NPC roster can include NPCs who remember the change ("They added that wing after the dock expansion. Took our courtyard.").

What the generator does NOT do: generate a text explanation for every spatial anomaly. The anomalies come from the era/modification system; the explanations come from the NPC knowledge system and environmental text. This is the correct separation of concerns — the generator builds the space; the content systems make it legible.

8.3 Empty Quarter Taxonomy Reconciliation (OQ-8)

Nigel's categories (informal economy, settlement, economic stress, faction presence) and Araminta's categories (plaza, service alley, courtyard, vehicle staging, structural gap) are orthogonal axes, not conflicting taxonomies.

Araminta's types describe physical form (what the space looks like). Nigel's describe social function (what the space means). A market stall (Nigel: informal economy) is physically a service alley (Araminta) with vendor cart furniture. A personal shrine (Nigel: settlement indicator) is physically a courtyard (Araminta) with shrine furniture.

struct QuarterFill {
    /// Physical form (Araminta's taxonomy)
    form: QuarterForm,

    /// Social function (Nigel's taxonomy)
    function: QuarterFunction,

    /// Furniture/object set selected from form × function
    furnishing_tag: String,
}

enum QuarterForm {
    Plaza, ServiceAlley, Courtyard, VehicleStaging, StructuralGap,
}

enum QuarterFunction {
    InformalEconomy, Settlement, EconomicStress, FactionPresence, Neutral,
}

The form × function matrix produces the furniture selection. Not all combinations are valid (no VehicleStaging × Settlement — cargo docks don't become shrines). The generator maintains a validity table.


9. Updated Pipeline Summary

PHASE 1: WORLD PREP (background, async)
═══════════════════════════════════════

Master Seed
  ↓
System Generation ─────── derives: system_seed
  ↓
Society Profile ────────── derives: society_seed
  ↓                        output: SocietyProfile (serde YAML)
  ↓
District Skeletons ─────── derives: district_seed per district
  ├── Zoning (block types, access tiers)
  ├── Spatial prerequisite validation (Gestalt's 7 guarantees,
  │     only for Full complexity districts)
  ├── Social site placement (D-025 template selection + positioning)
  ├── Multi-block reservations
  ├── Corridor spines + access points
  ├── Zone palette assignment
  └── Boundary descriptors (for edge bleed)
  ↓
Block Planning ──────────── per district
  ├── ChunkLayout selection (merge strategy)
  ├── Era assignment + modifications
  ├── Edge contract computation
  └── Quarter layout (form × function)
  ↓
NPC Population ──────────── per district
  ├── Role slot filling (10-axis generation)
  ├── Triangle configuration
  ├── Entanglement marking
  └── Spawn location preferences
  ↓
Transition Strip Gen ────── per shared district edge
  ├── Palette blending
  ├── Access point alignment
  └── Transition block skeletons
  ↓
OUTPUT: PreparedDistrict + TransitionStrips


PHASE 2: LOCAL AREA GEN (on-demand, per chunk)
═══════════════════════════════════════════════

PreparedDistrict
  ↓
Chunk enters loading radius
  ↓
Chunk Fill ──────────────── derives: chunk_seed
  ├── Read BlockPlan + edge contracts
  ├── Select/stamp template from social site tag
  ├── Place terrain (non-urban) or architecture (urban)
  ├── Apply zone palette + era materials
  ├── Place furniture from form × function
  ├── Place NPC spawn points
  ├── Validate edge contracts against loaded neighbors
  └── Apply LOS anchor placement rules
  ↓
OUTPUT: ChunkData (cached, saved)

10. Cost Summary

Component Effort Target
SeedChain + derivation 0.5 dev-days v0.2
SocietyProfile serde schema 1 dev-day v0.2
DistrictSkeleton struct (updated, with boundaries) 2 dev-days v0.1 stub, v0.2 impl
BlockPlan + ChunkFillSpec structs 1 dev-day v0.1 stub
TransitionStrip generation 2 dev-days v0.3
TerrainType + non-urban chunk fill 3 dev-days v0.4+
ComplexityTier + minimal/empty district gen 1 dev-day v0.3
Spatial prerequisite validator 0.5 dev-days v0.3
Transit District as DistrictSkeleton (validation) 3 dev-days v0.1
Phase 1 background thread + scheduling 2 dev-days v0.3
Phase 2 chunk fill (template stamping) 5 dev-days v0.2
Total ~21 dev-days spread v0.1-0.4

Feasible. Challenging but doable. The critical path item is the Phase 2 chunk fill (~5 dev-days in v0.2) because it's the first thing that produces visible tiles. Everything else builds toward it or extends from it.


Tyre — Round 2 complete. The two-phase split is clean. Edge bleed is solved at the data structure level. Non-urban terrain fits the same hierarchy. Seeds are trivially solved. Standing by for Round 3 convergence.