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>
19 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Gestalt — Round 2: Spatial Pipeline Proposal | Ordered implementation plan, thinnest vertical slice, stored-vs-derived transition, D-record candidates, and ticket dependency graph for the spatial generation pipeline | workshop | active | generation-cascade | gestalt | 2 | 2026-04-30 |
Generation Cascade — Round 2: Gestalt Proposal
Scope note: This proposal covers the spatial pipeline ONLY — from atlas city markers to walkable rendered tiles. Social sites, NPC population, heritage grammar, society profiles, and cultural overlays are out of scope. The one textual concern (place naming) is Paula's domain and does not block any spatial work.
1. Ordered Implementation Plan
Five sequential steps. Each one unlocks the next. Nothing can be parallelized safely until step 3 lands.
Step 1 — WorldTier Enum Fix
Effort: 0.5 days
Rename enum values in server/src/simulation/generator.rs:
// Before:
enum WorldTier { Peripheral, Connected, Core }
// After (workshop-locked, L-3):
enum WorldTier { Epicenter, Regional, Backwater, Passage, Waypoint }
Update every reference in the codebase. This is a rename, not a redesign. Must land first — it is the type foundation for every subsequent step.
Step 2 — City → District Decomposition (Layer 6)
Effort: 1.5 days (two sub-tasks)
Sub-task 2a (0.5d): Single-district generator input
For the minimum viable slice, take markers.json city[0] (the capital city) and produce one DistrictInput — the struct that feeds Phase 1. Full multi-district city layout (N districts per city, coordinate translation) is deferred.
struct DistrictInput {
district_id: DistrictId,
seed: u64, // derived: world_seed XOR hash(city_id) XOR district_index
world_tier: WorldTier, // derived: system WorldTier from systems.db
complexity: ComplexityTier, // derived: from world_tier ceiling + city.population
setting: SettingType, // derived: from body.planet_class + city.kind
district_type: DistrictType, // derived: from body.economic_role + city.kind
layout_mode: DistrictLayoutMode, // derived: from settlement_pattern
}
All fields are derived from inputs already available in systems.db + markers.json. No new stored data.
Sub-task 2b (1d): Full city decomposition + coordinate translation
After the minimum slice is working, extend to N districts per city:
- Population formula:
n_districts = max(1, city.population / 50_000)(capped at city atlas pixel footprint) - Atlas pixel → sim-tile coordinate mapping (Tyre's Gap B — see Section 4 below for the proposed formula)
- DistrictType assignment from economic_role for each district slot
This is the production-quality layer. The minimum slice does not need it.
Step 3 — Phase 1 DistrictSkeleton Spatial Generation
Effort: 2 days (Stages 1–2 only)
Implement the two spatial stages that Phase 2 actually needs. Defer Stages 3–5 (reservations, social sites, guarantee audit) — they are enhancements to an already-walkable world, not prerequisites.
Stage 1 — Classification (0.5d)
From DistrictInput, write real values to:
world_tier,complexity,setting,layout_mode(all trivial — copy from input)district_type(from input)z_levels(from SettingType: Urban=3, Agricultural=1, Station=4, etc.)
Stage 2 — Block grid (1.5d)
This is the non-trivial step. Assign ZoningType to each of the 16 blocks in the 4×4 grid:
DistrictTypedetermines the probability distribution acrossZoningTypevaluesDistrictLayoutMode(Grid vs Organic) determines block placement and rotationBlockSkeleton.density_pctderived from district type and block position (center blocks denser)- Seed-driven variation within the distribution — same seed always produces same grid
Example mapping:
| DistrictType | Zoning distribution |
|---|---|
| Commercial | 50% Commercial, 25% Mixed, 15% Residential, 10% Transit |
| Industrial | 50% Industrial, 25% Restricted, 15% Mixed, 10% Transit |
| Residential | 60% Residential, 25% Mixed, 15% Recreational |
| LogisticsHub | 40% Transit, 30% Industrial, 20% Commercial, 10% Mixed |
Fields deferred (left as stubs for now): reservations, corridors, social_sites, boundaries, guarantee_audit, zone_palette, society_profile, access_points.
Step 4 — Phase 2: Basic Chunk Tile Generator
Effort: 1.5 days
For each BlockSkeleton, fill 4 chunks (2×2 per block, so 64×64 tiles per chunk):
Minimum tile vocabulary needed:
// These are the ONLY tile types needed for a walkable generated slice
const TILE_FLOOR: TileId = "floor";
const TILE_WALL: TileId = "wall";
const TILE_DOOR: TileId = "door";
Fill algorithm:
- Outer 2 tiles of each chunk = wall perimeter
- Interior 60×60 = floor
- Each block edge shared with an adjacent block gets one door placement (seeded position within center third of edge)
GeneratorChunkData(currentlyVec<bool>) gets extended to carryTileIdper cell
This produces structurally boring but fully-walkable districts. Every block is a room. Every room has doors connecting to neighbors. The renderer can already handle this — it reads TileId data.
Note: ZoningType per block should tint the floor tile identifier so zones are visually distinguishable at this stage (e.g. "floor_commercial", "floor_industrial") — one line change per block, but makes the output informative.
Step 5 — Chunk Streaming → Generator Hookup
Effort: 1 day
Modify WalkabilityMap::load_chunk() to call the Phase 2 generator when a chunk coordinate has no YAML file. Requires:
- A
DistrictMapresource that mapsChunkCoord→DistrictId+BlockPosition(Step 2b contributes to this; Step 2a version maps everything to the one test district) - Phase 2 generator callable from chunk loading path (Step 4)
- Cache generated chunks in memory (don't regenerate on every access)
2. Thinnest Vertical Slice — Converged Definition
Converging Gestalt (5–7d) and Tyre (9d) estimates:
The discrepancy came from two sources: (1) Tyre's 2d estimate for city decomp vs my 0.5d, and (2) Tyre's Phase 1 estimate included workshop-outcomes.md effort figures for all 5 stages while I deferred stages 3–5.
With the stripped scope (no social sites, no heritage grammar, no society profiles), and with the "one district at world origin" definition of minimum viable, the converged estimate is:
| Step | Task | Effort |
|---|---|---|
| 1 | WorldTier enum fix | 0.5d |
| 2a | Single-district input stub | 0.5d |
| 3 | Phase 1 stages 1–2 (classification + block grid) | 2d |
| 4 | Phase 2 minimal (wall + floor + door per block) | 1.5d |
| 5 | Chunk streaming hookup | 1d |
| — | Total minimum | 5.5 days |
What this produces: A player can spawn in a generated city district, walk through structurally distinct (but visually basic) rooms, cross door thresholds between blocks, and see zone-tinted floor tiles. The streaming system loads/unloads chunks correctly. The pipeline is end-to-end driven by city data in systems.db + markers.json — no hand-authored YAML maps involved.
What this explicitly does NOT include:
- Step 2b (full N-district city layout and coordinate translation) — 1d, separate ticket
- Phase 1 stages 3–5 (reservations, social sites, guarantee audit) — deferred
- Vertical structures (z_levels > 1) — deferred to Phase 4 deliverable (2-floor test map)
- Tile variety (single TILE_FLOOR type per zone tint) — deferred to heritage grammar sprint
3. The Stored vs. Derived Transition
The lead's question: Where in the pipeline does the transition from explicitly-stored data to seed-based deterministic derivation happen?
Answer: At the boundary between atlas markers and city → district decomposition (between Layer 5 and Layer 6).
Everything at Layer 5 and above is stored because it required human judgment (hand-refinement #838), expensive computation (terrain simulation, A* routing), or external process output (Gemma naming). Everything at Layer 6 and below is derived because it has no human-refinement phase and the combinatorial space is too large to store.
What Is Explicitly Stored
| Data | Where | Why stored |
|---|---|---|
| Star system definitions | systems.db | Authored from wiki markdown |
| Body parameters (population, planet_class, economic_role) | systems.db | Authored; economically simulated |
| City positions (atlas_cities rows) | systems.db + markers.json | Generated once; hand-refinable (#838) |
| City populations | systems.db + markers.json | Authored from wiki population data |
| Road/rail path topology | markers.json | Expensive A* computation; spatial structure that cannot be cheaply re-derived |
| River/ocean/mountain features | markers.json | Authored or terrain-simulation output |
| City names | markers.json | Gemma output; expensive to regenerate |
What Is Seed-Derived
| Data | Seed derivation | Why derived |
|---|---|---|
| District count per city | population / 50_000 — deterministic formula |
No human judgment needed |
| District WorldTier/ComplexityTier | system_tier lookup + population_tier formula |
Deterministic from stored inputs |
| DistrictSkeleton.seed | world_seed XOR fnv1a(city_id) XOR district_index |
Deterministic identity |
| Block grid (4×4 ZoningType) | Seeded from district.seed |
Too many blocks to author |
| Block placement (Organic mode offsets) | Seeded from district.seed + block_position |
Same |
| Chunk tile layout | Seeded from block.seed |
Too many chunks to author |
| Building door positions | Seeded from chunk_coord + edge_id |
Same |
The Save File Corollary
This stored-vs-derived split determines what the save file must contain:
- Save file stores: Delta layer only —
DamageOverlayentries, player state, event history - Save file does NOT store: Any base world data (chunk tiles, block grid) — these are rederived from the master seed at load time
- Implication: Changing the generation algorithm between saves would corrupt existing saves (rederived world no longer matches player's remembered state). This is the same problem as save migrations in Dwarf Fortress. This workshop should produce a D-record acknowledging the constraint.
City Marker Schema (OQ-R1-C Resolution)
The city marker schema gap (Miri/Tyre Gap A3/Gap B) has a clear answer in this framework:
Do not extend markers.json. Instead, resolve primary_function, planet_class, settlement_pattern via systems.db cross-reference using body_id.
Rationale: city markers are the stored anchor. Extending the schema means every pipeline run must correctly populate new fields — another source of drift. The systems.db already has economic_role, planet_class, settlement_pattern on the bodies table. The city → district decomposition step queries these directly by body_id. This keeps markers.json as a pure spatial-plus-naming store and systems.db as the authoritative parameter store.
4. Atlas Coordinate Translation (Resolving OQ-R1-D)
Tyre's Gap B: no formula maps atlas pixel coordinates to sim-tile coordinates.
Proposed formula:
The atlas grid is 512×256 pixels. Each pixel represents a terrain cell. A city's center: [row, col] is in this pixel space.
For the minimum slice, we don't need this — we spawn one district at world origin. But for Step 2b (full city layout), we need it.
The relationship between atlas pixels and the world map is:
- One atlas pixel ≈ one degree of surface area at the sim abstraction level
- A district is 512×512 sim tiles = 256×256 visual tiles = 256m
- The world size at simulation scale is to be determined (this is an open spec question)
Pragmatic proposal: Assign one district per city for the minimum slice (Step 2a). Defer the coordinate translation to a spec decision in Round 3 or a separate architecture D-record. The question "how big is the walkable world relative to the atlas" is a scope decision, not a pipeline decision.
5. D-Record Candidates
Four D-records for the spatial pipeline. These can be filed after Round 3 convergence.
D-CANDIDATE-1: Generation Pipeline Layer Definitions
Canonical layer sequence for the spatial generation pipeline:
| Layer | Name | Input | Output | Storage |
|---|---|---|---|---|
| 1–5 | Atlas layers (existing) | Wiki + authored content | systems.db, markers.json | Explicitly stored |
| 6 | City → District decomposition | City entry + systems.db body params | DistrictInput structs | Derived, not stored |
| 7 | Phase 1: DistrictSkeleton | DistrictInput | DistrictSkeleton (spatial stages) | Derived, not stored |
| 8 | Phase 2: ChunkData fill | BlockSkeleton + DistrictSkeleton | 64×64 tile grid | Derived, cached in session |
| 9 | Chunk streaming | Player position | On-demand Phase 2 invocation | Architecture layer |
The cliff between Layer 5 and Layer 6 is the stored-vs-derived transition (see D-CANDIDATE-2).
D-CANDIDATE-2: Stored-vs-Derived Transition Point
Atlas marker data (city positions, populations, road topology) is explicitly stored because it has a human-refinement phase and is expensive to recompute. Everything at Layer 6 and below is seed-derived at runtime. Save files store only the delta layer (DamageOverlay + player state) — not base world data. Changing generation algorithms between a player's saves is a breaking change and must be treated as a migration.
Implication for implementation: The generation pipeline must be stable once a world seed is committed to. A player's save file encodes the world seed as a root; all base world data derives from it. Version the generation algorithm separately from the game version.
D-CANDIDATE-3: Minimum Phase 1 Spatial Scope
Phase 1 DistrictSkeleton generation has two mandatory spatial stages and three optional stages. Only stages 1–2 are prerequisites for Phase 2 tile generation:
- Stage 1 (mandatory): Classification — WorldTier, ComplexityTier, SettingType, DistrictType, LayoutMode, z_levels
- Stage 2 (mandatory): Block grid — 4×4 BlockSkeleton array with ZoningType, density_pct, seed
- Stage 3 (optional, adds value): Reservations — MultiBlockReservation for parks, terminals, plazas
- Stage 4 (optional, adds value): Social sites — SocialSitePlacement (deferred to content pipeline work)
- Stage 5 (optional, adds value): Guarantee audit — spatial invariant checks (deferred to post-walkable sprint)
Phases 2 reads only Stage 1+2 output to generate walkable tiles. Stages 3–5 enrich an already-walkable world.
D-CANDIDATE-4: City → District Decomposition Formula
- District count:
max(1, floor(city.population / 50_000)), capped at world_tier district ceiling - WorldTier ceiling: Epicenter → uncapped; Regional → 8; Backwater → 4; Passage → 2; Waypoint → 1
- Seed chain:
district.seed = world_seed XOR fnv1a(city_id) XOR district_index(deterministic, no entropy) - DistrictType assignment: Derived from body.economic_role (primary function) + city.kind (capital/city role)
- Cross-reference source: systems.db
bodiestable, not markers.json (avoids schema extension)
6. Ticket Dependency Graph (Spatial Pipeline Only)
NEW: Fix WorldTier enum (generator.rs rename)
│
├──▶ NEW: City → district decomposition — single district (Step 2a)
│ │
│ └──▶ #899 (rescoped): Phase 1 DistrictSkeleton — stages 1-2 only
│ │
│ ├──▶ NEW: Phase 2 — basic chunk tile generator (Step 4)
│ │ │
│ │ └──▶ NEW: Connect chunk_streaming to generator (Step 5)
│ │ │
│ │ └──▶ [MINIMUM VIABLE WALKABLE WORLD]
│ │
│ └──▶ NEW: Add missing DistrictSkeleton fields (Tyre Gap C — 0.5d)
│ (vertical_structure, breach_only_zones, derived_analysis as stubs)
│
└──▶ NEW: Full city decomposition + coordinate translation (Step 2b, follow-on)
(not required for minimum slice; unblocks multi-district cities)
Existing Ticket Positions (Spatial Scope Only)
| Ticket | Title | Recommendation |
|---|---|---|
| #899 | District skeleton generator — Phase 1 | Rescope: stages 1–2 only, no social sites. Remove Stage 4–5 from scope. Add explicit dependency on WorldTier fix + new city decomp ticket. |
| #681 | Apartment generator (server) | Add formal block behind "Minimum Viable Walkable World" (chunk streaming hookup). Apartment zones require real district context. Current blockers (#615, #679) are insufficient. |
| #682 | Apartment visual rendering | Correctly blocked by #681. No changes. |
| #619 | Full character customisation | No generation dependency. Phase 4 work. Do not add generation blockers. |
| #694 | Character creation screen | No generation dependency. Phase 4 work. Do not add generation blockers. |
| #616 | Economic verb vocabulary | No generation dependency. Content design work, can proceed anytime. |
| #156 | Procedural district filler | Superseded by Phase 2 chunk tile generator. Close or mark wontfix; new ticket replaces it. |
| #100 | Ungenerated → instantiation | Superseded by chunk_streaming hookup ticket. Close or fold into new ticket. |
| #160 | Tier 3 procedural generator | Pre-workshop, unclear scope. Defer re-scoping to post-walkable sprint. |
#615 Position (OQ-R1-F Resolution)
With the scope correction (no NPC work in this workshop), the #615 question is moot for this workshop. Tyre argued it should block behind generation pipeline; my position was content design can proceed independently. With the lead's correction that #615 is NPC territory and out of scope, neither position is relevant to this workshop. #615 should be addressed in the NPC pipeline planning sprint, not here.
Summary
| Item | Answer |
|---|---|
| Minimum viable slice effort | 5.5 days (stripped scope) |
| Stored-vs-derived transition | Between Layer 5 and Layer 6 — atlas markers are the last stored layer |
| City marker schema extension? | No — cross-reference systems.db bodies table by body_id |
| #619/#694 blocked by generation? | No — Phase 4 UI work, independent |
| #681 additional blocker needed? | Yes — behind "minimum viable walkable world" milestone |
| #899 scope change needed? | Yes — stages 1–2 only, strip Stage 4–5 |
| Pre-workshop tickets (#100, #156) | Close/supersede — new tickets replace them |