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>
599 lines
30 KiB
Markdown
599 lines
30 KiB
Markdown
---
|
||
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.
|