Files
settled-reach/docs/workshops/generation-cascade/tyre-round3.md
T
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

30 KiB
Raw Blame History

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Tyre — Round 3: Phase 3 Technical Architecture Phase 3 technical layers between atlas output and Phase 5 tile generation — algorithms, tooling vs. runtime split, data formats, LoD architecture, testing, effort, and pipeline question answers workshop active generation-cascade tyre 3 2026-04-30

Generation Cascade — Round 3: Phase 3 Technical Architecture

Tyre — Technical Architect


Scope Declaration

This round corrects the framing from Rounds 1-2. Rounds 1-2 designed the Phase 5 (tile/chunk/district) pipeline. Phase 3 — "Planetary/moon maps and station layouts — cities, rivers, mountains, roads, biomes, rail" — was not designed. This document addresses the missing intermediate layer.

The resolved decisions from Rounds 1-2 (WorldTier enum, SeedChain, city-local coordinates, GeneratorChunkData upgrade, DistrictSkeleton) are treated as given facts about Phase 5. I am not revisiting them. I am designing what Phase 3 must produce before Phase 5 can run.


The Architectural Gap — What Phase 3 Is

The atlas pipeline currently ends here:

atlas_cities:  {city_id, body_id, name, kind, center_row, center_col, population}
atlas_roads:   {road_id, body_id, path(64 pixel coords), kind}

A city is a dot. A road is a list of pixel coordinates on a 512×256 grid. This is sufficient for Phase 1 (wiki display) but insufficient for:

  • The Atlas of the Reach (Phase 3 deliverable) — the implant app that shows regional maps, city extents, road connectivity, biome zones
  • Phase 5 generation inputs — the district skeleton generator needs to know: what biome surrounds this city? How large is it? What is its political character? What roads enter from which directions?

Phase 3 is the tooling work that enriches the atlas data from "dots on a heightmap" to "a browsable regional atlas with classification, footprint, and connectivity."

Phase 3 lives entirely in Python tooling and systems.db. It does not add Rust runtime code. The Rust server reads the enriched systems.db at startup; all enrichment computation runs offline in the asset pipeline.


Phase 3 Technical Layers

Layer A — City Classification and Footprint

Algorithm: Derived from existing systems.db data. No new simulation required.

# Inputs (all already in systems.db bodies + atlas_cities tables):
#   population, settlement_pattern, economic_role, planet_class, world_tier
#
# Outputs:
#   footprint_radius_km  — derived from population density
#   political_archetype  — derived from economic_role, optional authored override
#   prosperity_index     — derived from WorldTier + DistrictType distribution + economic data

def compute_footprint_radius_km(population: int, settlement_pattern: str) -> float:
    # Urban density base: ~8000 people/km² for concentrated, ~500 for dispersed
    density = {"urban_concentrated": 8000, "dispersed_rural": 500, "domed": 50000, "cave": 100000}
    d = density.get(settlement_pattern, 4000)
    return math.sqrt(population / (math.pi * d))

def derive_political_archetype(economic_role: str, city_kind: str) -> str:
    # economic_role from bodies table → archetype
    # CompanyTown | AdminCapital | FreePort | Contested | OrganicGrowth
    mapping = {
        "corporate_extraction": "CompanyTown",
        "administrative_center": "AdminCapital",
        "transit_hub": "FreePort",
        "mixed_economy": "OrganicGrowth",
    }
    return mapping.get(economic_role, "OrganicGrowth")

Tooling vs. Runtime: Python tooling (generate_regional.py). All computation runs offline.

Data format / storage: New columns on atlas_cities table:

ALTER TABLE atlas_cities ADD COLUMN footprint_radius_km REAL;
ALTER TABLE atlas_cities ADD COLUMN political_archetype TEXT;
ALTER TABLE atlas_cities ADD COLUMN prosperity_index REAL;
ALTER TABLE atlas_cities ADD COLUMN prosperity_override REAL;  -- NULL = always derived

SeedChain: None. This data is deterministically computed from stored inputs. No randomness.

