Files
jpmschweitzerandClaude Opus 4.6 42ee1f0a0e docs(workshops): generation cascade workshop — 4 rounds, D-194 through D-218
Four-round workshop (Gestalt, Tyre, Paula, Burnelli-Sheldon, Miri)
mapping the full generation pipeline from planetary heightmap to
walkable tile. 25 D-records produced. Ticket dependency chain for
Tier 0-4 implementation identified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:18:17 +02:00

22 KiB
Raw Permalink Blame History

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Tyre — Round 2: Spatial Generation Architecture Proposal Technical architecture for the spatial pipeline: coordinate translation, city-to-district decomposition, Phase 1/2 generation, chunk wiring, seed boundary, testing, and effort workshop active generation-cascade tyre 2 2026-04-30

Generation Cascade — Round 2: Spatial Generation Architecture Proposal

Tyre — Technical Architect


Scope Declaration

Per lead directive: this proposal covers the spatial pipeline only — from atlas city markers to walkable rendered tiles. Heritage grammar, NPC manifests, social sites, triangle wiring, culture overlays — all deferred. The remaining questions from Round 1 that fall outside spatial scope (OQ-R1-A heritage root count, OQ-R1-B SocietyProfileRef type) are not addressed here.

Spatial scope items I am addressing:

  • OQ-R1-C: City marker schema approach
  • OQ-R1-D: Atlas coordinate translation formula (resolved below)
  • OQ-R1-E: Vertical slice effort estimate (revised)
  • Gap B: Coordinate system design
  • Gap C: Missing DistrictSkeleton fields

The Core Architecture Insight: Two Separate Coordinate Systems

This resolves OQ-R1-D and underpins everything below.

The atlas grid (512×256 pixels) represents the planetary surface as a display coordinate system for the atlas UI. It shows continents, coastlines, city dots. It is not a world-space coordinate for the game simulation.

The walkable world uses a city-local coordinate system, measured in sim tiles (0.5m each, per D-066). A city does not "exist" at pixel (row, col) in the sim world — it exists in its own local space starting at (0, 0). The atlas pixel position is only used for:

  1. Rendering the city dot on the planetary map UI
  2. Determining relative positions between cities on the same body (future: overland travel)

There is no pixel-to-sim-tile conversion formula and none is needed. The translation is a lookup, not arithmetic:

atlas_city.center = [row, col]   → planetary UI display position only
walkable world origin            → always (0, 0) in city-local sim tile space

This is the correct architectural answer to OQ-R1-D. Attempting to produce a linear formula from 512×256 pixels to a planetary sim-tile grid would require deciding how large the planet is in sim tiles — a meaningless number for gameplay. Players don't walk between cities.

What this means for district positioning: Districts in a city are laid out in city-local sim tile space. District 0 is at world position (0, 0). District 1 is at (512, 0) (one district-width east). And so on. The atlas pixel coord stays in the atlas layer; the district layout algorithm works purely in sim tile space.


Seed vs. Stored-Data Boundary

This is the most important architectural decision in the spatial pipeline. I'll be direct about where the line is.

What is stored (authored or generated-then-committed)

Data Location Why stored
Star system and body definitions wiki/, systems.db bodies table Authored world content
Economic data, corporations systems.db economics tables Generated-then-committed via pipeline
City positions, populations, infrastructure markers.json, systems.db atlas tables Generated-then-committed; hand-edit possible
City names markers.json (Gemma-filled) Curated; not reproducible on demand
Chunk mutations (DamageOverlay, player changes) Save file (future) Playthrough-specific

What is NOT stored — always re-derived from seed

Data Why seed-derived
District count and positions per city Function of (city_data, world_seed)
DistrictSkeleton (all fields) Function of (DistrictId, body_context, world_seed)
BlockSkeleton per district Function of (district_seed)
ChunkData tile grid Function of (block_skeleton, chunk_coord, district_seed)
Building footprints within chunks Function of (zoning, density, chunk_seed)

The boundary is between Layer 4 and Layer 6: everything from city markers downward is seed-derived and never persisted as generator output. The markers.json city entries are the stored anchor. Below that, the world is fully deterministic from world_seed.

The SeedChain

