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

599 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Tyre — Round 3: Phase 3 Technical Architecture"
description: "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"
type: workshop
status: active
workshop: generation-cascade
agent: tyre
round: 3
created: 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.
```python
# 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:
```sql
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).
```python
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:
```sql
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.
```python
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:
```sql
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`).
```python
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:**
```sql
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`
```python
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:**
```rust
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:**
```bash
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.