Scale: A city of 500k people at urban density ≈ 7.9km radius ≈ ~0.8 pixels on the 512×256 atlas grid. This is correct — cities are subpixel at atlas scale, which is why they're stored as dots.


Layer B — Regional Biome Grid

Algorithm: Downsample the planet_simulation biome grid from 512×256 to a 64×32 regional grid. Each regional cell (8×8 atlas pixels) gets the plurality biome class from the underlying simulation output, plus terrain statistics (mean elevation, mean moisture, terrain roughness).

def generate_regional_biome_grid(terrain: dict) -> list[dict]:
    """Downsample 512×256 biome grid to 64×32 regional cells.
    
    Each cell represents ~8 pixels of planetary surface.
    Returns list of {row, col, biome_class, mean_elevation, mean_moisture,
                     terrain_roughness, is_coastal} dicts.
    """
    biome = terrain["biome"]           # int8 (256, 512)
    elevation = terrain["elevation"]   # float32 (256, 512)
    moisture = terrain["moisture"]     # float32 (256, 512)
    surface_water = terrain["surface_water"]  # bool (256, 512)
    
    cells = []
    for gr in range(32):
        for gc in range(64):
            # 8×8 pixel window
            r0, r1 = gr * 8, (gr + 1) * 8
            c0, c1 = gc * 8, (gc + 1) * 8
            
            b_patch = biome[r0:r1, c0:c1].ravel()
            e_patch = elevation[r0:r1, c0:c1]
            m_patch = moisture[r0:r1, c0:c1]
            w_patch = surface_water[r0:r1, c0:c1]
            
            # Plurality biome
            unique, counts = np.unique(b_patch, return_counts=True)
            dominant_biome = int(unique[counts.argmax()])
            
            # Coast detection: land cells touching water cells
            is_coastal = bool(w_patch.any() and (~w_patch).any())
            
            cells.append({
                "grid_row": gr, "grid_col": gc,
                "biome_class": dominant_biome,
                "mean_elevation": float(e_patch.mean()),
                "mean_moisture": float(m_patch.mean()),
                "terrain_roughness": float(np.gradient(e_patch).std()),
                "is_coastal": is_coastal,
                "water_fraction": float(w_patch.mean()),
            })
    return cells

Why 64×32? Each cell maps to a region small enough to have a single dominant character, large enough to be distinct (8×8 px = planetary feature scale). Gives 2048 cells per body — one SQL row per cell is 2048 rows, trivially queryable. If storage is a concern: pack as a JSON blob on atlas_body_grids instead.

Tooling vs. Runtime: Python tooling, runs during generate_atlas.py pipeline (terrain is already in memory at that point — no second simulate() call needed).

Data format / storage: New table:

CREATE TABLE IF NOT EXISTS atlas_regional_biomes (
    body_id TEXT NOT NULL REFERENCES bodies(body_id),
    grid_row INTEGER NOT NULL,  -- 0-31
    grid_col INTEGER NOT NULL,  -- 0-63
    biome_class INTEGER NOT NULL,
    mean_elevation REAL NOT NULL,
    mean_moisture REAL NOT NULL,
    terrain_roughness REAL NOT NULL,
    is_coastal INTEGER NOT NULL DEFAULT 0,  -- boolean
    water_fraction REAL NOT NULL DEFAULT 0.0,
    PRIMARY KEY (body_id, grid_row, grid_col)
);

SeedChain: None. Deterministically derived from terrain.

Phase 5 consumption: When Phase 5 generates districts for a city, it queries atlas_regional_biomes at the city's center position (converted from atlas pixel to regional grid cell: gr = center_row // 8, gc = center_col // 8) to get surrounding biome context. This tells the district skeleton generator whether wilderness districts around the city are desert, forest, tundra, etc.


Layer C — Road Network Graph

Algorithm: Convert existing pixel-path roads/railroads from atlas_roads / atlas_railroads into a proper graph structure. Node = city center or path junction. Edge = road segment with distance and kind.