Every child seed is derived from its parent using FNV-1a (per D-010's determinism mandate):

fn child_seed(parent: u64, discriminant: u64) -> u64 {
    let mut h = parent ^ 0xcbf29ce484222325;
    for byte in discriminant.to_le_bytes() {
        h ^= byte as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    h
}

district_seed = child_seed(world_seed, district_id)
chunk_seed   = child_seed(district_seed, (cx as u64) << 32 | (cy as u64))

This means:

  • Any district can be generated in isolation, without generating its neighbors first
  • Any chunk can be generated in isolation, given its district_seed
  • Generation is embarrassingly parallelizable
  • No global state is required

Implication for save files

Save files store:

  • world_seed: u64 (the single master seed)
  • markers_snapshot: HashMap<BodyId, MarkersJson> (or a hash of it for validation)
  • chunk_mutations: BTreeMap<ChunkCoord, ChunkMutations> (only player-modified chunks)

Everything else is re-derived on load. A save file for a large world stays small.


Layer-by-Layer Architecture Proposal

Layer 6: City → District Decomposition

Inputs:

  • body_id: &str
  • city: &AtlasCity (from systems.db atlas_cities table — id, kind, population)
  • body: &Body (from systems.db bodies table — settlement_pattern, economic_role, planet_class)
  • world_seed: u64

Algorithm:

fn decompose_city(city: &AtlasCity, body: &Body, world_seed: u64) -> Vec<DistrictPlacement> {
    let n = district_count(city.population, city.kind, body.settlement_pattern);
    let layout = city_layout_grid(n, city_seed(world_seed, &city.id));
    layout.iter().enumerate().map(|(i, grid_pos)| DistrictPlacement {
        district_id: district_id(world_seed, &body.body_id, &city.id, i as u32),
        world_origin: grid_to_sim_tiles(grid_pos),  // (col * 512, row * 512) in sim tiles
        city_id: city.id.clone(),
    }).collect()
}

District count formula:

population → n_districts:
  0          → 0 (uninhabited)
  150k      → 1
  50k500k   → 24  (log-scaled)
  500k5M    → 49  (log-scaled)
  5M+        → 916 (capped at 4×4)
  capital    → +1 bonus district
  domed/cave → fixed 1 regardless of population

District positioning: A simple grid arrangement in city-local space. District at grid position (col, row) gets world origin (col * 512, row * 512) sim tiles. Grid dimensions chosen to be roughly square for the given count (e.g., 4 districts → 2×2; 6 districts → 3×2). Exact grid shape is seeded for slight variety (some cities are long and thin, some square).

OQ-R1-C resolution: primary_function, planet_class, and settlement_pattern are available in systems.db bodies table and do NOT need to be added to markers.json. The decomposition function reads them from systems.db directly, cross-referenced by body_id. generate_atlas.py does not need modification.

Output:

struct DistrictPlacement {
    district_id: DistrictId,   // u64, deterministic from world_seed + body + city + index
    world_origin: (i64, i64),  // sim tile position of district's (0,0) corner
    city_id: String,
}

Testing: Pure function. Unit test: given (population, settlement_pattern), district count matches formula. Given same (city, body, seed), district positions are identical across runs. Given different seeds, positions vary. No ECS needed.


Layer 7: Phase 1 — DistrictSkeleton (Spatial Structure Only)

Scope for minimum vertical slice: Stages 1 and 2 only. Stages 3 (reservations), 4 (social sites), and 5 (guarantee audit) are deferred — they produce spatial enrichment but are not required for a walkable tile.

Stage 1: Classification

fn classify_district(placement: &DistrictPlacement, body: &Body, city: &AtlasCity) 
    -> (WorldTier, ComplexityTier, SettingType, DistrictType)
  • WorldTier: derived from system economic tier in systems.db (economic_role, system connectivity). Hub systems → Epicenter; standard → Regional or Backwater; transit stops → Passage.
  • ComplexityTier: derived from WorldTier ceiling table (workshop-outcomes.md)
  • SettingType: derived from planet_class + economic_role + city kind. Capital on a standard planet → Urban. Rural body → Agricultural. Orbital station → Station.
  • DistrictType: derived from city kind + district index within city (center district → Commercial or MixedUse; outer districts → Residential, Industrial, Transit)

All deterministic from upstream data. No RNG needed at Stage 1 — classification is a pure lookup/mapping.

Stage 2: Block grid

fn generate_block_grid(district_seed: u64, district_type: &DistrictType, 
    complexity: &ComplexityTier) -> [[BlockSkeleton; 4]; 4]
  • DistrictLayoutMode: seeded choice between Grid and Organic. Weight toward Grid for Commercial/Administrative, toward Organic for Residential/Mixed. Lead decision L-2: both modes must coexist.
  • Per block: assign ZoningType from a seeded draw against the district's type-to-zoning probability table. Commercial district → mostly Commercial blocks with some Residential edges.
  • Per block: assign density_pct seeded from district_seed + block position. Creates density gradient (center blocks denser than edge blocks for Urban districts).
  • Era: stub string for now ("standard"). Real era differentiation is later work.
  • Everything else on BlockSkeleton (chunk_layout, hosted_sites, landmark) → left as empty/stub.

Key constraint: The block grid is fully determined by (district_seed, district_type, complexity). No external lookups at generation time. The entire DistrictSkeleton can be re-derived from those three inputs plus world_seed.

Missing fields to add: Per Gap C from Round 1, these three fields are required by the workshop spec and absent from the current struct:

  • vertical_structure: VerticalStructure — add as stub enum VerticalStructure::Flat for now
  • breach_only_zones: Vec<ZoneId> — add as Vec<String>, empty for minimum slice
  • derived_analysis: DerivedDistrictAnalysis — add as stub struct, all fields None/default

These are 0.5 days of type work.

WorldTier fix: The enum must be corrected before any generation work starts. One-line change, 0.5 days including test update.


Layer 8: Phase 2 — ChunkData Tile Generation (Spatial Only)

This layer converts a BlockSkeleton into a 64×64 sim-tile grid of walkable/non-walkable tiles with tile type assignments.

Inputs:

  • block: &BlockSkeleton (zoning, density_pct, layout from Phase 1)
  • chunk_coord: ChunkCoord (which 2×2 chunk-within-block this is)
  • chunk_seed: u64 (derived from district_seed + chunk_coord)

Output: A 64×64 tile grid (the raw GeneratorChunkData)

Minimum viable generation algorithm:

The key insight: for the minimum slice, we don't need heritage grammar, room templates, or furniture. We need:

  1. Street tiles (walkable, visually distinct)
  2. Building exterior tiles (wall, non-walkable)
  3. Building interior floor tiles (walkable)
fn generate_chunk_tiles(block: &BlockSkeleton, within_block_pos: (u8, u8), 
    chunk_seed: u64) -> GeneratorChunkData {
    let density = block.density_pct;
    let mut rng = SimRng::from_seed(chunk_seed);
    
    // Step 1: Fill with street tile
    // Step 2: Place building footprints based on density
    //   - density 0-20%:  0-1 small buildings
    //   - density 20-60%: 2-4 medium buildings
    //   - density 60-100%: 4-8 buildings, larger, tighter
    // Step 3: For each footprint: fill interior with floor tile, perimeter with wall tile
    // Step 4: Ensure street connectivity (no building can fully block a chunk edge)
}

Building footprint algorithm: Simple non-overlapping rectangle placement using seeded random dimensions within density budget. Minimum building: 6×6 tiles. Minimum street gap between buildings: 4 tiles. This is not artistically sophisticated but produces walkable, navigable space.

Tile vocabulary for minimum slice:

// Minimum TileId set (just string constants to start)
const TILE_FLOOR_STREET: &str = "floor_street";
const TILE_FLOOR_INTERIOR: &str = "floor_interior";
const TILE_WALL: &str = "wall";

Three tile types. Everything needed for walkability and basic navigation. Visual polish is a later pass.

GeneratorChunkData type change: The current type alias pub type GeneratorChunkData = Vec<bool> must become a proper struct to carry tile IDs, not just walkability. Minimum:

pub struct GeneratorChunkData {
    pub tiles: Vec<TileEntry>,   // 64×64 = 4096 entries
}

pub struct TileEntry {
    pub tile_id: TileId,         // "floor_street", "wall", etc.
    pub walkable: bool,          // pre-computed for the movement system
}

This is a type-level change that breaks the placeholder ChunkData::new_walkable() usage cleanly — making the transition visible rather than silent.


Layer 9: Chunk Streaming → Generator Wiring

The chunk streaming architecture is ready. The hookup requires two additions:

Addition 1: DistrictMap resource

#[derive(Resource)]
pub struct DistrictMap {
    /// Maps a chunk coord to the district it belongs to.
    /// Built at world load time from DistrictPlacement data.
    chunks: BTreeMap<ChunkCoord, DistrictId>,
    /// The Phase 1 skeleton for each loaded district.
    skeletons: BTreeMap<DistrictId, DistrictSkeleton>,
}

Addition 2: Replace load_chunk() placeholder

pub fn load_chunk(&mut self, coord: ChunkCoord, district_map: &DistrictMap, 
    world_seed: u64) -> bool {
    if self.chunks.contains_key(&coord) { return false; }
    
    let chunk_data = if let Some(district_id) = district_map.chunks.get(&coord) {
        let skeleton = &district_map.skeletons[district_id];
        let block = block_for_chunk(skeleton, coord);
        let chunk_seed = child_seed(child_seed(world_seed, *district_id), 
                                    coord_to_u64(coord));
        generate_chunk_tiles(block, within_block_pos(coord), chunk_seed)
    } else {
        // Outside any district — wilderness, open terrain
        GeneratorChunkData::all_walkable()
    };
    
    self.chunks.insert(coord, chunk_data);
    true
}

This is the complete wiring. No other changes to chunk_streaming.rs are needed.


Data Format Boundaries

The handoff between Python tooling and Rust runtime is clean and already exists:

PYTHON TOOLING (offline pipeline)
  systems.db   → bodies, star_systems, economics, atlas_cities tables
  markers.json → city positions and populations (per body)
  
       ↓  [HANDOFF — server startup reads these two sources]
  
RUST SERVER (runtime)
  At startup: read systems.db + markers.json → build DistrictMap in memory
  On load_chunk(): derive ChunkData from district_seed + block_skeleton
  On save: persist only ChunkMutations (player-modified chunks)

The handoff is currently wired: server/src/main.rs references the generator. Systems.db is read at startup. The gap is not the boundary mechanism — it's that no code reads atlas_cities to build a DistrictMap.

No Python layer needed for Phase 1 or Phase 2. The entire spatial generation below city markers is Rust code, working against data already in systems.db. This is clean.


Testing Strategy Per Layer

Each layer produces deterministic output and can be validated independently.

City decomposition

#[test]
fn decompose_city_deterministic() {
    let result1 = decompose_city(&city, &body, 42);
    let result2 = decompose_city(&city, &body, 42);
    assert_eq!(result1, result2);
}

#[test]
fn district_count_formula() {
    assert_eq!(district_count(0, "city", "standard"), 0);
    assert_eq!(district_count(10_000, "city", "standard"), 1);
    assert_eq!(district_count(1_000_000, "capital", "standard"), 5); // 4 + 1 capital bonus
}

Fully unit-testable. No ECS, no file I/O.

Phase 1 DistrictSkeleton

#[test]
fn skeleton_same_seed_identical() {
    let s1 = generate_skeleton(placement, body, city, 42);
    let s2 = generate_skeleton(placement, body, city, 42);
    assert_eq!(s1, s2);  // requires PartialEq on DistrictSkeleton — needs real types, not String stubs
}

#[test]
fn block_grid_coverage() {
    let s = generate_skeleton(placement, body, city, 42);
    // Every block has a valid ZoningType
    for row in &s.blocks {
        for block in row {
            assert!(block.zoning != ZoningType::Mixed || s.district_type == DistrictType::MixedUse);
        }
    }
}

Requires replacing String stub types with real enums/structs (which is also needed to implement generation). Tests become the specification.

Phase 2 ChunkData

#[test]
fn chunk_has_walkable_tiles() {
    let data = generate_chunk_tiles(&block, (0, 0), 99);
    let walkable_count = data.tiles.iter().filter(|t| t.walkable).count();
    assert!(walkable_count >= 100, "chunk must have ≥100 walkable tiles for NPC routing");
}

#[test]
fn chunk_edges_walkable() {
    // Ensure no chunk boundary is fully blocked (prevents dead zones at chunk seams)
    let data = generate_chunk_tiles(&block, (0, 0), 99);
    // at least 4 walkable tiles on each edge
}

#[test]
fn chunk_deterministic() {
    let d1 = generate_chunk_tiles(&block, (0, 0), 42);
    let d2 = generate_chunk_tiles(&block, (0, 0), 42);
    assert_eq!(d1.tiles, d2.tiles);
}

For visual/rendering validation: a Gauntlet room with a generated district. Run --test-mode SR_LIVE=1 and walk the tiles manually. Per CLAUDE.md, new room added — existing Gauntlet rooms are not modified.

End-to-end integration

Spawn a player in a generated city. Verify:

  1. All chunks within ChunkLoadRadius load without panic
  2. Player can move (walkability map is non-empty)
  3. Second load produces identical tile layout (determinism check)

This is a live server test using existing --test-mode infrastructure.


Effort Estimates — Revised

My Round 1 estimate (9 days) was contaminated by heritage grammar work that is now out of scope. Revised for spatial-only:

Item Effort Notes
Fix WorldTier enum + test updates 0.5d One enum change, propagate through compilation errors
Add missing DistrictSkeleton fields as stubs 0.5d Type-only change; no logic
Replace String stubs with real types (Stages 1-2 scope) 1d DistrictContext, DistrictBoundaries, etc. — enough to implement Stages 1-2
City-to-district decomposition 1d Pure function, well-defined algorithm, unit-testable
Server startup: read atlas data → build DistrictMap 0.5d Reading systems.db + markers.json; already have DB access patterns
Phase 1 generation (Stages 1-2 only) 1.5d Classification lookup + seeded block grid
GeneratorChunkData type upgrade (Vec → TileEntry grid) 0.5d Type change + update new_walkable() caller
Phase 2 minimal tile generation 1.5d Floor/wall placement from density, seeded rectangles
Chunk streaming wiring 0.5d DistrictMap resource + dispatch in load_chunk()
Integration test (walkable generated city) 0.5d Gauntlet room + live test

Total: ~8 days — but only ~6 of those are truly sequential (critical path). The type changes and test writing can overlap with algorithm work.

Minimum parallelizable scope: If two developers were available:

  • Dev A: Layers 6-7 (city decomp + Phase 1)
  • Dev B: Layers 8-9 (Phase 2 + streaming wiring, stubbing Phase 1 output)

Parallelized: ~4 days wall-clock.

Reconciling with Gestalt's estimate

Gestalt's 5-7 days appears to assume the String stub replacements are free (fold into Phase 1 work) and the startup/DistrictMap wiring is minimal. Both are reasonable simplifications. The key actual divergence: I have explicit line items for the type upgrade (GeneratorChunkData) and the server startup path that Gestalt may have assumed as already wired. Neither of us included heritage grammar — that was the bulk of the Round 1 overestimate.

Gestalt's floor estimate (~5 days) represents the fastest possible path with no unexpected compilation surprises from the String-to-real-type migrations. My 8 days is the conservative estimate including that friction. True answer is somewhere between: ~6-7 days under reasonable conditions.


Open Question Resolutions

OQ-R1-D (Atlas coordinate translation)

Resolved: there is no pixel-to-sim-tile formula. The atlas coordinates are UI-only. The walkable world uses city-local coordinates starting at (0, 0). See "Core Architecture Insight" section above.

OQ-R1-C (City marker schema)

Resolved: primary_function, planet_class, settlement_pattern are read from systems.db bodies table by cross-reference on body_id. generate_atlas.py does not need modification.

OQ-R1-E (Effort estimate)

Resolved: ~6-7 days for minimum spatial vertical slice (spatial only, no heritage grammar, no social sites). Conservative estimate is 8 days including type migration friction.

OQ-R1-F (#615 cascade position)

Per lead directive: #615 is NPC territory and out of scope for this workshop. No position taken.


What This Proposal Defers

To be explicit about what is NOT in scope for the vertical slice:

  • Heritage grammar (Phase 2 tile aesthetics beyond floor/wall)
  • Room templates (rectangular buildings replace proper room grammar)
  • DistrictSkeleton Stages 3-5 (reservations, social sites, guarantee audit)
  • Building interiors with furniture or objects
  • Vertical structure / z-levels beyond ground floor
  • MobileChunk
  • NPC placement

None of these are needed for the question "can the player walk through a generated district?" They can be layered in after the spatial plumbing exists.