def build_road_graph(cities: list[dict], roads: list[dict]) -> tuple[list, list]:
    """Convert pixel-path roads to a navigable graph.
    
    Node: city or junction point at a path endpoint.
    Edge: road segment between two nodes, with euclidean distance in atlas pixels
          and road kind (commercial, highway, rail).
    
    Returns (nodes, edges).
    """
    nodes = []
    node_index = {}
    
    # Cities are always nodes
    for city in cities:
        nid = len(nodes)
        node_index[(city["_row"], city["_col"])] = nid
        nodes.append({
            "node_id": nid,
            "kind": "city",
            "ref_id": city["id"],
            "row": city["_row"],
            "col": city["_col"],
        })
    
    edges = []
    for road in roads:
        path = road["path"]  # [[row, col], ...]
        if len(path) < 2:
            continue
        
        start = tuple(path[0])
        end = tuple(path[-1])
        
        # Snap endpoints to nearest city node
        start_nid = _snap_to_node(start, nodes, threshold=15)
        end_nid = _snap_to_node(end, nodes, threshold=15)
        
        if start_nid is None or end_nid is None:
            continue
        
        # Distance: sum of euclidean segments along path
        dist = sum(
            math.sqrt((path[i][0] - path[i-1][0])**2 + (path[i][1] - path[i-1][1])**2)
            for i in range(1, len(path))
        )
        
        edges.append({
            "from_node": start_nid,
            "to_node": end_nid,
            "kind": road.get("kind", "commercial"),
            "distance_px": dist,
        })
    
    return nodes, edges

Tooling vs. Runtime: Python tooling, runs after generate_atlas.py has placed cities and roads.

Data format / storage: Two new tables:

CREATE TABLE IF NOT EXISTS atlas_road_nodes (
    node_id TEXT PRIMARY KEY,   -- "{body_id}/{local_id}"
    body_id TEXT NOT NULL,
    kind TEXT NOT NULL,         -- 'city' | 'junction'
    ref_id TEXT,                -- city local_id if kind='city'
    grid_row INTEGER NOT NULL,
    grid_col INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS atlas_road_edges (
    edge_id TEXT PRIMARY KEY,
    body_id TEXT NOT NULL,
    from_node TEXT NOT NULL REFERENCES atlas_road_nodes(node_id),
    to_node TEXT NOT NULL REFERENCES atlas_road_nodes(node_id),
    kind TEXT NOT NULL,         -- 'commercial' | 'highway' | 'rail'
    distance_px REAL NOT NULL
);

SeedChain: None. Derived from stored city positions and road paths.

Phase 3 Atlas UI use: The implant atlas app can render the road graph as a connectivity overlay on the planetary map. This is richer than raw pixel paths — the client can query "what roads connect city A to city B" without scanning all pixels.

Phase 5 use: District access point placement uses this graph to know which compass directions roads enter the city from. A city at the end of a single road gets access points on that one edge; a transit hub at a road junction gets access points on multiple edges.


Layer D — Station Module Topology

Algorithm: Stations have no terrain. Their layout is generated from a schema derived from economic_role, population, and settlement_pattern = "orbital_only". A station is decomposed into modules with a connection topology.

Module types:

  • HabitatRing — residential / crew quarters
  • DockBay — ship berths, cargo handling
  • CommerceHub — trade, market, services
  • IndustrialSection — manufacturing, processing
  • AdminCore — command, administration
  • SecuritySection — law enforcement, detention

Connection topology: a graph of module adjacency. Each edge has a passage type (Pressurized | Airlock | EVA | Service).

def generate_station_topology(station: dict) -> dict:
    """Generate module list and connection graph for an orbital station.
    
    economic_role drives which modules are present and their relative sizes.
    population drives total habitable volume (module count).
    """
    economic_role = station["economic_role"]
    population = station["population"]
    
    # Module count scales with population (log-ish)
    module_count = max(3, int(math.log10(max(population, 100)) * 2))
    
    role_templates = {
        "transit_hub": ["DockBay", "DockBay", "CommerceHub", "AdminCore", "HabitatRing"],
        "corporate_extraction": ["IndustrialSection", "DockBay", "AdminCore", "HabitatRing"],
        "administrative_center": ["AdminCore", "AdminCore", "HabitatRing", "SecuritySection"],
        "mixed_economy": ["HabitatRing", "CommerceHub", "DockBay", "AdminCore"],
    }
    
    template = role_templates.get(economic_role, ["HabitatRing", "DockBay", "AdminCore"])
    modules = (template * ((module_count // len(template)) + 1))[:module_count]
    
    # Linear + hub topology: AdminCore in center, others radiate out
    edges = []
    admin_idx = next((i for i, m in enumerate(modules) if m == "AdminCore"), 0)
    for i, module in enumerate(modules):
        if i != admin_idx:
            edges.append({
                "from": admin_idx,
                "to": i,
                "passage_type": "Pressurized" if module != "DockBay" else "Airlock",
            })
    
    return {"modules": modules, "edges": edges}

Tooling vs. Runtime: Python tooling, runs during generate_regional.py.

Data format / storage:

CREATE TABLE IF NOT EXISTS atlas_station_modules (
    module_id TEXT PRIMARY KEY,  -- "{body_id}/module_{index}"
    body_id TEXT NOT NULL,
    module_index INTEGER NOT NULL,
    module_type TEXT NOT NULL,
    PRIMARY KEY (body_id, module_index)
);

CREATE TABLE IF NOT EXISTS atlas_station_connections (
    connection_id TEXT PRIMARY KEY,
    body_id TEXT NOT NULL,
    from_module INTEGER NOT NULL,
    to_module INTEGER NOT NULL,
    passage_type TEXT NOT NULL  -- 'Pressurized' | 'Airlock' | 'EVA' | 'Service'
);

SeedChain: None. Deterministically derived from economic_role + population.

Note: Stations have settlement_pattern = "orbital_only" in the bodies table, so generate_atlas.py skips city placement for them. generate_regional.py handles them as a separate code path.


Layer E — Wilderness and Countryside Annotation

Algorithm: For each body, identify the inter-city regions and annotate them with land-use classifications. This is lightweight: sample the regional biome grid (Layer B) at positions between cities and classify based on biome class + proximity to cities.

Land use classes: Agricultural | Wilderness | Industrial | Wasteland | Ocean | Impassable

def annotate_wilderness_regions(
    cities: list[dict],
    regional_biomes: list[dict],  # 64×32 grid from Layer B
    road_graph: dict,             # from Layer C
) -> list[dict]:
    """Annotate non-city regional cells with land use.
    
    Cells within city footprint radius: mark as 'city' (excluded from wilderness).
    Cells along roads: mark as 'corridor'.
    Remaining cells: classify from biome + proximity to cities.
    """
    annotations = []
    for cell in regional_biomes:
        if cell["water_fraction"] > 0.6:
            land_use = "Ocean"
        elif cell["biome_class"] in (17, 26):  # ice biomes
            land_use = "Impassable"
        elif _within_city_footprint(cell, cities):
            land_use = "Urban"
        elif _near_road(cell, road_graph, threshold=2):
            land_use = "Corridor"
        else:
            land_use = _biome_to_land_use(cell["biome_class"], cell["mean_moisture"])
        
        annotations.append({
            "grid_row": cell["grid_row"],
            "grid_col": cell["grid_col"],
            "land_use": land_use,
        })
    return annotations

Tooling vs. Runtime: Python tooling, runs during generate_regional.py. Reuses Layer B + C output.

Data format / storage: Add land_use column to atlas_regional_biomes rather than a separate table (it's a property of the same cell).

Phase 5 use: When generating wilderness districts, Phase 5 queries this annotation to know whether to produce Agricultural, Wilderness, or Industrial district types for chunks outside city bounds.


The Scale Problem — Concrete Answer

Planet scale: 512×256 atlas pixels = entire planetary surface. Cities are sub-pixel dots.

Regional scale (Phase 3): 64×32 cells = 8px per cell. A city footprint of 7.9km radius (500k population) spans roughly 0.8 atlas pixels = 0.1 regional cells. At regional scale, cities are still point features — the footprint is an attribute, not a spatial extent in the regional grid.

City scale (Phase 5 input): N districts, each 512×512 sim tiles. A district spans ~256m. A city of 500k has ~10 districts = a 3-4 district grid.

The scale gap between regional and city doesn't need a smooth interpolation. It's a conceptual jump:

  • Regional query: "What biome is near this city?"
  • City query: "How many districts does this city have, and what are their types?"

These are answered by different data structures. There is no intermediate "regional grid of sim tiles" — that would be Phase 5's job when the player enters the city. The Phase 3 regional data just provides the context for Phase 5.

What Phase 3 passes to Phase 5 per city:

pub struct CityGenerationContext {
    pub city_id: String,
    pub political_archetype: PoliticalArchetype,
    pub prosperity_index: f32,
    pub surrounding_biome: BiomeClass,     // from regional grid at city position
    pub road_entry_directions: Vec<CardinalDirection>,  // from road graph
    pub footprint_radius_km: f32,
}

This struct — populated from systems.db at game startup — is the handoff from Phase 3 to Phase 5. Phase 5 uses it as input to district skeleton generation (D-C4, D-C8 from Round 2).


LoD Architecture at Regional Scale

The door-per-edge + descriptor + catalog principle from Phase 5 extends cleanly to all scales:

Scale LoD "closed" state LoD "open" threshold What opens
Galactic Star system = entry in bodies table Visit system Planet surfaces visible
Planetary Planet = heightmap dot in atlas UI Player approaches (orbit/landing) Regional biome overlay activates
Regional Region = biome cell on 64×32 grid Player enters city range City footprint + district grid activates
City City = footprint circle on regional map Player enters city Districts rendered as labeled blocks
District District = block label on city map Player enters district DistrictSkeleton generates on demand
Building Building = door-per-edge boundary Player at door threshold Descriptor + catalog populates interior
Room Room = locked/closed door Player opens door Tile grid visible and walkable

This cascade is architecturally consistent. Every layer closes to the minimum representation possible for entities outside player range. The Phase 3 deliverable (Atlas of the Reach implant app) is the interface for the Planetary and Regional levels.

Concrete rule for Phase 3 → Phase 5 transition: When the player's walkable position approaches within city.footprint_radius_km of a city center (converted via the appropriate scale factor), Phase 5 generates the city's district grid. Before that threshold, the city exists only as atlas data. The threshold can be implemented as a simple distance check against the city's metadata in systems.db.


Testing Strategy Per Layer

Layer A (City Classification):

  • Unit test: given known {population, settlement_pattern, economic_role}, assert correct footprint_radius_km, political_archetype, prosperity_index
  • Fixture: Van Maanen's Star capital — confirm its values match lore expectations
  • Regression: run on all inhabited bodies, assert no NULL values and prosperity_index ∈ [0.0, 1.0]

Layer B (Regional Biome Grid):

  • Unit test: given a synthetic 512×256 biome array, assert downsampled 64×32 output has correct plurality logic
  • Regression: run generate_atlas.py --body GJ380c --dry-run with the new regional sampling — compare output against a committed fixture (similar to how check-systems-db-stamp works). The planet_simulation output is deterministic from seed, so this fixture is stable.
  • Validation: assert coastal cells correctly identify ocean adjacency

Layer C (Road Graph):

  • Unit test: given 2 cities and a straight-line road between them, assert graph has 2 nodes + 1 edge + correct distance
  • Validation: assert graph is connected (all cities reachable from capital) for bodies with ≥2 cities
  • Integration: run on full body, assert road_entry_directions is non-empty for any city with ≥1 connected road

Layer D (Station Topology):

  • Unit test: given economic_role = "transit_hub", assert topology contains ≥1 DockBay + 1 AdminCore + valid edge list
  • Validation: assert all edges reference valid module indices; assert no disconnected modules (every module reachable from AdminCore)
  • Regression: run on all orbital-only bodies

Layer E (Wilderness Annotation):

  • Unit test: given a 3×3 regional grid with one water cell, assert it gets "Ocean" annotation
  • Regression: run on full pipeline, assert annotation column is non-NULL for all cells

Integration — the full pipeline:

make regen-db  # now includes generate_regional.py
tooling/db/sqlite-query "SELECT COUNT(*) FROM atlas_city_profiles WHERE prosperity_index IS NULL"
# → must return 0
tooling/db/sqlite-query "SELECT COUNT(*) FROM atlas_regional_biomes"
# → must return (number of inhabited bodies) × 2048

What makes Phase 3 layers independently testable: Each layer's inputs are either (a) already in systems.db from prior layers or (b) the terrain dict which is deterministically produced by planet_simulation.py. No layer depends on runtime game state. All layers can be exercised by running the Python tooling pipeline.


Effort Estimates

Layer Work Effort
A — City classification Add columns to atlas_cities, implement derive functions, meta stamp update 1.5d
B — Regional biome grid New table, sampling code integrated into generate_atlas.py, regression fixture 1.5d
C — Road graph New tables, graph-building code, connectivity validation 1d
D — Station topology New tables, template-based generator, separate code path in generate_regional.py 1.5d
E — Wilderness annotation Column addition to atlas_regional_biomes, annotation logic 0.5d
Plumbing New generate_regional.py script structure, meta stamp, Makefile integration, pre-push hook update 0.5d
Rust read path CityGenerationContext struct + systems.db query functions 0.5d
Total ~7 developer days

Important: The 7-day estimate is independent of the Phase 5 work (~6 days from Round 2). Phase 3 and Phase 5 are parallel workstreams. Phase 3 does not block Phase 5 during development — Phase 5 can be developed against hardcoded stub context values and then wired to systems.db when Phase 3 is complete.


Data Pipeline Decision — systems.db vs. Seed-Derived vs. Middle Ground

All Phase 3 data belongs in systems.db (committed asset store). Rationale:

  1. Authoring coupling: city footprint, political archetype, prosperity_index all derive from wiki-authored bodies data. They belong with that data in the asset store, not in a per-play runtime derivation.

  2. UI dependency: the Atlas of the Reach implant app reads this data to display the regional map. It must be available before any player has ever started a game session. Seed-derived data requires a game session to generate.

  3. Determinism is not the issue: Phase 3 data is deterministic (same sources → same output) but determinism alone doesn't justify runtime generation. The atlas city placements are also deterministic, yet we commit them to systems.db via the generator pipeline. Same principle applies here.

  4. Generation cost: planet_simulation.py takes real compute time. Caching its output at the regional resolution (Layer B) is the right call. Re-running it at game start for every body would be prohibitive.

  5. The "middle ground" case: there is a legitimate middle ground for wilderness interior variation — the exact shape of forests and river tributaries below regional resolution. That detail is NOT Phase 3. It's Phase 5 wilderness district generation, and it correctly belongs in the seed-derived tier.

Summary of the boundary:

systems.db (committed by generate_regional.py):
  atlas_city_profiles      — footprint, archetype, prosperity
  atlas_regional_biomes    — 64×32 biome grid per body + land use
  atlas_road_nodes/edges   — road graph
  atlas_station_modules    — station topology
  
Seed-derived (Phase 5, never stored):
  DistrictSkeleton         — district layout and blocks
  ChunkData                — 64×64 tile grids
  Wilderness interior      — exact forest/river shapes within wilderness districts

Open Questions from Round 2 — Answered

OQ-R2-1: prosperity_index and perimeter_treatment — include in minimum Phase 5 slice?

YES. Paula's argument stands up technically. prosperity_index is a single f32 derived in Stage 1 from inputs already available at classification time (WorldTier + DistrictType + city.economic_role). Adding it is 10 lines of Stage 1 code. Not adding it means Phase 2 tile generation produces identical spatial character regardless of district wealth — then retrofitting it is a Phase 2 rewrite.

perimeter_treatment is equally cheap: derived from (DistrictType, WorldTier, prosperity_index) as a lookup table. Its impact is a single set of boundary tiles at access_points. Cost to include now: trivial. Cost to retrofit: every Phase 2 tile generation function gets a parameter it previously ignored.

Both fields belong on DistrictSkeleton before Phase 2 is implemented. Include them.

OQ-R2-2: political_archetype — formal field or implicit in decomposition formula?

Formal field, but derived by default. Add political_archetype TEXT to atlas_cities in Phase 3 (Layer A above). For unnamed cities: derived from economic_role by the Python tooling. For named cities: wiki authors can supply an override column value (political_archetype_override). The decomposition formula in Phase 5 reads this field; it doesn't derive it inline.

This is cleaner than burying the derivation inside the district generator — the generator should receive a value, not compute lore classifications.

OQ-R2-3: Phase 2 tile algorithm — door-per-block-edge vs. density-driven rectangle placement?

Both. They target different levels:

  • door-per-block-edge defines the block's connectivity skeleton: where are the block boundaries, where do streets cross them, where do buildings face the street
  • density-driven rectangle placement fills in building footprints within the block skeleton

A block first gets its street edges (door-per-block-edge), then buildings are placed as density-appropriate rectangles inside the remaining space. This is how urban blocks actually work. Implement them in that order: skeleton first, fill second.

OQ-R2-4: Step 2b (full N-district city layout) — minimum slice or follow-on?

Follow-on. The minimum slice needs 1 district to demonstrate walkability. But the CityGenerationContext struct and the district grid data model must support N districts from day 1 — just don't generate the other districts yet. A district_grid_width: u8 and district_count: u8 on CityGenerationContext cost nothing now and prevent a data model migration later.

OQ-R2-5: Naming register lookup table — before Phase 2 or post-walkable?

Post-walkable. The naming register is a text overlay — it affects what street signs say, not whether tiles are walkable. It's a Vec<(DistrictType, WorldTier, NamingRegister)> lookup that the generator uses when filling the name field on blocks and streets. Add it as a follow-on ticket after Phase 2 produces a walkable district.

OQ-R2-6: prosperity_index for named cities — authored or derived?

Derived by default, authored override allowed. Add prosperity_override REAL column to atlas_cities (NULL = derived). The wiki author of Van Maanen's Star can supply an explicit value if the lore demands it. The Phase 3 generator checks: COALESCE(prosperity_override, <derived_formula>). Zero special-casing required.

OQ-R2-7: Naming register table ownership?

Paula proposes the register taxonomy. Tyre reviews against spatial constraints (does the register system produce contradictions for unusual WorldTier + DistrictType combos?). Mellanie populates name pools within each register. Lead locks the lookup table as a D-record. The implementation is a static TOML or RON file consumed by the Phase 5 district generator.


Generator Pipeline Integration

Phase 3 introduces a new generator: generate_regional.py. The make regen-db pipeline order becomes:

1. import_economics.py           (existing — economic data, brands)
2. generate_atlas.py             (existing — city placement, heightmaps, road pixel-paths)
3. generate_regional.py          (NEW — city classification, regional biomes, road graph, station topology)

generate_regional.py follows the same generator convention as generate_atlas.py:

  • Writes a meta stamp row to the meta table
  • Includes itself in the GENERATOR_SOURCES dict in tooling/check-systems-db-stamp
  • The pre-push hook validates its stamp alongside the existing generators

The /pr-push skill's source-file watch list must also be updated to include generate_regional.py.

Incremental mode: generate_regional.py --body GJ380c processes only one body (same as generate_atlas.py). --force overwrites existing regional data. --dry-run prints what would be written without touching the DB.


Summary

Phase 3 is a tooling and data enrichment phase. It produces:

  • 5 new or modified systems.db tables
  • 1 new Python generator (generate_regional.py)
  • CityGenerationContext Rust struct (read path only)

It bridges atlas output (city dots) to Phase 5 input (district generation context). It does not generate walkable tiles — that is Phase 5's job. What it does is give Phase 5 enough structured context to generate districts that are spatially appropriate to their location, biome, and political character.

Estimated effort: ~7 developer days, independent of and parallel to Phase 5 work (~6 days).

The Phase 3 deliverable — the Atlas of the Reach implant app — consumes this enriched systems.db data to display regional maps, city footprints, road connectivity, and biome zones. The atlas UI implementation is client-side work (stig's territory) that reads the tables Phase 3 produces.